diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts index 89d99ba3..d8629d61 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -127,8 +127,9 @@ function ensureApiKey(): string { // API key - always generated/loaded on startup for CSRF protection const API_KEY = ensureApiKey(); -// Print API key to console for web mode users -console.log(` +// Print API key to console for web mode users (unless suppressed for production logging) +if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') { + console.log(` ╔═══════════════════════════════════════════════════════════════════════╗ ║ 🔐 API Key for Web Mode Authentication ║ ╠═══════════════════════════════════════════════════════════════════════╣ @@ -140,6 +141,9 @@ console.log(` ║ 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 @@ -205,13 +209,17 @@ export function createWsConnectionToken(): string { /** * Validate a WebSocket connection token * These tokens are single-use and short-lived (5 minutes) + * Token is invalidated immediately after first successful use */ export function validateWsConnectionToken(token: string): boolean { const tokenData = wsConnectionTokens.get(token); if (!tokenData) return false; + // Always delete the token (single-use) + wsConnectionTokens.delete(token); + + // Check if expired if (Date.now() > tokenData.expiresAt) { - wsConnectionTokens.delete(token); 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 { - 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) const headerKey = headers['x-api-key'] as string | undefined; if (headerKey) { - if (headerKey === API_KEY) { + if (validateApiKey(headerKey)) { return { authenticated: true }; } return { authenticated: false, errorType: 'invalid_api_key' }; @@ -288,7 +309,7 @@ function checkAuthentication( // Check for API key in query parameter (fallback) const queryKey = query.apiKey; if (queryKey) { - if (queryKey === API_KEY) { + if (validateApiKey(queryKey)) { return { authenticated: true }; } return { authenticated: false, errorType: 'invalid_api_key' }; diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts index 81aa5583..26b41638 100644 --- a/apps/server/src/routes/auth/index.ts +++ b/apps/server/src/routes/auth/index.ts @@ -13,6 +13,7 @@ */ import { Router } from 'express'; +import type { Request } from 'express'; import { validateApiKey, createSession, @@ -23,6 +24,83 @@ import { createWsConnectionToken, } 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(); + +// 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 * @@ -51,8 +129,23 @@ export function createAuthRoutes(): Router { * * Validates the API key and sets a session cookie. * Body: { apiKey: string } + * + * Rate limited to 5 attempts per minute per IP to prevent brute force attacks. */ 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 }; if (!apiKey) { @@ -63,6 +156,9 @@ export function createAuthRoutes(): Router { return; } + // Record this attempt (only for actual API key validation attempts) + recordLoginAttempt(clientIp); + if (!validateApiKey(apiKey)) { res.status(401).json({ success: false, diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index f952c930..44a4f459 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -230,6 +230,44 @@ export class HttpApiClient implements ElectronAPI { 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 { + try { + const headers: Record = { + '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 { if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) { return; @@ -237,23 +275,37 @@ export class HttpApiClient implements ElectronAPI { this.isConnecting = true; - try { - let wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events'; + // In Electron mode, use API key directly + 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 - // (WebSocket doesn't support custom headers in browser) - const apiKey = getApiKey(); - if (apiKey) { - wsUrl += `?apiKey=${encodeURIComponent(apiKey)}`; - } else { - // In web mode, add session token as query param - // (cookies may not work cross-origin, so use explicit token) - const sessionToken = getSessionToken(); - if (sessionToken) { - wsUrl += `?sessionToken=${encodeURIComponent(sessionToken)}`; + // In web mode, fetch a short-lived wsToken first + this.fetchWsToken() + .then((wsToken) => { + const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events'; + if (wsToken) { + this.establishWebSocket(`${wsUrl}?wsToken=${encodeURIComponent(wsToken)}`); + } else { + // Fallback: try connecting without token (will fail if not authenticated) + console.warn('[HttpApiClient] No wsToken available, attempting connection anyway'); + this.establishWebSocket(wsUrl); } - } + }) + .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.onopen = () => {