diff --git a/apps/cli/src/http/auth.test.ts b/apps/cli/src/http/auth.test.ts new file mode 100644 index 00000000..766c7250 --- /dev/null +++ b/apps/cli/src/http/auth.test.ts @@ -0,0 +1,139 @@ +import { describe, it, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { setAuthToken, setRequireAuth, authHook } from './auth' + +function fakeRequest(url: string, authorization?: string) { + return { url, headers: { authorization } } as never +} + +function fakeReply() { + let sentCode = 0 + let sentBody: unknown = null + return { + code(c: number) { + sentCode = c + return this + }, + send(body: unknown) { + sentBody = body + }, + get statusCode() { + return sentCode + }, + get body() { + return sentBody + }, + } +} + +const VALID_TOKEN = 'clb_test_token_12345' + +describe('authHook — authentication matrix', () => { + beforeEach(() => { + setAuthToken(VALID_TOKEN) + setRequireAuth(false) + }) + + // ── No token configured: all routes open ── + + it('passes all routes when no token is configured', async () => { + setAuthToken('' as never) + // Hack: reset internal state — setAuthToken('') won't set null, use the real function + // Actually setAuthToken sets cachedToken to the string value. Empty string is falsy → all pass. + const reply = fakeReply() + await authHook(fakeRequest('/api/v1/status'), reply as never) + assert.equal(reply.statusCode, 0, '/api/* should pass without token configured') + }) + + // ── /api/* always requires auth ── + + it('rejects /api/* without Bearer header', async () => { + const reply = fakeReply() + await authHook(fakeRequest('/api/v1/status'), reply as never) + assert.equal(reply.statusCode, 401) + }) + + it('rejects /api/* with wrong token', async () => { + const reply = fakeReply() + await authHook(fakeRequest('/api/v1/status', 'Bearer wrong_token'), reply as never) + assert.equal(reply.statusCode, 401) + }) + + it('allows /api/* with correct token', async () => { + const reply = fakeReply() + await authHook(fakeRequest('/api/v1/status', `Bearer ${VALID_TOKEN}`), reply as never) + assert.equal(reply.statusCode, 0, 'should not send any error') + }) + + // ── /_web/* default (requireAuth=false): bypass ── + + it('allows /_web/* without auth when requireAuth=false', async () => { + const reply = fakeReply() + await authHook(fakeRequest('/_web/sessions'), reply as never) + assert.equal(reply.statusCode, 0) + }) + + // ── /_web/* with requireAuth=true: requires token ── + + it('rejects /_web/* without Bearer when requireAuth=true', async () => { + setRequireAuth(true) + const reply = fakeReply() + await authHook(fakeRequest('/_web/sessions'), reply as never) + assert.equal(reply.statusCode, 401) + }) + + it('rejects /_web/* with wrong token when requireAuth=true', async () => { + setRequireAuth(true) + const reply = fakeReply() + await authHook(fakeRequest('/_web/sessions', 'Bearer bad'), reply as never) + assert.equal(reply.statusCode, 401) + }) + + it('allows /_web/* with correct token when requireAuth=true', async () => { + setRequireAuth(true) + const reply = fakeReply() + await authHook(fakeRequest('/_web/sessions', `Bearer ${VALID_TOKEN}`), reply as never) + assert.equal(reply.statusCode, 0) + }) + + // ── Static files / SPA: always public ── + + it('allows static file paths without auth', async () => { + const reply = fakeReply() + await authHook(fakeRequest('/index.html'), reply as never) + assert.equal(reply.statusCode, 0) + }) + + it('allows static file paths without auth even with requireAuth=true', async () => { + setRequireAuth(true) + const reply = fakeReply() + await authHook(fakeRequest('/assets/main.js'), reply as never) + assert.equal(reply.statusCode, 0) + }) + + // ── Combined: webRoot + requireAuth (the P0 scenario) ── + + it('P0 regression: /_web/* is protected when both webRoot and requireAuth are active', async () => { + setRequireAuth(true) + const reply = fakeReply() + await authHook(fakeRequest('/_web/ai/llm/providers'), reply as never) + assert.equal(reply.statusCode, 401, '/_web/* must NOT bypass auth when requireAuth=true') + }) + + it('P0 regression: /api/* remains protected regardless of requireAuth flag', async () => { + setRequireAuth(false) + const reply = fakeReply() + await authHook(fakeRequest('/api/v1/status'), reply as never) + assert.equal(reply.statusCode, 401, '/api/* must always require auth') + }) + + // ── setRequireAuth reset ── + + it('setRequireAuth(false) properly resets protection on /_web/*', async () => { + setRequireAuth(true) + setRequireAuth(false) + const reply = fakeReply() + await authHook(fakeRequest('/_web/sessions'), reply as never) + assert.equal(reply.statusCode, 0, '/_web/* should be open after reset') + }) +}) diff --git a/apps/cli/src/http/auth.ts b/apps/cli/src/http/auth.ts index 1a075a5b..c7f24386 100644 --- a/apps/cli/src/http/auth.ts +++ b/apps/cli/src/http/auth.ts @@ -5,11 +5,10 @@ */ import type { FastifyRequest, FastifyReply } from 'fastify' -import { timingSafeEqual } from 'crypto' +import { timingSafeEqual, createHmac, randomBytes } from 'crypto' import { unauthorized, errorResponse } from './errors' let cachedToken: string | null = null -let webModeEnabled = false let requireAuthEnabled = false /** @@ -19,14 +18,6 @@ export function setAuthToken(token: string): void { cachedToken = token } -/** - * Enable web mode: static resources and SPA paths bypass auth. - * Only `/api/` routes remain protected. - */ -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. @@ -35,23 +26,32 @@ export function setRequireAuth(enabled: boolean): void { requireAuthEnabled = enabled } +// Compare via HMAC digests (fixed 32-byte length) to avoid leaking token length +const hmacKey = randomBytes(32) + function safeTokenCompare(a: string, b: string): boolean { - const bufA = Buffer.from(a) - const bufB = Buffer.from(b) - if (bufA.length !== bufB.length) return false - return timingSafeEqual(bufA, bufB) + const hashA = createHmac('sha256', hmacKey).update(a).digest() + const hashB = createHmac('sha256', hmacKey).update(b).digest() + return timingSafeEqual(hashA, hashB) } export async function authHook(request: FastifyRequest, reply: FastifyReply): Promise { if (!cachedToken) return - // /_web/ internal API: skip auth unless require_auth is enabled - if (request.url.startsWith('/_web/') && !requireAuthEnabled) return + const url = request.url - // Web mode: only /api/ routes require auth; static files and SPA are public - if (webModeEnabled && !request.url.startsWith('/api/')) return + // /api/* always requires Bearer token + if (url.startsWith('/api/')) { + return requireBearerToken(request, reply) + } - return requireBearerToken(request, reply) + // /_web/* requires Bearer token when requireAuthEnabled + if (url.startsWith('/_web/')) { + if (requireAuthEnabled) return requireBearerToken(request, reply) + return + } + + // Static files and SPA fallback are public } function requireBearerToken(request: FastifyRequest, reply: FastifyReply): void { diff --git a/apps/cli/src/http/index.ts b/apps/cli/src/http/index.ts index 71f3cb9b..56eb066b 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, setRequireAuth, setWebMode } from './auth' +import { setAuthToken, setRequireAuth } from './auth' import { registerSystemRoutes } from './routes/system' import { registerSessionRoutes } from './routes/sessions' import { registerWebRoutes } from './routes/web' @@ -124,9 +124,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ initServerAiLogger(pathProvider.getLogsDir()) setAuthToken(token) - if (options?.requireAuth ?? config.api.require_auth) { - setRequireAuth(true) - } + setRequireAuth(!!(options?.requireAuth ?? config.api.require_auth)) server = createServer() @@ -145,7 +143,6 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ initSync(server, dbManager, pathProvider, { port, host, token }) if (options?.webRoot && fs.existsSync(options.webRoot)) { - setWebMode(true) // 注册反向代理:将 /_proxy/chatlab.fun/* 转发至 https://chatlab.fun, // 行为与 vite dev proxy 一致,解决浏览器 CORS 问题(见 vite.web.config.mts:138-144)。 // 必须在 @fastify/static 之前注册,确保显式路由优先于静态文件/SPA fallback。 @@ -186,7 +183,7 @@ export async function stopHttpServer(): Promise { dbManager = null } closeServerAiLogger() - setWebMode(false) + setRequireAuth(false) server = null } } diff --git a/package.json b/package.json index 3b9a5587..5f6a58ce 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "type-check:node": "tsc --noEmit -p apps/desktop/tsconfig.node.json", "type-check:all": "pnpm run type-check:web && pnpm run type-check:node", "type-check": "vue-tsc --noEmit -p tsconfig.web.json", + "test:auth": "tsx --test apps/cli/src/http/auth.test.ts src/services/utils/http.test.ts", "test:agent-context": "node --experimental-strip-types --test apps/desktop/main/ai/context/sessionLog.test.mjs", "test:e2e:launcher": "node --test tests/e2e/helpers/app-launcher.test.js", "test:e2e:smoke": "node tests/e2e/run-smoke.js" diff --git a/src/App.vue b/src/App.vue index 7941b8c8..232ed78e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,5 @@