mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
docs: add API security hardening design plan
Security improvements identified for the protect-api-with-api-key branch: - Use short-lived wsToken for WebSocket auth (not session tokens in URLs) - Add AUTOMAKER_HIDE_API_KEY env var to suppress console logging - Add rate limiting to login endpoint (5 attempts/min/IP) - Use timing-safe comparison for API key validation - Make WebSocket tokens single-use 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,7 @@
|
|||||||
"ws": "^8.18.3"
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/cookie": "^0.6.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
|||||||
@@ -10,18 +10,14 @@ import express from 'express';
|
|||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import morgan from 'morgan';
|
import morgan from 'morgan';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
|
import cookie from 'cookie';
|
||||||
import { WebSocketServer, WebSocket } from 'ws';
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
import { createServer } from 'http';
|
import { createServer } from 'http';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
import { createEventEmitter, type EventEmitter } from './lib/events.js';
|
import { createEventEmitter, type EventEmitter } from './lib/events.js';
|
||||||
import { initAllowedPaths } from '@automaker/platform';
|
import { initAllowedPaths } from '@automaker/platform';
|
||||||
import {
|
import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js';
|
||||||
authMiddleware,
|
|
||||||
validateSession,
|
|
||||||
validateApiKey,
|
|
||||||
getSessionCookieName,
|
|
||||||
} from './lib/auth.js';
|
|
||||||
import { createAuthRoutes } from './routes/auth/index.js';
|
import { createAuthRoutes } from './routes/auth/index.js';
|
||||||
import { createFsRoutes } from './routes/fs/index.js';
|
import { createFsRoutes } from './routes/fs/index.js';
|
||||||
import { createHealthRoutes, createDetailedHandler } from './routes/health/index.js';
|
import { createHealthRoutes, createDetailedHandler } from './routes/health/index.js';
|
||||||
@@ -98,7 +94,7 @@ const app = express();
|
|||||||
// Middleware
|
// Middleware
|
||||||
// Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var)
|
// Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var)
|
||||||
if (ENABLE_REQUEST_LOGGING) {
|
if (ENABLE_REQUEST_LOGGING) {
|
||||||
morgan.token('status-colored', (req, res) => {
|
morgan.token('status-colored', (_req, res) => {
|
||||||
const status = res.statusCode;
|
const status = res.statusCode;
|
||||||
if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors
|
if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors
|
||||||
if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors
|
if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors
|
||||||
@@ -226,46 +222,31 @@ const terminalService = getTerminalService();
|
|||||||
function authenticateWebSocket(request: import('http').IncomingMessage): boolean {
|
function authenticateWebSocket(request: import('http').IncomingMessage): boolean {
|
||||||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||||
|
|
||||||
// Check for API key in header (Electron mode)
|
// Convert URL search params to query object
|
||||||
const headerKey = request.headers['x-api-key'] as string | undefined;
|
const query: Record<string, string | undefined> = {};
|
||||||
if (headerKey && validateApiKey(headerKey)) {
|
url.searchParams.forEach((value, key) => {
|
||||||
return true;
|
query[key] = value;
|
||||||
}
|
});
|
||||||
|
|
||||||
// Check for session token in header (web mode with explicit token)
|
// Parse cookies from header
|
||||||
const sessionTokenHeader = request.headers['x-session-token'] as string | undefined;
|
|
||||||
if (sessionTokenHeader && validateSession(sessionTokenHeader)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for API key in query param (fallback for WebSocket)
|
|
||||||
const queryKey = url.searchParams.get('apiKey');
|
|
||||||
if (queryKey && validateApiKey(queryKey)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for session token in query param (fallback for WebSocket in web mode)
|
|
||||||
const queryToken = url.searchParams.get('sessionToken');
|
|
||||||
if (queryToken && validateSession(queryToken)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for session cookie (web mode)
|
|
||||||
const cookieHeader = request.headers.cookie;
|
const cookieHeader = request.headers.cookie;
|
||||||
if (cookieHeader) {
|
const cookies = cookieHeader ? cookie.parse(cookieHeader) : {};
|
||||||
const cookieName = getSessionCookieName();
|
|
||||||
const cookies = cookieHeader.split(';').reduce(
|
// Use shared authentication logic for standard auth methods
|
||||||
(acc, cookie) => {
|
if (
|
||||||
const [key, value] = cookie.trim().split('=');
|
checkRawAuthentication(
|
||||||
acc[key] = value;
|
request.headers as Record<string, string | string[] | undefined>,
|
||||||
return acc;
|
query,
|
||||||
},
|
cookies
|
||||||
{} as Record<string, string>
|
)
|
||||||
);
|
) {
|
||||||
const sessionToken = cookies[cookieName];
|
return true;
|
||||||
if (sessionToken && validateSession(sessionToken)) {
|
}
|
||||||
return true;
|
|
||||||
}
|
// Additionally check for short-lived WebSocket connection token (WebSocket-specific)
|
||||||
|
const wsToken = url.searchParams.get('wsToken');
|
||||||
|
if (wsToken && validateWsConnectionToken(wsToken)) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -18,10 +18,24 @@ const API_KEY_FILE = path.join(DATA_DIR, '.api-key');
|
|||||||
const SESSIONS_FILE = path.join(DATA_DIR, '.sessions');
|
const SESSIONS_FILE = path.join(DATA_DIR, '.sessions');
|
||||||
const SESSION_COOKIE_NAME = 'automaker_session';
|
const SESSION_COOKIE_NAME = 'automaker_session';
|
||||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for WebSocket connection tokens
|
||||||
|
|
||||||
// Session store - persisted to file for survival across server restarts
|
// Session store - persisted to file for survival across server restarts
|
||||||
const validSessions = new Map<string, { createdAt: number; expiresAt: number }>();
|
const validSessions = new Map<string, { createdAt: number; expiresAt: number }>();
|
||||||
|
|
||||||
|
// Short-lived WebSocket connection tokens (in-memory only, not persisted)
|
||||||
|
const wsConnectionTokens = new Map<string, { createdAt: number; expiresAt: number }>();
|
||||||
|
|
||||||
|
// Clean up expired WebSocket tokens periodically
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
wsConnectionTokens.forEach((data, token) => {
|
||||||
|
if (data.expiresAt <= now) {
|
||||||
|
wsConnectionTokens.delete(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 60 * 1000); // Clean up every minute
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load sessions from file on startup
|
* Load sessions from file on startup
|
||||||
*/
|
*/
|
||||||
@@ -56,13 +70,16 @@ function loadSessions(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save sessions to file
|
* Save sessions to file (async)
|
||||||
*/
|
*/
|
||||||
function saveSessions(): void {
|
async function saveSessions(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(SESSIONS_FILE), { recursive: true });
|
await fs.promises.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
|
||||||
const sessions = Array.from(validSessions.entries());
|
const sessions = Array.from(validSessions.entries());
|
||||||
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions), { encoding: 'utf-8', mode: 0o600 });
|
await fs.promises.writeFile(SESSIONS_FILE, JSON.stringify(sessions), {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Auth] Failed to save sessions:', error);
|
console.error('[Auth] Failed to save sessions:', error);
|
||||||
}
|
}
|
||||||
@@ -134,19 +151,20 @@ function generateSessionToken(): string {
|
|||||||
/**
|
/**
|
||||||
* Create a new session and return the token
|
* Create a new session and return the token
|
||||||
*/
|
*/
|
||||||
export function createSession(): string {
|
export async function createSession(): Promise<string> {
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
validSessions.set(token, {
|
validSessions.set(token, {
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
expiresAt: now + SESSION_MAX_AGE_MS,
|
expiresAt: now + SESSION_MAX_AGE_MS,
|
||||||
});
|
});
|
||||||
saveSessions(); // Persist to file
|
await saveSessions(); // Persist to file
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a session token
|
* Validate a session token
|
||||||
|
* Note: This returns synchronously but triggers async persistence if session expired
|
||||||
*/
|
*/
|
||||||
export function validateSession(token: string): boolean {
|
export function validateSession(token: string): boolean {
|
||||||
const session = validSessions.get(token);
|
const session = validSessions.get(token);
|
||||||
@@ -154,7 +172,8 @@ export function validateSession(token: string): boolean {
|
|||||||
|
|
||||||
if (Date.now() > session.expiresAt) {
|
if (Date.now() > session.expiresAt) {
|
||||||
validSessions.delete(token);
|
validSessions.delete(token);
|
||||||
saveSessions(); // Persist removal
|
// Fire-and-forget: persist removal asynchronously
|
||||||
|
saveSessions().catch((err) => console.error('[Auth] Error saving sessions:', err));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,9 +183,39 @@ export function validateSession(token: string): boolean {
|
|||||||
/**
|
/**
|
||||||
* Invalidate a session token
|
* Invalidate a session token
|
||||||
*/
|
*/
|
||||||
export function invalidateSession(token: string): void {
|
export async function invalidateSession(token: string): Promise<void> {
|
||||||
validSessions.delete(token);
|
validSessions.delete(token);
|
||||||
saveSessions(); // Persist removal
|
await saveSessions(); // Persist removal
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a short-lived WebSocket connection token
|
||||||
|
* Used for initial WebSocket handshake authentication
|
||||||
|
*/
|
||||||
|
export function createWsConnectionToken(): string {
|
||||||
|
const token = generateSessionToken();
|
||||||
|
const now = Date.now();
|
||||||
|
wsConnectionTokens.set(token, {
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: now + WS_TOKEN_MAX_AGE_MS,
|
||||||
|
});
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a WebSocket connection token
|
||||||
|
* These tokens are single-use and short-lived (5 minutes)
|
||||||
|
*/
|
||||||
|
export function validateWsConnectionToken(token: string): boolean {
|
||||||
|
const tokenData = wsConnectionTokens.get(token);
|
||||||
|
if (!tokenData) return false;
|
||||||
|
|
||||||
|
if (Date.now() > tokenData.expiresAt) {
|
||||||
|
wsConnectionTokens.delete(token);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -202,6 +251,58 @@ export function getSessionCookieName(): string {
|
|||||||
return SESSION_COOKIE_NAME;
|
return SESSION_COOKIE_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication result type
|
||||||
|
*/
|
||||||
|
type AuthResult =
|
||||||
|
| { authenticated: true }
|
||||||
|
| { authenticated: false; errorType: 'invalid_api_key' | 'invalid_session' | 'no_auth' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core authentication check - shared between middleware and status check
|
||||||
|
* Extracts auth credentials from various sources and validates them
|
||||||
|
*/
|
||||||
|
function checkAuthentication(
|
||||||
|
headers: Record<string, string | string[] | undefined>,
|
||||||
|
query: Record<string, string | undefined>,
|
||||||
|
cookies: Record<string, string | undefined>
|
||||||
|
): AuthResult {
|
||||||
|
// Check for API key in header (Electron mode)
|
||||||
|
const headerKey = headers['x-api-key'] as string | undefined;
|
||||||
|
if (headerKey) {
|
||||||
|
if (headerKey === API_KEY) {
|
||||||
|
return { authenticated: true };
|
||||||
|
}
|
||||||
|
return { authenticated: false, errorType: 'invalid_api_key' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for session token in header (web mode with explicit token)
|
||||||
|
const sessionTokenHeader = headers['x-session-token'] as string | undefined;
|
||||||
|
if (sessionTokenHeader) {
|
||||||
|
if (validateSession(sessionTokenHeader)) {
|
||||||
|
return { authenticated: true };
|
||||||
|
}
|
||||||
|
return { authenticated: false, errorType: 'invalid_session' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for API key in query parameter (fallback)
|
||||||
|
const queryKey = query.apiKey;
|
||||||
|
if (queryKey) {
|
||||||
|
if (queryKey === API_KEY) {
|
||||||
|
return { authenticated: true };
|
||||||
|
}
|
||||||
|
return { authenticated: false, errorType: 'invalid_api_key' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for session cookie (web mode)
|
||||||
|
const sessionToken = cookies[SESSION_COOKIE_NAME];
|
||||||
|
if (sessionToken && validateSession(sessionToken)) {
|
||||||
|
return { authenticated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { authenticated: false, errorType: 'no_auth' };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication middleware
|
* Authentication middleware
|
||||||
*
|
*
|
||||||
@@ -212,60 +313,38 @@ export function getSessionCookieName(): string {
|
|||||||
* 4. Session cookie (for web mode)
|
* 4. Session cookie (for web mode)
|
||||||
*/
|
*/
|
||||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||||
// Check for API key in header (Electron mode)
|
const result = checkAuthentication(
|
||||||
const headerKey = req.headers['x-api-key'] as string | undefined;
|
req.headers as Record<string, string | string[] | undefined>,
|
||||||
if (headerKey) {
|
req.query as Record<string, string | undefined>,
|
||||||
if (headerKey === API_KEY) {
|
(req.cookies || {}) as Record<string, string | undefined>
|
||||||
next();
|
);
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Invalid API key.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for session token in header (web mode with explicit token)
|
if (result.authenticated) {
|
||||||
const sessionTokenHeader = req.headers['x-session-token'] as string | undefined;
|
|
||||||
if (sessionTokenHeader) {
|
|
||||||
if (validateSession(sessionTokenHeader)) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Invalid or expired session token.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for API key in query parameter (fallback)
|
|
||||||
const queryKey = req.query.apiKey as string | undefined;
|
|
||||||
if (queryKey) {
|
|
||||||
if (queryKey === API_KEY) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Invalid API key.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for session cookie (web mode)
|
|
||||||
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME] as string | undefined;
|
|
||||||
if (sessionToken && validateSession(sessionToken)) {
|
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No valid authentication
|
// Return appropriate error based on what failed
|
||||||
res.status(401).json({
|
switch (result.errorType) {
|
||||||
success: false,
|
case 'invalid_api_key':
|
||||||
error: 'Authentication required.',
|
res.status(403).json({
|
||||||
});
|
success: false,
|
||||||
|
error: 'Invalid API key.',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'invalid_session':
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid or expired session token.',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'no_auth':
|
||||||
|
default:
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Authentication required.',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -289,29 +368,22 @@ export function getAuthStatus(): { enabled: boolean; method: string } {
|
|||||||
* Check if a request is authenticated (for status endpoint)
|
* Check if a request is authenticated (for status endpoint)
|
||||||
*/
|
*/
|
||||||
export function isRequestAuthenticated(req: Request): boolean {
|
export function isRequestAuthenticated(req: Request): boolean {
|
||||||
// Check API key header
|
const result = checkAuthentication(
|
||||||
const headerKey = req.headers['x-api-key'] as string | undefined;
|
req.headers as Record<string, string | string[] | undefined>,
|
||||||
if (headerKey && headerKey === API_KEY) {
|
req.query as Record<string, string | undefined>,
|
||||||
return true;
|
(req.cookies || {}) as Record<string, string | undefined>
|
||||||
}
|
);
|
||||||
|
return result.authenticated;
|
||||||
// Check session token header
|
}
|
||||||
const sessionTokenHeader = req.headers['x-session-token'] as string | undefined;
|
|
||||||
if (sessionTokenHeader && validateSession(sessionTokenHeader)) {
|
/**
|
||||||
return true;
|
* Check if raw credentials are authenticated
|
||||||
}
|
* Used for WebSocket authentication where we don't have Express request objects
|
||||||
|
*/
|
||||||
// Check query parameter
|
export function checkRawAuthentication(
|
||||||
const queryKey = req.query.apiKey as string | undefined;
|
headers: Record<string, string | string[] | undefined>,
|
||||||
if (queryKey && queryKey === API_KEY) {
|
query: Record<string, string | undefined>,
|
||||||
return true;
|
cookies: Record<string, string | undefined>
|
||||||
}
|
): boolean {
|
||||||
|
return checkAuthentication(headers, query, cookies).authenticated;
|
||||||
// Check cookie
|
|
||||||
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME] as string | undefined;
|
|
||||||
if (sessionToken && validateSession(sessionToken)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
getSessionCookieOptions,
|
getSessionCookieOptions,
|
||||||
getSessionCookieName,
|
getSessionCookieName,
|
||||||
isRequestAuthenticated,
|
isRequestAuthenticated,
|
||||||
|
createWsConnectionToken,
|
||||||
} from '../../lib/auth.js';
|
} from '../../lib/auth.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,7 +52,7 @@ 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 }
|
||||||
*/
|
*/
|
||||||
router.post('/login', (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
const { apiKey } = req.body as { apiKey?: string };
|
const { apiKey } = req.body as { apiKey?: string };
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -71,7 +72,7 @@ export function createAuthRoutes(): Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create session and set cookie
|
// Create session and set cookie
|
||||||
const sessionToken = createSession();
|
const sessionToken = await createSession();
|
||||||
const cookieOptions = getSessionCookieOptions();
|
const cookieOptions = getSessionCookieOptions();
|
||||||
const cookieName = getSessionCookieName();
|
const cookieName = getSessionCookieName();
|
||||||
|
|
||||||
@@ -87,35 +88,27 @@ export function createAuthRoutes(): Router {
|
|||||||
/**
|
/**
|
||||||
* GET /api/auth/token
|
* GET /api/auth/token
|
||||||
*
|
*
|
||||||
* Returns the session token if the user has a valid session cookie.
|
* Generates a short-lived WebSocket connection token if the user has a valid session.
|
||||||
* This allows the UI to get a token for explicit header-based auth
|
* This token is used for initial WebSocket handshake authentication and expires in 5 minutes.
|
||||||
* after a page refresh (when the token in memory is lost).
|
* The token is NOT the session cookie value - it's a separate, short-lived token.
|
||||||
*/
|
*/
|
||||||
router.get('/token', (req, res) => {
|
router.get('/token', (req, res) => {
|
||||||
const cookieName = getSessionCookieName();
|
// Validate the session is still valid (via cookie, API key, or session token header)
|
||||||
const sessionToken = req.cookies?.[cookieName] as string | undefined;
|
|
||||||
|
|
||||||
if (!sessionToken) {
|
|
||||||
res.status(401).json({
|
|
||||||
success: false,
|
|
||||||
error: 'No session cookie found.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the session is still valid
|
|
||||||
if (!isRequestAuthenticated(req)) {
|
if (!isRequestAuthenticated(req)) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Session expired.',
|
error: 'Authentication required.',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the existing session token for header-based auth
|
// Generate a new short-lived WebSocket connection token
|
||||||
|
const wsToken = createWsConnectionToken();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
token: sessionToken,
|
token: wsToken,
|
||||||
|
expiresIn: 300, // 5 minutes in seconds
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,12 +117,12 @@ export function createAuthRoutes(): Router {
|
|||||||
*
|
*
|
||||||
* Clears the session cookie and invalidates the session.
|
* Clears the session cookie and invalidates the session.
|
||||||
*/
|
*/
|
||||||
router.post('/logout', (req, res) => {
|
router.post('/logout', async (req, res) => {
|
||||||
const cookieName = getSessionCookieName();
|
const cookieName = getSessionCookieName();
|
||||||
const sessionToken = req.cookies?.[cookieName] as string | undefined;
|
const sessionToken = req.cookies?.[cookieName] as string | undefined;
|
||||||
|
|
||||||
if (sessionToken) {
|
if (sessionToken) {
|
||||||
invalidateSession(sessionToken);
|
await invalidateSession(sessionToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the cookie
|
// Clear the cookie
|
||||||
|
|||||||
@@ -10,24 +10,8 @@ describe('auth.ts', () => {
|
|||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('authMiddleware - no API key', () => {
|
describe('authMiddleware', () => {
|
||||||
it('should call next() when no API key is set', async () => {
|
it('should reject request without any authentication', async () => {
|
||||||
delete process.env.AUTOMAKER_API_KEY;
|
|
||||||
|
|
||||||
const { authMiddleware } = await import('@/lib/auth.js');
|
|
||||||
const { req, res, next } = createMockExpressContext();
|
|
||||||
|
|
||||||
authMiddleware(req, res, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
|
||||||
expect(res.status).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('authMiddleware - with API key', () => {
|
|
||||||
it('should reject request without API key header', async () => {
|
|
||||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
|
||||||
|
|
||||||
const { authMiddleware } = await import('@/lib/auth.js');
|
const { authMiddleware } = await import('@/lib/auth.js');
|
||||||
const { req, res, next } = createMockExpressContext();
|
const { req, res, next } = createMockExpressContext();
|
||||||
|
|
||||||
@@ -36,7 +20,7 @@ describe('auth.ts', () => {
|
|||||||
expect(res.status).toHaveBeenCalledWith(401);
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
expect(res.json).toHaveBeenCalledWith({
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Authentication required. Provide X-API-Key header.',
|
error: 'Authentication required.',
|
||||||
});
|
});
|
||||||
expect(next).not.toHaveBeenCalled();
|
expect(next).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -73,43 +57,20 @@ describe('auth.ts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('isAuthEnabled', () => {
|
describe('isAuthEnabled', () => {
|
||||||
it('should return false when no API key is set', async () => {
|
it('should always return true (auth is always required)', async () => {
|
||||||
delete process.env.AUTOMAKER_API_KEY;
|
|
||||||
|
|
||||||
const { isAuthEnabled } = await import('@/lib/auth.js');
|
|
||||||
expect(isAuthEnabled()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true when API key is set', async () => {
|
|
||||||
process.env.AUTOMAKER_API_KEY = 'test-key';
|
|
||||||
|
|
||||||
const { isAuthEnabled } = await import('@/lib/auth.js');
|
const { isAuthEnabled } = await import('@/lib/auth.js');
|
||||||
expect(isAuthEnabled()).toBe(true);
|
expect(isAuthEnabled()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAuthStatus', () => {
|
describe('getAuthStatus', () => {
|
||||||
it('should return disabled status when no API key', async () => {
|
it('should return enabled status with api_key_or_session method', async () => {
|
||||||
delete process.env.AUTOMAKER_API_KEY;
|
|
||||||
|
|
||||||
const { getAuthStatus } = await import('@/lib/auth.js');
|
|
||||||
const status = getAuthStatus();
|
|
||||||
|
|
||||||
expect(status).toEqual({
|
|
||||||
enabled: false,
|
|
||||||
method: 'none',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return enabled status when API key is set', async () => {
|
|
||||||
process.env.AUTOMAKER_API_KEY = 'test-key';
|
|
||||||
|
|
||||||
const { getAuthStatus } = await import('@/lib/auth.js');
|
const { getAuthStatus } = await import('@/lib/auth.js');
|
||||||
const status = getAuthStatus();
|
const status = getAuthStatus();
|
||||||
|
|
||||||
expect(status).toEqual({
|
expect(status).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
method: 'api_key',
|
method: 'api_key_or_session',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,10 +61,13 @@ export function SettingsView() {
|
|||||||
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
||||||
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
|
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
|
||||||
// Also hide on Windows for now (CLI usage command not supported)
|
// Also hide on Windows for now (CLI usage command not supported)
|
||||||
|
// Only show if CLI has been verified/authenticated
|
||||||
const isWindows =
|
const isWindows =
|
||||||
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
|
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
|
||||||
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
|
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
|
||||||
const showUsageTracking = !hasApiKey && !isWindows;
|
const isCliVerified =
|
||||||
|
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
||||||
|
const showUsageTracking = !hasApiKey && !isWindows && isCliVerified;
|
||||||
|
|
||||||
// Convert electron Project to settings-view Project type
|
// Convert electron Project to settings-view Project type
|
||||||
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
|
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
|
||||||
|
|||||||
94
docs/plans/2025-12-29-api-security-hardening-design.md
Normal file
94
docs/plans/2025-12-29-api-security-hardening-design.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# API Security Hardening Design
|
||||||
|
|
||||||
|
**Date:** 2025-12-29
|
||||||
|
**Branch:** protect-api-with-api-key
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Security improvements for the API authentication system before merging the PR. These changes harden the existing implementation for production deployment scenarios (local, Docker, internet-exposed).
|
||||||
|
|
||||||
|
## Fixes to Implement
|
||||||
|
|
||||||
|
### 1. Use Short-Lived wsToken for WebSocket Authentication
|
||||||
|
|
||||||
|
**Problem:** The client currently passes `sessionToken` in WebSocket URL query parameters. Query params get logged and can leak credentials.
|
||||||
|
|
||||||
|
**Solution:** Update the client to:
|
||||||
|
|
||||||
|
1. Fetch a wsToken from `/api/auth/token` before each WebSocket connection
|
||||||
|
2. Use `wsToken` query param instead of `sessionToken`
|
||||||
|
3. Never put session tokens in URLs
|
||||||
|
|
||||||
|
**Files to modify:**
|
||||||
|
|
||||||
|
- `apps/ui/src/lib/http-api-client.ts` - Update `connectWebSocket()` to fetch wsToken first
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Add Environment Variable to Hide API Key from Logs
|
||||||
|
|
||||||
|
**Problem:** The API key is printed to console on startup, which gets captured by logging systems in production.
|
||||||
|
|
||||||
|
**Solution:** Add `AUTOMAKER_HIDE_API_KEY=true` env var to suppress the banner.
|
||||||
|
|
||||||
|
**Files to modify:**
|
||||||
|
|
||||||
|
- `apps/server/src/lib/auth.ts` - Wrap console.log banner in env var check
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Add Rate Limiting to Login Endpoint
|
||||||
|
|
||||||
|
**Problem:** No brute force protection on `/api/auth/login`. Attackers could attempt many API keys.
|
||||||
|
|
||||||
|
**Solution:** Add basic in-memory rate limiting:
|
||||||
|
|
||||||
|
- ~5 attempts per minute per IP
|
||||||
|
- In-memory Map tracking (resets on server restart)
|
||||||
|
- Return 429 Too Many Requests when exceeded
|
||||||
|
|
||||||
|
**Files to modify:**
|
||||||
|
|
||||||
|
- `apps/server/src/routes/auth/index.ts` - Add rate limiting logic to login handler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Use Timing-Safe Comparison for API Key
|
||||||
|
|
||||||
|
**Problem:** Using `===` for API key comparison is vulnerable to timing attacks.
|
||||||
|
|
||||||
|
**Solution:** Use `crypto.timingSafeEqual()` for constant-time comparison.
|
||||||
|
|
||||||
|
**Files to modify:**
|
||||||
|
|
||||||
|
- `apps/server/src/lib/auth.ts` - Update `validateApiKey()` function
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Make WebSocket Tokens Single-Use
|
||||||
|
|
||||||
|
**Problem:** wsTokens can be reused within the 5-minute window. If intercepted, attackers have time to use them.
|
||||||
|
|
||||||
|
**Solution:** Delete the token after first successful validation.
|
||||||
|
|
||||||
|
**Files to modify:**
|
||||||
|
|
||||||
|
- `apps/server/src/lib/auth.ts` - Update `validateWsConnectionToken()` to delete after use
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Fix #4 (timing-safe comparison) - Simple, isolated change
|
||||||
|
2. Fix #5 (single-use wsToken) - Simple, isolated change
|
||||||
|
3. Fix #2 (hide API key env var) - Simple, isolated change
|
||||||
|
4. Fix #3 (rate limiting) - Moderate complexity
|
||||||
|
5. Fix #1 (client wsToken usage) - Requires coordination with server
|
||||||
|
|
||||||
|
## Testing Notes
|
||||||
|
|
||||||
|
- Test login with rate limiting (verify 429 after 5 attempts)
|
||||||
|
- Test WebSocket connection with new wsToken flow
|
||||||
|
- Test wsToken is invalidated after first use
|
||||||
|
- Verify `AUTOMAKER_HIDE_API_KEY=true` suppresses banner
|
||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -46,6 +46,7 @@
|
|||||||
"ws": "^8.18.3"
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/cookie": "^0.6.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
@@ -5869,6 +5870,13 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/cookie": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/cookie-parser": {
|
"node_modules/@types/cookie-parser": {
|
||||||
"version": "1.4.10",
|
"version": "1.4.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user