mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +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:
@@ -10,18 +10,14 @@ import express from 'express';
|
||||
import cors from 'cors';
|
||||
import morgan from 'morgan';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import cookie from 'cookie';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import { createEventEmitter, type EventEmitter } from './lib/events.js';
|
||||
import { initAllowedPaths } from '@automaker/platform';
|
||||
import {
|
||||
authMiddleware,
|
||||
validateSession,
|
||||
validateApiKey,
|
||||
getSessionCookieName,
|
||||
} from './lib/auth.js';
|
||||
import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js';
|
||||
import { createAuthRoutes } from './routes/auth/index.js';
|
||||
import { createFsRoutes } from './routes/fs/index.js';
|
||||
import { createHealthRoutes, createDetailedHandler } from './routes/health/index.js';
|
||||
@@ -98,7 +94,7 @@ const app = express();
|
||||
// Middleware
|
||||
// Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var)
|
||||
if (ENABLE_REQUEST_LOGGING) {
|
||||
morgan.token('status-colored', (req, res) => {
|
||||
morgan.token('status-colored', (_req, res) => {
|
||||
const status = res.statusCode;
|
||||
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
|
||||
@@ -226,46 +222,31 @@ const terminalService = getTerminalService();
|
||||
function authenticateWebSocket(request: import('http').IncomingMessage): boolean {
|
||||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
|
||||
// Check for API key in header (Electron mode)
|
||||
const headerKey = request.headers['x-api-key'] as string | undefined;
|
||||
if (headerKey && validateApiKey(headerKey)) {
|
||||
return true;
|
||||
}
|
||||
// Convert URL search params to query object
|
||||
const query: Record<string, string | undefined> = {};
|
||||
url.searchParams.forEach((value, key) => {
|
||||
query[key] = value;
|
||||
});
|
||||
|
||||
// Check for session token in header (web mode with explicit token)
|
||||
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)
|
||||
// Parse cookies from header
|
||||
const cookieHeader = request.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const cookieName = getSessionCookieName();
|
||||
const cookies = cookieHeader.split(';').reduce(
|
||||
(acc, cookie) => {
|
||||
const [key, value] = cookie.trim().split('=');
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
const sessionToken = cookies[cookieName];
|
||||
if (sessionToken && validateSession(sessionToken)) {
|
||||
return true;
|
||||
}
|
||||
const cookies = cookieHeader ? cookie.parse(cookieHeader) : {};
|
||||
|
||||
// Use shared authentication logic for standard auth methods
|
||||
if (
|
||||
checkRawAuthentication(
|
||||
request.headers as Record<string, string | string[] | undefined>,
|
||||
query,
|
||||
cookies
|
||||
)
|
||||
) {
|
||||
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;
|
||||
|
||||
@@ -18,10 +18,24 @@ const API_KEY_FILE = path.join(DATA_DIR, '.api-key');
|
||||
const SESSIONS_FILE = path.join(DATA_DIR, '.sessions');
|
||||
const SESSION_COOKIE_NAME = 'automaker_session';
|
||||
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
|
||||
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
|
||||
*/
|
||||
@@ -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 {
|
||||
fs.mkdirSync(path.dirname(SESSIONS_FILE), { recursive: true });
|
||||
await fs.promises.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
|
||||
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) {
|
||||
console.error('[Auth] Failed to save sessions:', error);
|
||||
}
|
||||
@@ -134,19 +151,20 @@ function generateSessionToken(): string {
|
||||
/**
|
||||
* Create a new session and return the token
|
||||
*/
|
||||
export function createSession(): string {
|
||||
export async function createSession(): Promise<string> {
|
||||
const token = generateSessionToken();
|
||||
const now = Date.now();
|
||||
validSessions.set(token, {
|
||||
createdAt: now,
|
||||
expiresAt: now + SESSION_MAX_AGE_MS,
|
||||
});
|
||||
saveSessions(); // Persist to file
|
||||
await saveSessions(); // Persist to file
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a session token
|
||||
* Note: This returns synchronously but triggers async persistence if session expired
|
||||
*/
|
||||
export function validateSession(token: string): boolean {
|
||||
const session = validSessions.get(token);
|
||||
@@ -154,7 +172,8 @@ export function validateSession(token: string): boolean {
|
||||
|
||||
if (Date.now() > session.expiresAt) {
|
||||
validSessions.delete(token);
|
||||
saveSessions(); // Persist removal
|
||||
// Fire-and-forget: persist removal asynchronously
|
||||
saveSessions().catch((err) => console.error('[Auth] Error saving sessions:', err));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -164,9 +183,39 @@ export function validateSession(token: string): boolean {
|
||||
/**
|
||||
* Invalidate a session token
|
||||
*/
|
||||
export function invalidateSession(token: string): void {
|
||||
export async function invalidateSession(token: string): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
@@ -212,60 +313,38 @@ export function getSessionCookieName(): string {
|
||||
* 4. Session cookie (for web mode)
|
||||
*/
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
// Check for API key in header (Electron mode)
|
||||
const headerKey = req.headers['x-api-key'] as string | undefined;
|
||||
if (headerKey) {
|
||||
if (headerKey === API_KEY) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Invalid API key.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = checkAuthentication(
|
||||
req.headers as Record<string, string | string[] | undefined>,
|
||||
req.query as Record<string, string | undefined>,
|
||||
(req.cookies || {}) as Record<string, string | undefined>
|
||||
);
|
||||
|
||||
// Check for session token in header (web mode with explicit token)
|
||||
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)) {
|
||||
if (result.authenticated) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// No valid authentication
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Authentication required.',
|
||||
});
|
||||
// Return appropriate error based on what failed
|
||||
switch (result.errorType) {
|
||||
case 'invalid_api_key':
|
||||
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)
|
||||
*/
|
||||
export function isRequestAuthenticated(req: Request): boolean {
|
||||
// Check API key header
|
||||
const headerKey = req.headers['x-api-key'] as string | undefined;
|
||||
if (headerKey && headerKey === API_KEY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check session token header
|
||||
const sessionTokenHeader = req.headers['x-session-token'] as string | undefined;
|
||||
if (sessionTokenHeader && validateSession(sessionTokenHeader)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check query parameter
|
||||
const queryKey = req.query.apiKey as string | undefined;
|
||||
if (queryKey && queryKey === API_KEY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check cookie
|
||||
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME] as string | undefined;
|
||||
if (sessionToken && validateSession(sessionToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
const result = checkAuthentication(
|
||||
req.headers as Record<string, string | string[] | undefined>,
|
||||
req.query as Record<string, string | undefined>,
|
||||
(req.cookies || {}) as Record<string, string | undefined>
|
||||
);
|
||||
return result.authenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if raw credentials are authenticated
|
||||
* Used for WebSocket authentication where we don't have Express request objects
|
||||
*/
|
||||
export function checkRawAuthentication(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
query: Record<string, string | undefined>,
|
||||
cookies: Record<string, string | undefined>
|
||||
): boolean {
|
||||
return checkAuthentication(headers, query, cookies).authenticated;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getSessionCookieOptions,
|
||||
getSessionCookieName,
|
||||
isRequestAuthenticated,
|
||||
createWsConnectionToken,
|
||||
} from '../../lib/auth.js';
|
||||
|
||||
/**
|
||||
@@ -51,7 +52,7 @@ export function createAuthRoutes(): Router {
|
||||
* Validates the API key and sets a session cookie.
|
||||
* Body: { apiKey: string }
|
||||
*/
|
||||
router.post('/login', (req, res) => {
|
||||
router.post('/login', async (req, res) => {
|
||||
const { apiKey } = req.body as { apiKey?: string };
|
||||
|
||||
if (!apiKey) {
|
||||
@@ -71,7 +72,7 @@ export function createAuthRoutes(): Router {
|
||||
}
|
||||
|
||||
// Create session and set cookie
|
||||
const sessionToken = createSession();
|
||||
const sessionToken = await createSession();
|
||||
const cookieOptions = getSessionCookieOptions();
|
||||
const cookieName = getSessionCookieName();
|
||||
|
||||
@@ -87,35 +88,27 @@ export function createAuthRoutes(): Router {
|
||||
/**
|
||||
* GET /api/auth/token
|
||||
*
|
||||
* Returns the session token if the user has a valid session cookie.
|
||||
* This allows the UI to get a token for explicit header-based auth
|
||||
* after a page refresh (when the token in memory is lost).
|
||||
* Generates a short-lived WebSocket connection token if the user has a valid session.
|
||||
* This token is used for initial WebSocket handshake authentication and expires in 5 minutes.
|
||||
* The token is NOT the session cookie value - it's a separate, short-lived token.
|
||||
*/
|
||||
router.get('/token', (req, res) => {
|
||||
const cookieName = getSessionCookieName();
|
||||
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
|
||||
// Validate the session is still valid (via cookie, API key, or session token header)
|
||||
if (!isRequestAuthenticated(req)) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Session expired.',
|
||||
error: 'Authentication required.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Return the existing session token for header-based auth
|
||||
// Generate a new short-lived WebSocket connection token
|
||||
const wsToken = createWsConnectionToken();
|
||||
|
||||
res.json({
|
||||
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.
|
||||
*/
|
||||
router.post('/logout', (req, res) => {
|
||||
router.post('/logout', async (req, res) => {
|
||||
const cookieName = getSessionCookieName();
|
||||
const sessionToken = req.cookies?.[cookieName] as string | undefined;
|
||||
|
||||
if (sessionToken) {
|
||||
invalidateSession(sessionToken);
|
||||
await invalidateSession(sessionToken);
|
||||
}
|
||||
|
||||
// Clear the cookie
|
||||
|
||||
Reference in New Issue
Block a user