This commit is contained in:
+112
-14
@@ -16,6 +16,7 @@ import { mountBrandingApi, resolveBranding } from './branding-api.js';
|
||||
import { createBrowserApi } from './browser-api.js';
|
||||
import { createBrowserSessionApi } from './browser-session-api.js';
|
||||
import { createSubtaskActivityRouter } from './subtask-activity-api.js';
|
||||
import { createUsageRouter } from './usage-api.js';
|
||||
import { SessionManager } from '../engine/browser-session.js';
|
||||
import { createNovncRouter, setupNovncWebSocketProxy } from './novnc-proxy.js';
|
||||
import { setSessionManager } from '../engine/tools/browser.js';
|
||||
@@ -104,6 +105,11 @@ import { createNotesApi } from './notes-api.js';
|
||||
import { mountGateway, type GatewayMountHandle } from './gateway-mount.js';
|
||||
import { readGatewayConfig } from '../gateway/config.js';
|
||||
import { createAdminGatewayStatusRouter } from './admin-gateway-status-api.js';
|
||||
import { createServer as createHttpsServer } from 'https';
|
||||
import { X509Certificate } from 'crypto';
|
||||
import { mergeServerConfig } from '../server/config.js';
|
||||
import { resolveTlsOptions } from '../net/tls-options.js';
|
||||
import { createHttpRedirectServer } from '../net/http-redirect.js';
|
||||
|
||||
const __filenameServer = fileURLToPath(import.meta.url);
|
||||
const __dirnameServer = dirname(__filenameServer);
|
||||
@@ -112,7 +118,7 @@ export interface CoreServerOptions {
|
||||
repo: Repository;
|
||||
worktreeDir?: string;
|
||||
configuredRepos?: string[];
|
||||
generateTitle?: (body: string) => Promise<string>;
|
||||
generateTitle?: (body: string, ownerId?: string) => Promise<string>;
|
||||
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
|
||||
configManager?: ConfigManager;
|
||||
piecesDir?: string;
|
||||
@@ -170,6 +176,14 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
gatewayMount: GatewayMountHandle | null;
|
||||
/** True when an OAuth provider or local auth is active. False = no-auth mode. */
|
||||
authActive: boolean;
|
||||
/**
|
||||
* Resolved server config snapshot — computed once with the real listen port
|
||||
* so both the cookie-secure decision (inside createCoreServer) and the
|
||||
* listener branch (inside startCoreServer) share the SAME object.
|
||||
* A config hot-reload between the two would otherwise cause tls.enabled to
|
||||
* disagree between the cookie flag and the actual listener type.
|
||||
*/
|
||||
serverConfig: ReturnType<typeof mergeServerConfig>;
|
||||
} {
|
||||
const { repo, worktreeDir } = opts;
|
||||
const app = express();
|
||||
@@ -276,6 +290,16 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
}
|
||||
let authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined;
|
||||
|
||||
// Resolve server config ONCE here with the real listen port (threaded in via
|
||||
// opts.listenPort by startCoreServer). Both the cookie-secure decision below
|
||||
// and the listener branch in startCoreServer consume this same snapshot so a
|
||||
// config hot-reload between the two calls cannot produce a mismatch between
|
||||
// tls.enabled and the cookie secure flag.
|
||||
const serverCfg = mergeServerConfig(loadConfig().server, {
|
||||
freshInstall: false,
|
||||
httpsPort: opts.listenPort ?? Number(process.env['PORT'] ?? 9876),
|
||||
});
|
||||
|
||||
if (authActive) {
|
||||
// Idempotently seed the shared `local` system admin (id='local', the same
|
||||
// owner the no-auth path uses) so an existing single-user / no-auth
|
||||
@@ -286,9 +310,21 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
logger.info(`[auth] seeded local system admin id=local email=${bootstrap.email}`);
|
||||
}
|
||||
|
||||
// Compose effective secureCookie: native TLS termination also requires
|
||||
// the secure flag on session cookies, even when no upstream proxy is
|
||||
// present. IMPORTANT: trust-proxy (line ~191) stays keyed on the
|
||||
// ORIGINAL opts.authConfig.secureCookie — native TLS must NOT enable it.
|
||||
const effectiveSecureCookie = computeEffectiveSecureCookie(
|
||||
!!opts.authConfig?.secureCookie,
|
||||
serverCfg.tls.enabled,
|
||||
);
|
||||
const authConfigForSetup = opts.authConfig
|
||||
? { ...opts.authConfig, secureCookie: effectiveSecureCookie }
|
||||
: opts.authConfig;
|
||||
|
||||
const auth = setupAuth(
|
||||
repo,
|
||||
opts.authConfig!,
|
||||
authConfigForSetup!,
|
||||
() => {
|
||||
const b = resolveBranding(opts.configManager);
|
||||
return { appName: b.appName, loginPageTitle: b.loginPageTitle };
|
||||
@@ -330,6 +366,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// per-piece write authz (built-in/global-custom → admin, user-custom → owner)
|
||||
// is enforced inside pieces-api.ts handlers.
|
||||
app.use('/api/pieces', requireAuth);
|
||||
app.use('/api/usage', requireAuth);
|
||||
// Scheduled tasks: any authenticated user can create/list (visibility-filtered).
|
||||
// PATCH/DELETE owner-or-admin enforcement lives in the handlers (Task 14).
|
||||
app.use('/api/scheduled-tasks', requireAuth);
|
||||
@@ -893,10 +930,11 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
});
|
||||
|
||||
// --- Local files API ---
|
||||
mountLocalFilesApi(app, repo);
|
||||
mountLocalFilesApi(app, repo, { authActive });
|
||||
|
||||
// --- Subtask activity API ---
|
||||
app.use('/api/local/tasks', createSubtaskActivityRouter(repo));
|
||||
app.use('/api/usage', createUsageRouter(repo, { authActive }));
|
||||
|
||||
// --- Subtask files API (listing MUST come before wildcard) ---
|
||||
mountSubtaskFilesApi(app, repo);
|
||||
@@ -1176,7 +1214,19 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
return isOwner || user.role === 'admin';
|
||||
};
|
||||
|
||||
return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount, authActive };
|
||||
return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount, authActive, serverConfig: serverCfg };
|
||||
}
|
||||
|
||||
/** Cookie `secure` must be set whenever the user-facing scheme is https —
|
||||
* via an upstream TLS proxy (secureCookie) OR native TLS termination. */
|
||||
export function computeEffectiveSecureCookie(secureCookie: boolean, tlsEnabled: boolean): boolean {
|
||||
return secureCookie || tlsEnabled;
|
||||
}
|
||||
|
||||
/** Heuristic: native TLS + the proxy signal (secure_cookie) likely means a
|
||||
* reverse proxy is also terminating TLS → double-TLS misconfiguration. */
|
||||
export function shouldWarnDoubleTls(tlsEnabled: boolean, secureCookie: boolean): boolean {
|
||||
return tlsEnabled && secureCookie;
|
||||
}
|
||||
|
||||
export function finalizeServer(app: express.Application): express.Application {
|
||||
@@ -1231,6 +1281,7 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
|
||||
workerMetrics,
|
||||
gatewayMount,
|
||||
authActive,
|
||||
serverConfig,
|
||||
// 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.
|
||||
@@ -1255,18 +1306,65 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
|
||||
// 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.`,
|
||||
// Use the config snapshot already resolved in createCoreServer (same port,
|
||||
// same loadConfig() call) — no second read so a hot-reload between the two
|
||||
// cannot make the cookie-secure flag disagree with the listener type.
|
||||
const tls = serverConfig.tls;
|
||||
let server: import('http').Server | import('https').Server;
|
||||
if (tls.enabled) {
|
||||
// Augment the self-signed SAN list with the redirect target host and the
|
||||
// (non-wildcard) bind host so that browsers following the HTTP→HTTPS redirect
|
||||
// always land on a hostname that is covered by the certificate. Provided-cert
|
||||
// deployments are unaffected because resolveTlsOptions ignores selfSignedHosts
|
||||
// when cert_file/key_file are set.
|
||||
const extraSan = [
|
||||
tls.redirectHost,
|
||||
host && host !== '0.0.0.0' && host !== '::' ? host : null,
|
||||
].filter((h): h is string => !!h);
|
||||
const tlsForResolve = extraSan.length
|
||||
? { ...tls, selfSignedHosts: [...tls.selfSignedHosts, ...extraSan] }
|
||||
: tls;
|
||||
const resolved = resolveTlsOptions(tlsForResolve); // fatal throw on bad operator cert
|
||||
server = createHttpsServer({ cert: resolved.cert, key: resolved.key, minVersion: resolved.minVersion }, finalApp);
|
||||
server.listen(port, host, () => {
|
||||
const source = tls.certFile ? `provided(${tls.certFile})` : 'self-signed';
|
||||
const fp = new X509Certificate(resolved.cert).fingerprint256;
|
||||
logger.info(`Core server listening on https://${host}:${port} cert=${source} sha256=${fp}`);
|
||||
if (shouldWarnDoubleTls(true, !!opts.authConfig?.secureCookie)) {
|
||||
logger.warn(
|
||||
`[security] server.tls.enabled is ON while auth.secure_cookie is also ON (reverse-proxy signal). ` +
|
||||
`If a TLS-terminating proxy is in front of this app, set server.tls.enabled: false to avoid double TLS.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
if (tls.httpRedirect) {
|
||||
if (tls.redirectHost == null && (host === '0.0.0.0' || host === '::')) {
|
||||
logger.warn(
|
||||
`[server] HTTP->HTTPS redirect host falls back to the wildcard bind address (${host}); browsers cannot follow it. ` +
|
||||
`Set server.tls.redirect_host to the externally reachable hostname.`,
|
||||
);
|
||||
}
|
||||
const pinnedHost = tls.redirectHost ?? (isLoopbackBind ? 'localhost' : host);
|
||||
const redirector = createHttpRedirectServer({ httpsPort: port, pinnedHost });
|
||||
redirector.on('error', (e) => logger.warn(`[server] HTTP redirect listener error: ${(e as Error).message}`));
|
||||
redirector.listen(tls.httpRedirectPort, host, () =>
|
||||
logger.info(`HTTP->HTTPS redirect listening on http://${host}:${tls.httpRedirectPort}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
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)
|
||||
if (browserSessionManager) browserSessionManager.startIdleGc();
|
||||
|
||||
Reference in New Issue
Block a user