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 <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-06-02 15:41:21 +08:00
1 parent a6039d95ab
commit a75fd0186d
5 files changed
+25 -6

No files matched your search

+3
View File
@@ -286,6 +286,7 @@ program
.option('--host <host>', 'Listen address', '127.0.0.1')
.option('--token <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')
+4 -3
View File
@@ -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) => ` <string>${a}</string>`)
.join('\n')
const programArgs = [process.execPath, cliEntry, ...args].map((a) => ` <string>${a}</string>`).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]
+11 -2
View File
@@ -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<void> {
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
+6 -1
View File
@@ -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()
+1
View File
@@ -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({