mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
security: harden API authentication system
- Use crypto.timingSafeEqual() for API key validation (prevents timing attacks) - Make WebSocket tokens single-use (invalidated after first validation) - Add AUTOMAKER_HIDE_API_KEY env var to suppress API key banner in logs - Add rate limiting to login endpoint (5 attempts/minute/IP) - Update client to fetch short-lived wsToken for WebSocket auth (session tokens no longer exposed in URLs) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -127,8 +127,9 @@ function ensureApiKey(): string {
|
|||||||
// API key - always generated/loaded on startup for CSRF protection
|
// API key - always generated/loaded on startup for CSRF protection
|
||||||
const API_KEY = ensureApiKey();
|
const API_KEY = ensureApiKey();
|
||||||
|
|
||||||
// Print API key to console for web mode users
|
// Print API key to console for web mode users (unless suppressed for production logging)
|
||||||
console.log(`
|
if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
|
||||||
|
console.log(`
|
||||||
╔═══════════════════════════════════════════════════════════════════════╗
|
╔═══════════════════════════════════════════════════════════════════════╗
|
||||||
║ 🔐 API Key for Web Mode Authentication ║
|
║ 🔐 API Key for Web Mode Authentication ║
|
||||||
╠═══════════════════════════════════════════════════════════════════════╣
|
╠═══════════════════════════════════════════════════════════════════════╣
|
||||||
@@ -140,6 +141,9 @@ console.log(`
|
|||||||
║ In Electron mode, authentication is handled automatically. ║
|
║ In Electron mode, authentication is handled automatically. ║
|
||||||
╚═══════════════════════════════════════════════════════════════════════╝
|
╚═══════════════════════════════════════════════════════════════════════╝
|
||||||
`);
|
`);
|
||||||
|
} else {
|
||||||
|
console.log('[Auth] API key banner hidden (AUTOMAKER_HIDE_API_KEY=true)');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a cryptographically secure session token
|
* Generate a cryptographically secure session token
|
||||||
@@ -205,13 +209,17 @@ export function createWsConnectionToken(): string {
|
|||||||
/**
|
/**
|
||||||
* Validate a WebSocket connection token
|
* Validate a WebSocket connection token
|
||||||
* These tokens are single-use and short-lived (5 minutes)
|
* These tokens are single-use and short-lived (5 minutes)
|
||||||
|
* Token is invalidated immediately after first successful use
|
||||||
*/
|
*/
|
||||||
export function validateWsConnectionToken(token: string): boolean {
|
export function validateWsConnectionToken(token: string): boolean {
|
||||||
const tokenData = wsConnectionTokens.get(token);
|
const tokenData = wsConnectionTokens.get(token);
|
||||||
if (!tokenData) return false;
|
if (!tokenData) return false;
|
||||||
|
|
||||||
|
// Always delete the token (single-use)
|
||||||
|
wsConnectionTokens.delete(token);
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
if (Date.now() > tokenData.expiresAt) {
|
if (Date.now() > tokenData.expiresAt) {
|
||||||
wsConnectionTokens.delete(token);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,10 +227,23 @@ export function validateWsConnectionToken(token: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate the API key
|
* Validate the API key using timing-safe comparison
|
||||||
|
* Prevents timing attacks that could leak information about the key
|
||||||
*/
|
*/
|
||||||
export function validateApiKey(key: string): boolean {
|
export function validateApiKey(key: string): boolean {
|
||||||
return key === API_KEY;
|
if (!key || typeof key !== 'string') return false;
|
||||||
|
|
||||||
|
// Both buffers must be the same length for timingSafeEqual
|
||||||
|
const keyBuffer = Buffer.from(key);
|
||||||
|
const apiKeyBuffer = Buffer.from(API_KEY);
|
||||||
|
|
||||||
|
// If lengths differ, compare against a dummy to maintain constant time
|
||||||
|
if (keyBuffer.length !== apiKeyBuffer.length) {
|
||||||
|
crypto.timingSafeEqual(apiKeyBuffer, apiKeyBuffer);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.timingSafeEqual(keyBuffer, apiKeyBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -270,7 +291,7 @@ function checkAuthentication(
|
|||||||
// Check for API key in header (Electron mode)
|
// Check for API key in header (Electron mode)
|
||||||
const headerKey = headers['x-api-key'] as string | undefined;
|
const headerKey = headers['x-api-key'] as string | undefined;
|
||||||
if (headerKey) {
|
if (headerKey) {
|
||||||
if (headerKey === API_KEY) {
|
if (validateApiKey(headerKey)) {
|
||||||
return { authenticated: true };
|
return { authenticated: true };
|
||||||
}
|
}
|
||||||
return { authenticated: false, errorType: 'invalid_api_key' };
|
return { authenticated: false, errorType: 'invalid_api_key' };
|
||||||
@@ -288,7 +309,7 @@ function checkAuthentication(
|
|||||||
// Check for API key in query parameter (fallback)
|
// Check for API key in query parameter (fallback)
|
||||||
const queryKey = query.apiKey;
|
const queryKey = query.apiKey;
|
||||||
if (queryKey) {
|
if (queryKey) {
|
||||||
if (queryKey === API_KEY) {
|
if (validateApiKey(queryKey)) {
|
||||||
return { authenticated: true };
|
return { authenticated: true };
|
||||||
}
|
}
|
||||||
return { authenticated: false, errorType: 'invalid_api_key' };
|
return { authenticated: false, errorType: 'invalid_api_key' };
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import type { Request } from 'express';
|
||||||
import {
|
import {
|
||||||
validateApiKey,
|
validateApiKey,
|
||||||
createSession,
|
createSession,
|
||||||
@@ -23,6 +24,83 @@ import {
|
|||||||
createWsConnectionToken,
|
createWsConnectionToken,
|
||||||
} from '../../lib/auth.js';
|
} from '../../lib/auth.js';
|
||||||
|
|
||||||
|
// Rate limiting configuration
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute window
|
||||||
|
const RATE_LIMIT_MAX_ATTEMPTS = 5; // Max 5 attempts per window
|
||||||
|
|
||||||
|
// In-memory rate limit tracking (resets on server restart)
|
||||||
|
const loginAttempts = new Map<string, { count: number; windowStart: number }>();
|
||||||
|
|
||||||
|
// Clean up old rate limit entries periodically (every 5 minutes)
|
||||||
|
setInterval(
|
||||||
|
() => {
|
||||||
|
const now = Date.now();
|
||||||
|
loginAttempts.forEach((data, ip) => {
|
||||||
|
if (now - data.windowStart > RATE_LIMIT_WINDOW_MS * 2) {
|
||||||
|
loginAttempts.delete(ip);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
5 * 60 * 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client IP address from request
|
||||||
|
* Handles X-Forwarded-For header for reverse proxy setups
|
||||||
|
*/
|
||||||
|
function getClientIp(req: Request): string {
|
||||||
|
const forwarded = req.headers['x-forwarded-for'];
|
||||||
|
if (forwarded) {
|
||||||
|
// X-Forwarded-For can be a comma-separated list; take the first (original client)
|
||||||
|
const forwardedIp = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0];
|
||||||
|
return forwardedIp.trim();
|
||||||
|
}
|
||||||
|
return req.ip || req.socket.remoteAddress || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an IP is rate limited
|
||||||
|
* Returns { limited: boolean, retryAfter?: number }
|
||||||
|
*/
|
||||||
|
function checkRateLimit(ip: string): { limited: boolean; retryAfter?: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const attempt = loginAttempts.get(ip);
|
||||||
|
|
||||||
|
if (!attempt) {
|
||||||
|
return { limited: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if window has expired
|
||||||
|
if (now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) {
|
||||||
|
loginAttempts.delete(ip);
|
||||||
|
return { limited: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if over limit
|
||||||
|
if (attempt.count >= RATE_LIMIT_MAX_ATTEMPTS) {
|
||||||
|
const retryAfter = Math.ceil((RATE_LIMIT_WINDOW_MS - (now - attempt.windowStart)) / 1000);
|
||||||
|
return { limited: true, retryAfter };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { limited: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a login attempt for rate limiting
|
||||||
|
*/
|
||||||
|
function recordLoginAttempt(ip: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const attempt = loginAttempts.get(ip);
|
||||||
|
|
||||||
|
if (!attempt || now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) {
|
||||||
|
// Start new window
|
||||||
|
loginAttempts.set(ip, { count: 1, windowStart: now });
|
||||||
|
} else {
|
||||||
|
// Increment existing window
|
||||||
|
attempt.count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create auth routes
|
* Create auth routes
|
||||||
*
|
*
|
||||||
@@ -51,8 +129,23 @@ export function createAuthRoutes(): Router {
|
|||||||
*
|
*
|
||||||
* Validates the API key and sets a session cookie.
|
* Validates the API key and sets a session cookie.
|
||||||
* Body: { apiKey: string }
|
* Body: { apiKey: string }
|
||||||
|
*
|
||||||
|
* Rate limited to 5 attempts per minute per IP to prevent brute force attacks.
|
||||||
*/
|
*/
|
||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
|
const clientIp = getClientIp(req);
|
||||||
|
|
||||||
|
// Check rate limit before processing
|
||||||
|
const rateLimit = checkRateLimit(clientIp);
|
||||||
|
if (rateLimit.limited) {
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Too many login attempts. Please try again later.',
|
||||||
|
retryAfter: rateLimit.retryAfter,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { apiKey } = req.body as { apiKey?: string };
|
const { apiKey } = req.body as { apiKey?: string };
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -63,6 +156,9 @@ export function createAuthRoutes(): Router {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record this attempt (only for actual API key validation attempts)
|
||||||
|
recordLoginAttempt(clientIp);
|
||||||
|
|
||||||
if (!validateApiKey(apiKey)) {
|
if (!validateApiKey(apiKey)) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -230,6 +230,44 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.connectWebSocket();
|
this.connectWebSocket();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a short-lived WebSocket token from the server
|
||||||
|
* Used for secure WebSocket authentication without exposing session tokens in URLs
|
||||||
|
*/
|
||||||
|
private async fetchWsToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add session token header if available
|
||||||
|
const sessionToken = getSessionToken();
|
||||||
|
if (sessionToken) {
|
||||||
|
headers['X-Session-Token'] = sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.serverUrl}/api/auth/token`, {
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('[HttpApiClient] Failed to fetch wsToken:', response.status);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success && data.token) {
|
||||||
|
return data.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HttpApiClient] Error fetching wsToken:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private connectWebSocket(): void {
|
private connectWebSocket(): void {
|
||||||
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
|
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
|
||||||
return;
|
return;
|
||||||
@@ -237,23 +275,37 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
|
|
||||||
this.isConnecting = true;
|
this.isConnecting = true;
|
||||||
|
|
||||||
try {
|
// In Electron mode, use API key directly
|
||||||
let wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
const apiKey = getApiKey();
|
||||||
|
if (apiKey) {
|
||||||
|
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
||||||
|
this.establishWebSocket(`${wsUrl}?apiKey=${encodeURIComponent(apiKey)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// In Electron mode, add API key as query param for WebSocket auth
|
// In web mode, fetch a short-lived wsToken first
|
||||||
// (WebSocket doesn't support custom headers in browser)
|
this.fetchWsToken()
|
||||||
const apiKey = getApiKey();
|
.then((wsToken) => {
|
||||||
if (apiKey) {
|
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
||||||
wsUrl += `?apiKey=${encodeURIComponent(apiKey)}`;
|
if (wsToken) {
|
||||||
} else {
|
this.establishWebSocket(`${wsUrl}?wsToken=${encodeURIComponent(wsToken)}`);
|
||||||
// In web mode, add session token as query param
|
} else {
|
||||||
// (cookies may not work cross-origin, so use explicit token)
|
// Fallback: try connecting without token (will fail if not authenticated)
|
||||||
const sessionToken = getSessionToken();
|
console.warn('[HttpApiClient] No wsToken available, attempting connection anyway');
|
||||||
if (sessionToken) {
|
this.establishWebSocket(wsUrl);
|
||||||
wsUrl += `?sessionToken=${encodeURIComponent(sessionToken)}`;
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[HttpApiClient] Failed to prepare WebSocket connection:', error);
|
||||||
|
this.isConnecting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Establish the actual WebSocket connection
|
||||||
|
*/
|
||||||
|
private establishWebSocket(wsUrl: string): void {
|
||||||
|
try {
|
||||||
this.ws = new WebSocket(wsUrl);
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user