Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ RUN git config --system --add safe.directory '*' && \
USER automaker

# Environment variables
ENV NODE_ENV=production
ENV PORT=3008
ENV DATA_DIR=/data

Expand Down
4 changes: 4 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev:test": "tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src/",
Expand All @@ -29,6 +30,7 @@
"@automaker/types": "^1.0.0",
"@automaker/utils": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
Expand All @@ -37,6 +39,8 @@
"ws": "^8.18.3"
},
"devDependencies": {
"@types/cookie": "^0.6.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/morgan": "^1.9.10",
Expand Down
101 changes: 92 additions & 9 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,19 @@
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, getAuthStatus } from './lib/auth.js';
import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js';
import { requireJsonContentType } from './middleware/require-json-content-type.js';
import { createAuthRoutes } from './routes/auth/index.js';
import { createFsRoutes } from './routes/fs/index.js';
import { createHealthRoutes } from './routes/health/index.js';
import { createHealthRoutes, createDetailedHandler } from './routes/health/index.js';
import { createAgentRoutes } from './routes/agent/index.js';
import { createSessionsRoutes } from './routes/sessions/index.js';
import { createFeaturesRoutes } from './routes/features/index.js';
Expand Down Expand Up @@ -91,7 +95,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
Expand All @@ -105,17 +109,43 @@ if (ENABLE_REQUEST_LOGGING) {
})
);
}
// SECURITY: Restrict CORS to localhost UI origins to prevent drive-by attacks
// from malicious websites. MCP server endpoints can execute arbitrary commands,
// so allowing any origin would enable RCE from any website visited while Automaker runs.
const DEFAULT_CORS_ORIGINS = ['http://localhost:3007', 'http://127.0.0.1:3007'];
// CORS configuration
// When using credentials (cookies), origin cannot be '*'
// We dynamically allow the requesting origin for local development
app.use(
cors({
origin: process.env.CORS_ORIGIN || DEFAULT_CORS_ORIGINS,
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps, curl, Electron)
if (!origin) {
callback(null, true);
return;
}

// If CORS_ORIGIN is set, use it (can be comma-separated list)
const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim());
if (allowedOrigins && allowedOrigins.length > 0 && allowedOrigins[0] !== '*') {
if (allowedOrigins.includes(origin)) {
callback(null, origin);
} else {
callback(new Error('Not allowed by CORS'));
}
return;
}

// For local development, allow localhost origins
if (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:')) {
callback(null, origin);
return;
}

// Reject other origins by default for security
callback(new Error('Not allowed by CORS'));
},
credentials: true,
})
);
app.use(express.json({ limit: '50mb' }));
app.use(cookieParser());

// Create shared event emitter for streaming
const events: EventEmitter = createEventEmitter();
Expand Down Expand Up @@ -144,12 +174,20 @@ setInterval(() => {
}
}, VALIDATION_CLEANUP_INTERVAL_MS);

// Mount API routes - health is unauthenticated for monitoring
// Require Content-Type: application/json for all API POST/PUT/PATCH requests
// This helps prevent CSRF and content-type confusion attacks
app.use('/api', requireJsonContentType);

// Mount API routes - health and auth are unauthenticated
app.use('/api/health', createHealthRoutes());
app.use('/api/auth', createAuthRoutes());

// Apply authentication to all other routes
app.use('/api', authMiddleware);

// Protected health endpoint with detailed info
app.get('/api/health/detailed', createDetailedHandler());

app.use('/api/fs', createFsRoutes(events));
app.use('/api/agent', createAgentRoutes(agentService, events));
app.use('/api/sessions', createSessionsRoutes(agentService));
Expand Down Expand Up @@ -182,10 +220,55 @@ const wss = new WebSocketServer({ noServer: true });
const terminalWss = new WebSocketServer({ noServer: true });
const terminalService = getTerminalService();

/**
* Authenticate WebSocket upgrade requests
* Checks for API key in header/query, session token in header/query, OR valid session cookie
*/
function authenticateWebSocket(request: import('http').IncomingMessage): boolean {
const url = new URL(request.url || '', `http://${request.headers.host}`);

// Convert URL search params to query object
const query: Record<string, string | undefined> = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});

// Parse cookies from header
const cookieHeader = request.headers.cookie;
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;
}

// Handle HTTP upgrade requests manually to route to correct WebSocket server
server.on('upgrade', (request, socket, head) => {
const { pathname } = new URL(request.url || '', `http://${request.headers.host}`);

// Authenticate all WebSocket connections
if (!authenticateWebSocket(request)) {
console.log('[WebSocket] Authentication failed, rejecting connection');
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}

if (pathname === '/api/events') {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
Expand Down
Loading