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:
Test User
2025-12-29 17:35:55 -05:00
parent 579246dc26
commit 469ee5ff85
3 changed files with 190 additions and 21 deletions

View File

@@ -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' };

View File

@@ -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<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
*
@@ -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,

View File

@@ -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<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 {
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 = () => {