From a75fd0186d6977a3dabf8b7bc8ddaab0db8b471c Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 31 May 2026 17:06:52 +0800 Subject: [PATCH] feat(cli): add --require-auth flag for /_web/* API protection When enabled, all /_web/* routes require Bearer token authentication, same as /api/v1/*. This hardens security for server/headless/public deployments where the same-origin assumption doesn't hold. - Add `require_auth` field to config schema (default: false) - Add `setRequireAuth()` to auth hook, conditionally enforce auth on /_web/* - Support `--require-auth` in both `chatlab start` and `--daemon` mode - Read from config.api.require_auth or CLI flag override Co-authored-by: Cursor --- apps/cli/src/cli.ts | 3 +++ apps/cli/src/daemon/service.ts | 7 ++++--- apps/cli/src/http/auth.ts | 13 +++++++++++-- apps/cli/src/http/index.ts | 7 ++++++- packages/config/src/schema.ts | 1 + 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index bfdb67ff..57ac8eaa 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -286,6 +286,7 @@ program .option('--host ', 'Listen address', '127.0.0.1') .option('--token ', 'Custom Bearer Token (reads from config or auto-generates if omitted)') .option('--headless', 'API-only mode, do not serve the Web UI') + .option('--require-auth', 'Require Bearer token for all routes including /_web/*') .option('--no-open', 'Do not auto-open the browser') .option('--daemon', 'Run as a resident system service (auto-start on login, macOS/Linux)') .action(async (options) => { @@ -297,6 +298,7 @@ program host: options.host, token: options.token || undefined, headless: options.headless, + requireAuth: options.requireAuth || undefined, }) return } @@ -328,6 +330,7 @@ program host: options.host, token: options.token || undefined, webRoot, + requireAuth: options.requireAuth || undefined, }) const { startPeriodicUpdateCheck } = await import('./update-checker') diff --git a/apps/cli/src/daemon/service.ts b/apps/cli/src/daemon/service.ts index 68124113..e31eca36 100644 --- a/apps/cli/src/daemon/service.ts +++ b/apps/cli/src/daemon/service.ts @@ -56,6 +56,7 @@ export interface ServiceInstallOptions { host?: string token?: string headless?: boolean + requireAuth?: boolean } export interface ServiceStatus { @@ -74,10 +75,9 @@ function buildPlist(options: ServiceInstallOptions): string { const args = ['start', '--no-open', '--port', String(port), '--host', host] if (options.token) args.push('--token', options.token) if (options.headless) args.push('--headless') + if (options.requireAuth) args.push('--require-auth') - const programArgs = [process.execPath, cliEntry, ...args] - .map((a) => ` ${a}`) - .join('\n') + const programArgs = [process.execPath, cliEntry, ...args].map((a) => ` ${a}`).join('\n') fs.mkdirSync(path.join(SYSTEM_DIR, 'logs'), { recursive: true }) @@ -195,6 +195,7 @@ function buildUnit(options: ServiceInstallOptions): string { const args = ['start', '--no-open', '--port', String(port), '--host', host] if (options.token) args.push('--token', options.token) if (options.headless) args.push('--headless') + if (options.requireAuth) args.push('--require-auth') const execStart = [process.execPath, cliEntry, ...args].map(escapeSystemdArg).join(' ') return `[Unit] diff --git a/apps/cli/src/http/auth.ts b/apps/cli/src/http/auth.ts index dec47055..1a075a5b 100644 --- a/apps/cli/src/http/auth.ts +++ b/apps/cli/src/http/auth.ts @@ -10,6 +10,7 @@ import { unauthorized, errorResponse } from './errors' let cachedToken: string | null = null let webModeEnabled = false +let requireAuthEnabled = false /** * 设置 auth hook 使用的 token(由 server 启动时注入) @@ -26,6 +27,14 @@ export function setWebMode(enabled: boolean): void { webModeEnabled = enabled } +/** + * When enabled, /_web/* routes also require Bearer token (same as /api/*). + * Used for server/headless deployments where same-origin assumption doesn't hold. + */ +export function setRequireAuth(enabled: boolean): void { + requireAuthEnabled = enabled +} + function safeTokenCompare(a: string, b: string): boolean { const bufA = Buffer.from(a) const bufB = Buffer.from(b) @@ -36,8 +45,8 @@ function safeTokenCompare(a: string, b: string): boolean { export async function authHook(request: FastifyRequest, reply: FastifyReply): Promise { if (!cachedToken) return - // /_web/ internal API: no auth required (same-origin Web UI only) - if (request.url.startsWith('/_web/')) return + // /_web/ internal API: skip auth unless require_auth is enabled + if (request.url.startsWith('/_web/') && !requireAuthEnabled) return // Web mode: only /api/ routes require auth; static files and SPA are public if (webModeEnabled && !request.url.startsWith('/api/')) return diff --git a/apps/cli/src/http/index.ts b/apps/cli/src/http/index.ts index 7268ba4d..71f3cb9b 100644 --- a/apps/cli/src/http/index.ts +++ b/apps/cli/src/http/index.ts @@ -18,7 +18,7 @@ import { verifyCliDataPath, } from '@openchatlab/node-runtime' import { createServer } from './server' -import { setAuthToken, setWebMode } from './auth' +import { setAuthToken, setRequireAuth, setWebMode } from './auth' import { registerSystemRoutes } from './routes/system' import { registerSessionRoutes } from './routes/sessions' import { registerWebRoutes } from './routes/web' @@ -40,6 +40,8 @@ export interface HttpServerOptions { token?: string /** dist-web/ 目录路径,启用后托管 Web SPA 静态资源 */ webRoot?: string + /** When true, /_web/* also requires Bearer token (for server/headless deployments) */ + requireAuth?: boolean } function resolveNativeBinding(): string | undefined { @@ -122,6 +124,9 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ initServerAiLogger(pathProvider.getLogsDir()) setAuthToken(token) + if (options?.requireAuth ?? config.api.require_auth) { + setRequireAuth(true) + } server = createServer() diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index cf705419..a0f9f90e 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -23,6 +23,7 @@ export const apiConfigSchema = z.object({ port: z.number().int().min(1).max(65535).default(DEFAULT_API_PORT), host: z.string().default('127.0.0.1'), token: z.string().default(''), + require_auth: z.boolean().default(false), }) export const localeConfigSchema = z.object({