This commit is contained in:
+48
-3
@@ -168,10 +168,25 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
* read current state for the admin status endpoint.
|
||||
*/
|
||||
gatewayMount: GatewayMountHandle | null;
|
||||
/** True when an OAuth provider or local auth is active. False = no-auth mode. */
|
||||
authActive: boolean;
|
||||
} {
|
||||
const { repo, worktreeDir } = opts;
|
||||
const app = express();
|
||||
|
||||
// Don't advertise the framework/version (minor info-leak, free to drop).
|
||||
app.disable('x-powered-by');
|
||||
|
||||
// Baseline hardening header on every response. `nosniff` stops the browser
|
||||
// from MIME-sniffing a response into something executable. File-serving
|
||||
// endpoints add `Content-Security-Policy: sandbox` on top
|
||||
// (see setUntrustedFileResponseHeaders) to neutralize stored XSS from
|
||||
// agent/user-authored workspace files.
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
next();
|
||||
});
|
||||
|
||||
// リバースプロキシ背後で secure cookie / X-Forwarded-Proto を正しく処理
|
||||
if (opts.authConfig?.secureCookie) {
|
||||
app.set('trust proxy', 1);
|
||||
@@ -816,9 +831,13 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// POST /api/local/tasks/:taskId/console/session. Reuses the same
|
||||
// SshSubsystem + preflight the console tools use; the access gate
|
||||
// runs inside openConsoleSession against task.pieceName.
|
||||
// NOTE: no express.json() here — the router is mounted on the whole
|
||||
// /api prefix, so a mount-level parser (default limit 100kb) would
|
||||
// run for EVERY /api request and 413 large bodies before the
|
||||
// route-specific parsers (e.g. the task-attachment limit) ever ran.
|
||||
// The session route carries its own scoped json() parser.
|
||||
app.use(
|
||||
'/api',
|
||||
express.json(),
|
||||
createConsoleSessionRouter({
|
||||
sub: sshSubsystem,
|
||||
preflight: sshPreflight,
|
||||
@@ -1157,7 +1176,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
return isOwner || user.role === 'admin';
|
||||
};
|
||||
|
||||
return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount };
|
||||
return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount, authActive };
|
||||
}
|
||||
|
||||
export function finalizeServer(app: express.Application): express.Application {
|
||||
@@ -1166,6 +1185,15 @@ export function finalizeServer(app: express.Application): express.Application {
|
||||
});
|
||||
|
||||
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||
// body-parser signals an exceeded json({ limit }) with type
|
||||
// 'entity.too.large'. Surface it as the 413 it is — collapsing it into a
|
||||
// generic 500 hides the actual problem ("attachment over the configured
|
||||
// limit") from API clients and the UI.
|
||||
if ((err as { type?: string }).type === 'entity.too.large') {
|
||||
logger.warn(`Request body too large: ${err.message}`);
|
||||
res.status(413).json({ error: 'Request body too large' });
|
||||
return;
|
||||
}
|
||||
logger.error(`Unhandled error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
@@ -1202,6 +1230,7 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
|
||||
backendStatusRegistry,
|
||||
workerMetrics,
|
||||
gatewayMount,
|
||||
authActive,
|
||||
// Forward the actual port to createCoreServer so the admin gateway
|
||||
// status endpoint reports the real bind port (not the PORT env
|
||||
// guess). See `listenPort` doc on CoreServerOptions.
|
||||
@@ -1218,9 +1247,25 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
|
||||
opts.workerManager.setWorkerMetrics(workerMetrics);
|
||||
}
|
||||
const finalApp = finalizeServer(app);
|
||||
const host = process.env['HOST'] ?? '0.0.0.0';
|
||||
// Safe-by-default bind: loopback unless HOST is set explicitly. The agent API
|
||||
// includes the Bash tool, so an unauthenticated instance reachable on the LAN
|
||||
// is effectively unauthenticated RCE. Bare-metal / systemd installs inherit
|
||||
// this loopback default; the Docker image sets HOST=0.0.0.0 (it must bind all
|
||||
// interfaces inside the container) and is protected by the host-side
|
||||
// 127.0.0.1:9876 port mapping instead.
|
||||
const host = process.env['HOST'] ?? '127.0.0.1';
|
||||
const isLoopbackBind = host === '127.0.0.1' || host === '::1' || host === 'localhost';
|
||||
const server = finalApp.listen(port, host, () => {
|
||||
logger.info(`Core server listening on ${host}:${port}`);
|
||||
if (!isLoopbackBind && !authActive) {
|
||||
logger.warn(
|
||||
`[security] Listening on ${host} with authentication DISABLED. The agent API ` +
|
||||
`(including the Bash tool) is reachable by anyone who can reach this host — ` +
|
||||
`this is effectively unauthenticated remote code execution. Enable auth in ` +
|
||||
`config.yaml (auth.local or an OAuth provider) before exposing a non-loopback ` +
|
||||
`interface, or unset HOST to bind 127.0.0.1.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 起動と同時に CAPTCHA Pool の idle GC を回す (task session を 5 分アイドルで GC)
|
||||
|
||||
Reference in New Issue
Block a user