mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
Merge pull request #107 from aakhter/cod-29-network-auth-downloads
fix: COD-29 harden network auth and downloads
This commit is contained in:
+11
-3
@@ -483,21 +483,29 @@ program
|
||||
program
|
||||
.command('web')
|
||||
.description('Start the web interface')
|
||||
.option('-H, --host <host>', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1')
|
||||
.option('-p, --port <port>', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000')
|
||||
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
|
||||
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
|
||||
.option(
|
||||
'--allow-unauthenticated-network',
|
||||
'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)'
|
||||
)
|
||||
.action(async (options) => {
|
||||
const { startWebServer } = await import('./web/server.js');
|
||||
const host = options.host;
|
||||
const port = parseInt(options.port, 10);
|
||||
const https = !!options.https;
|
||||
const titleHostname = options.titleHostname;
|
||||
const allowUnauthenticatedNetwork = !!options.allowUnauthenticatedNetwork;
|
||||
const protocol = https ? 'https' : 'http';
|
||||
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
||||
|
||||
console.log(chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`));
|
||||
console.log(chalk.cyan(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`));
|
||||
|
||||
try {
|
||||
const server = await startWebServer(port, https, false, titleHostname);
|
||||
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://localhost:${port}`));
|
||||
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork);
|
||||
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://${displayHost}:${port}`));
|
||||
if (https) {
|
||||
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* - CORS (localhost only)
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { StaleExpirationMap } from '../../utils/index.js';
|
||||
import type { AuthSessionRecord } from '../ports/auth-port.js';
|
||||
@@ -69,6 +69,13 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
const authSessions = state.authSessions;
|
||||
const authFailures = state.authFailures;
|
||||
|
||||
function sendAuthRateLimit(reply: FastifyReply, clientIp: string): void {
|
||||
const remainingMs = authFailures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
|
||||
reply.header('Retry-After', String(retryAfterSeconds));
|
||||
reply.code(429).send('Too Many Requests — try again later');
|
||||
}
|
||||
|
||||
app.addHook('onRequest', (req, reply, done) => {
|
||||
// Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available.
|
||||
// Safe: validated by HookEventSchema, only triggers broadcasts.
|
||||
@@ -90,13 +97,6 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Rate limit: reject if too many failed attempts from this IP
|
||||
const failures = authFailures.get(clientIp) ?? 0;
|
||||
if (failures >= AUTH_FAILURE_MAX) {
|
||||
reply.code(429).send('Too Many Requests — try again later');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check session cookie first (avoids re-sending credentials on every request)
|
||||
// Use get() instead of has() so refreshOnGet extends the TTL on active sessions
|
||||
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
||||
@@ -140,6 +140,13 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limit only requests that failed to authenticate on this attempt.
|
||||
const failures = authFailures.get(clientIp) ?? 0;
|
||||
if (failures >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, clientIp);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth failed — track failure count
|
||||
authFailures.set(clientIp, failures + 1);
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
const EXPLICIT_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||
|
||||
export function isExplicitlyEnabled(value: string | undefined): boolean {
|
||||
return value !== undefined && EXPLICIT_TRUE_VALUES.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function isLoopbackBindHost(host: string): boolean {
|
||||
const normalized = host
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\[(.*)\]$/, '$1');
|
||||
if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') {
|
||||
return true;
|
||||
}
|
||||
if (isIP(normalized) === 4 && normalized.startsWith('127.')) {
|
||||
return true;
|
||||
}
|
||||
return normalized.startsWith('::ffff:127.');
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join } from 'node:path';
|
||||
import { basename as pathBasename, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import fs from 'node:fs/promises';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||
@@ -278,7 +279,6 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
ico: 'image/x-icon',
|
||||
bmp: 'image/bmp',
|
||||
mp4: 'video/mp4',
|
||||
@@ -292,19 +292,21 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
};
|
||||
|
||||
const content = await fs.readFile(resolvedPath);
|
||||
if (download === 'true') {
|
||||
const rawBasename = filePath!.split('/').pop() || 'download';
|
||||
// Sanitize filename for Content-Disposition header (prevent header injection)
|
||||
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
|
||||
const rawBasename = filePath!.split('/').pop() || 'download';
|
||||
// Sanitize filename for Content-Disposition header (prevent header injection)
|
||||
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
|
||||
if (download === 'true' || ext === 'svg') {
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
|
||||
'Content-Type': ext === 'svg' ? 'application/octet-stream' : mimeTypes[ext] || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${basename}"`,
|
||||
'Content-Length': content.length,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
reply.raw.end(content);
|
||||
return;
|
||||
}
|
||||
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
reply.send(content);
|
||||
} catch (err) {
|
||||
reply
|
||||
@@ -380,4 +382,108 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
const closed = fileStreamManager.closeStream(streamId);
|
||||
return { success: closed };
|
||||
});
|
||||
// Session-scoped file download.
|
||||
// Uses the same realpath-based workspace boundary as file preview/raw routes;
|
||||
// the sensitive-path blocklist remains defense-in-depth, not the primary boundary.
|
||||
const SENSITIVE_PATTERNS: RegExp[] = [
|
||||
/^\/etc\/shadow$/,
|
||||
/^\/etc\/gshadow$/,
|
||||
/^\/etc\/master\.passwd$/,
|
||||
new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`),
|
||||
/\/\.env$/,
|
||||
/\/\.env\./,
|
||||
/\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i,
|
||||
/\/\.aws\/credentials$/,
|
||||
/\/\.gcloud\/credentials\.db$/,
|
||||
/\/\.docker\/config\.json$/,
|
||||
];
|
||||
|
||||
function isSensitivePath(absPath: string): boolean {
|
||||
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath));
|
||||
}
|
||||
|
||||
app.get('/api/download', async (req, reply) => {
|
||||
const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string };
|
||||
|
||||
if (!filePath) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing sessionId parameter'));
|
||||
return;
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, sessionId);
|
||||
const validated = validateSessionFilePath(session.workingDir, filePath);
|
||||
if (!validated) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
}
|
||||
const { resolvedPath } = validated;
|
||||
|
||||
// Check sensitive path blocklist
|
||||
if (isSensitivePath(resolvedPath)) {
|
||||
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
|
||||
if (!stat.isFile()) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is not a file'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 50MB size limit
|
||||
const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024;
|
||||
if (stat.size > MAX_DOWNLOAD_SIZE) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > 50MB limit)`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
const mimeTypes: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
pdf: 'application/pdf',
|
||||
json: 'application/json',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
csv: 'text/csv',
|
||||
xml: 'application/xml',
|
||||
zip: 'application/zip',
|
||||
gz: 'application/gzip',
|
||||
tar: 'application/x-tar',
|
||||
};
|
||||
|
||||
const filename = pathBasename(resolvedPath);
|
||||
const content = await fs.readFile(resolvedPath);
|
||||
// Bypass Fastify compression — write directly to raw response
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': content.length,
|
||||
});
|
||||
reply.raw.end(content);
|
||||
return;
|
||||
} catch (err) {
|
||||
reply
|
||||
.code(500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+38
-12
@@ -102,6 +102,7 @@ import { SseEvent } from './sse-events.js';
|
||||
import type { ScheduledRun } from './ports/index.js';
|
||||
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
|
||||
import { installRouteErrorHandler } from './route-error-handler.js';
|
||||
import { isExplicitlyEnabled, isLoopbackBindHost } from './network-auth-policy.js';
|
||||
import {
|
||||
registerPushRoutes,
|
||||
registerTeamRoutes,
|
||||
@@ -191,6 +192,7 @@ export class WebServer extends EventEmitter {
|
||||
private sse: SseStreamManager;
|
||||
private store = getStore();
|
||||
private port: number;
|
||||
private host: string;
|
||||
private https: boolean;
|
||||
private testMode: boolean;
|
||||
private mux: TerminalMultiplexer;
|
||||
@@ -232,6 +234,10 @@ export class WebServer extends EventEmitter {
|
||||
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
||||
private readonly titleHostname: string;
|
||||
private readonly windowTitle: string;
|
||||
private readonly indexHtmlTemplate: string;
|
||||
private readonly allowUnauthenticatedNetwork: boolean;
|
||||
private _pasteImageGcStop: (() => void) | null = null;
|
||||
private _eventLoopMonitor: EventLoopMonitorHandle | null = null;
|
||||
private teamWatcherHandlers: {
|
||||
@@ -240,15 +246,22 @@ export class WebServer extends EventEmitter {
|
||||
teamRemoved: (config: unknown) => void;
|
||||
taskUpdated: (data: unknown) => void;
|
||||
} | null = null;
|
||||
private readonly titleHostname: string;
|
||||
private readonly windowTitle: string;
|
||||
private readonly indexHtmlTemplate: string;
|
||||
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false, titleHostname?: string) {
|
||||
constructor(
|
||||
port: number = 3000,
|
||||
https: boolean = false,
|
||||
testMode: boolean = false,
|
||||
host: string = '127.0.0.1',
|
||||
titleHostname?: string,
|
||||
allowUnauthenticatedNetwork: boolean = false
|
||||
) {
|
||||
super();
|
||||
this.setMaxListeners(0);
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.https = https;
|
||||
this.testMode = testMode;
|
||||
this.allowUnauthenticatedNetwork =
|
||||
allowUnauthenticatedNetwork || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
|
||||
this.titleHostname = titleHostname || getHostname();
|
||||
this.windowTitle = `codeman:${this.titleHostname}`;
|
||||
this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8');
|
||||
@@ -1646,6 +1659,13 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (!isLoopbackBindHost(this.host) && !process.env.CODEMAN_PASSWORD && !this.allowUnauthenticatedNetwork) {
|
||||
throw new Error(
|
||||
'Refusing to start Codeman on a non-loopback host without CODEMAN_PASSWORD. ' +
|
||||
'Set CODEMAN_PASSWORD or explicitly allow unauthenticated network access.'
|
||||
);
|
||||
}
|
||||
|
||||
await this.setupRoutes();
|
||||
|
||||
const lifecycleLog = getLifecycleLog();
|
||||
@@ -1672,19 +1692,23 @@ export class WebServer extends EventEmitter {
|
||||
this._eventLoopMonitor = startEventLoopMonitor();
|
||||
}
|
||||
|
||||
await this.app.listen({ port: this.port, host: '0.0.0.0' });
|
||||
await this.app.listen({ port: this.port, host: this.host });
|
||||
const protocol = this.https ? 'https' : 'http';
|
||||
console.log(`Codeman web interface running at ${protocol}://localhost:${this.port}`);
|
||||
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
|
||||
console.log(`Codeman web interface running at ${protocol}://${displayHost}:${this.port}`);
|
||||
|
||||
// Security warning: server binds to 0.0.0.0 (all interfaces) — warn if no auth configured
|
||||
if (!process.env.CODEMAN_PASSWORD) {
|
||||
if (!isLoopbackBindHost(this.host) && !process.env.CODEMAN_PASSWORD && this.allowUnauthenticatedNetwork) {
|
||||
console.warn('\n⚠ WARNING: No CODEMAN_PASSWORD set — server is accessible without authentication.');
|
||||
console.warn(' Anyone on your network can access and control Claude sessions.');
|
||||
console.warn(' Set CODEMAN_PASSWORD environment variable to enable auth.\n');
|
||||
console.warn(
|
||||
' This was explicitly allowed by --allow-unauthenticated-network or CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK.\n'
|
||||
);
|
||||
}
|
||||
|
||||
// Set API URL for child processes (MCP server, spawned sessions)
|
||||
process.env.CODEMAN_API_URL = `${protocol}://localhost:${this.port}`;
|
||||
const apiHost =
|
||||
this.host === '0.0.0.0' || this.host === 'localhost' || this.host === '::1' ? '127.0.0.1' : this.host;
|
||||
process.env.CODEMAN_API_URL = `${protocol}://${apiHost}:${this.port}`;
|
||||
|
||||
// Start scheduled runs cleanup timer
|
||||
this.cleanup.setInterval(
|
||||
@@ -2176,9 +2200,11 @@ export async function startWebServer(
|
||||
port: number = 3000,
|
||||
https: boolean = false,
|
||||
testMode: boolean = false,
|
||||
titleHostname?: string
|
||||
host: string = '127.0.0.1',
|
||||
titleHostname?: string,
|
||||
allowUnauthenticatedNetwork: boolean = false
|
||||
): Promise<WebServer> {
|
||||
const server = new WebServer(port, https, testMode, titleHostname);
|
||||
const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork);
|
||||
await server.start();
|
||||
return server;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user