From 1a48b607b2ce87ebf96a4f461780e9b2316de8c7 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Wed, 23 Sep 2026 14:42:55 +0800 Subject: [PATCH] fix(auth): allow read-only asset queries for password accounts --- .../middleware/asset-query-auth.test.ts | 52 +++++++++++++++++++ src/server/middleware/auth.ts | 3 +- src/server/middleware/read-only-policy.ts | 6 +++ src/server/middleware/read-only.ts | 6 +-- 4 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 src/server/middleware/asset-query-auth.test.ts create mode 100644 src/server/middleware/read-only-policy.ts diff --git a/src/server/middleware/asset-query-auth.test.ts b/src/server/middleware/asset-query-auth.test.ts new file mode 100644 index 0000000..33b61ff --- /dev/null +++ b/src/server/middleware/asset-query-auth.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Hono } from 'hono'; +import { passwordRouter } from '../auth/password.js'; +import { authMiddleware } from './auth.js'; +import { readOnlyMiddleware } from './read-only.js'; +import assistantRouter from '../routes/asset-assistant/index.js'; +import { vehicleRepository } from '../routes/vehicles/repository.js'; + +test('password login can query assets through both guards without gaining write access', async (t) => { + const keys = ['BI_AUTH_MODE', 'BI_AUTH_PASSWORD', 'JWT_SECRET', 'DEV_BYPASS_AUTH', 'DB_READ_ONLY', 'DEEPSEEK_API_KEY'] as const; + const previous = Object.fromEntries(keys.map(key => [key, process.env[key]])); + t.after(() => { for (const key of keys) { if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key]; } }); + Object.assign(process.env, { BI_AUTH_MODE: 'password', BI_AUTH_PASSWORD: 'test-password-only', JWT_SECRET: 'test-signing-secret-with-at-least-32-characters', DEV_BYPASS_AUTH: '0', DEEPSEEK_API_KEY: 'test-only' }); + t.mock.method(vehicleRepository, 'getVehicles', async () => []); + let modelCalls = 0; + t.mock.method(globalThis, 'fetch', async () => { + modelCalls++; + return new Response(JSON.stringify({ choices: [{ message: { content: modelCalls % 2 ? JSON.stringify({kind:'assets',category:'inventory',groupBy:'region',view:'table',filters:{}}) : '当前范围暂无库存。' }, finish_reason:'stop' }] })); + }); + const app = new Hono(); + app.use('/api/*', readOnlyMiddleware); + app.route('/api/auth', passwordRouter()); + app.use('/api/*', authMiddleware); + app.route('/api/asset-assistant', assistantRouter); + let fallbackCalls = 0; + app.all('*', c => { fallbackCalls++; return c.text('ok'); }); + for (const readOnly of ['0', '1']) { + process.env.DB_READ_ONLY = readOnly; + const login = await app.request('/api/auth/password', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({password:process.env.BI_AUTH_PASSWORD}) }); + assert.equal(login.status, 200); + const { token } = await login.json(); + const headers = { Authorization:`Bearer ${token}`, 'Content-Type':'application/json' }; + const response = await app.request('/api/asset-assistant/ask', {method:'POST', headers, body:JSON.stringify({message:'各区域的库存车辆有多少?按数量排序。'})}); + assert.equal(response.status, 200, `DB_READ_ONLY=${readOnly}: ${response.status === 200 ? '' : await response.text()}`); + assert.match(response.headers.get('content-type') || '', /text\/event-stream/); + assert.match(await response.text(), /"type":"result"/); + const before = modelCalls; + for (const authorization of [undefined, 'Bearer invalid']) { + const requestHeaders: Record = authorization ? {Authorization:authorization} : {}; + assert.equal((await app.request('/api/asset-assistant/ask', {method:'POST',headers:requestHeaders,body:'{}'})).status,401); + } + for (const [method, path] of [ + ['POST','/api/feedback'], ['POST','/api/scheduling/confirm'], + ['POST','/api/asset-assistant/other'], ['POST','/api/asset-assistant/ask/extra'], + ['PUT','/api/asset-assistant/ask'], ['PATCH','/api/asset-assistant/ask'], ['DELETE','/api/asset-assistant/ask'], + ]) assert.equal((await app.request(path,{method,headers})).status,403, `${method} ${path}`); + assert.equal(modelCalls,before,'denied requests must not contact the model'); + assert.equal(fallbackCalls,0,'write handlers must not run'); + } + assert.equal(modelCalls,4); +}); diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts index ff3e6e4..40572db 100644 --- a/src/server/middleware/auth.ts +++ b/src/server/middleware/auth.ts @@ -1,4 +1,5 @@ import type { Context, Next } from 'hono'; +import { isReadOnlyRequest } from './read-only-policy.js'; import type { AuthUser } from '../auth/types.js'; import { authMode, verifyAuthToken } from '../auth/config.js'; @@ -42,7 +43,7 @@ export async function authMiddleware(c: Context, next: Next) { try { const payload = verifyAuthToken(token); - if (payload.authMethod === 'password' && !['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) { + if (payload.authMethod === 'password' && !isReadOnlyRequest(c.req.method, path)) { return c.json({ error: 'Password account is read-only' }, 403); } const user: AuthUser = { diff --git a/src/server/middleware/read-only-policy.ts b/src/server/middleware/read-only-policy.ts new file mode 100644 index 0000000..9d8f718 --- /dev/null +++ b/src/server/middleware/read-only-policy.ts @@ -0,0 +1,6 @@ +/** Query POSTs must be explicitly audited; never allow an entire route prefix. */ +export function isReadOnlyRequest(method: string, path: string): boolean { + return ['GET', 'HEAD', 'OPTIONS'].includes(method) + // Interprets a question and reads permission-filtered assets; no business writes. + || (method === 'POST' && path === '/api/asset-assistant/ask'); +} diff --git a/src/server/middleware/read-only.ts b/src/server/middleware/read-only.ts index d5f558a..91140b0 100644 --- a/src/server/middleware/read-only.ts +++ b/src/server/middleware/read-only.ts @@ -1,13 +1,11 @@ import type { MiddlewareHandler } from 'hono'; +import { isReadOnlyRequest } from './read-only-policy.js'; /** Guard preview write endpoints before auth/route handlers can perform work. */ export const readOnlyMiddleware: MiddlewareHandler = async (context, next) => { - // This POST only interprets queries and reads assets; it never writes business data. - const isAssetQuery = context.req.method === 'POST' && context.req.path === '/api/asset-assistant/ask'; if (process.env.DB_READ_ONLY === '1' && context.req.path !== '/api/auth/password' - && !isAssetQuery - && !['GET', 'HEAD', 'OPTIONS'].includes(context.req.method)) { + && !isReadOnlyRequest(context.req.method, context.req.path)) { return context.json({ error: '当前为只读预览环境,不允许写入操作' }, 403); } return next();