From 5964a80b2886fb760948447b96d75b6509139054 Mon Sep 17 00:00:00 2001 From: xcs Date: Tue, 25 Aug 2026 19:41:43 +0800 Subject: [PATCH] =?UTF-8?q?=E9=89=B4=E6=9D=83=E4=B8=AD=E9=97=B4=E4=BB=B6:H?= =?UTF-8?q?EAD=20=E6=8C=89=20GET=20=E5=88=A4=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E4=B8=8D=E5=BD=92=E4=B8=80=E7=AD=89=E4=BA=8E=E3=80=8C=E5=8F=AA?= =?UTF-8?q?=E8=A6=81=E7=99=BB=E5=BD=95=E5=B0=B1=E8=83=BD=E8=B7=91=E4=BB=BB?= =?UTF-8?q?=E4=BD=95=20GET=20=E5=A4=84=E7=90=86=E5=99=A8=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hono 在 #dispatch 里把 HEAD 改写成 GET 再分发,而 `c.req.method` 读的是请求行上的 原始方法,仍是 'HEAD';路由注册表只装 HTTP_METHODS 那五个,于是 lookup('HEAD', …) **必然 miss** → 落进 `?? 'any'` 兜底 → 一个 `ends: []` 的人(Keycloak 里还没配角色的 新员工)照样能执行三个端的全部 GET 处理器。不绕过认证(无凭据仍 401),够不到 POST/PUT/DELETE,响应体被框架丢掉 —— 但它绕过**端授权**:响应头照抄过去 (desk 那两处 content-disposition 带着资料文件名),而 GET 处理器不是都没副作用 (`GET /api/reports/:period` 一路走到 report-service 的 store.save)。 改:查表之前先归一方法;**票据那一路的 GET 判断用同一个归一后的 method**, 否则 HEAD 拿不到票据那条路,两条路行为不一致。 测(auth-middleware.test.ts +3):HEAD 到 ops 路由持只有 workbench 的 token → 403 (与 GET 同款)· HEAD 到自己端内的路由 → 200 · HEAD 上的票据与 GET 同款。 反向验证:撤掉归一那一行,前两条当场红(200 / 401),装回即绿。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw --- .../src/app/__tests__/auth-middleware.test.ts | 16 ++++++++++++++++ apps/api/src/app/auth-middleware.ts | 12 ++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/__tests__/auth-middleware.test.ts b/apps/api/src/app/__tests__/auth-middleware.test.ts index a4dd56c..5881c31 100644 --- a/apps/api/src/app/__tests__/auth-middleware.test.ts +++ b/apps/api/src/app/__tests__/auth-middleware.test.ts @@ -66,6 +66,8 @@ const get = (path: string, headers: Record = {}): Promise = {}): Promise => Promise.resolve(app.request(path, { method: 'POST', headers })); +const head = (path: string, headers: Record = {}): Promise => + Promise.resolve(app.request(path, { method: 'HEAD', headers })); type Body = { code: string; message: string; detail?: { need: string[]; ends: string[] } }; describe('鉴权中间件', () => { @@ -122,6 +124,20 @@ describe('鉴权中间件', () => { const { ticket } = tickets.issue('sub-1', now); expect((await get(`/api/gov/tasks?ticket=${encodeURIComponent(ticket)}`, { authorization: 'Bearer admin' })).status).toBe(200); }); + it('HEAD 按 GET 判:端不够 → 403,与 GET 同款', async () => { + // Hono 在 #dispatch 里把 HEAD 改写成 GET 再分发,而 c.req.method 仍是 'HEAD' —— + // 不归一的话查表必然 miss、落进「any」兜底,于是「只要登录就能跑任何 GET 处理器」。 + expect((await get('/api/gov/tasks', { authorization: 'Bearer user' })).status).toBe(403); + expect((await head('/api/gov/tasks', { authorization: 'Bearer user' })).status).toBe(403); + }); + it('HEAD 该进的还得进:端够了 → 200', async () => { + expect((await head('/api/desk/tasks', { authorization: 'Bearer user' })).status).toBe(200); + }); + it('HEAD 上的票据与 GET 同款(归一之后两条路一致)', async () => { + const { ticket } = tickets.issue('sub-1', now); + expect((await head(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`)).status).toBe(200); + expect((await head('/api/gov/tasks', {})).status).toBe(401); + }); it('未知路由:没凭据先 401,有凭据再 404', async () => { expect((await get('/api/nope')).status).toBe(401); expect((await get('/api/nope', { authorization: 'Bearer user' })).status).toBe(404); diff --git a/apps/api/src/app/auth-middleware.ts b/apps/api/src/app/auth-middleware.ts index ec5b67f..0501282 100644 --- a/apps/api/src/app/auth-middleware.ts +++ b/apps/api/src/app/auth-middleware.ts @@ -3,6 +3,7 @@ * * ① 查路由注册表拿 access;public 直接放行。 * ② 取凭据:Authorization: Bearer;GET 且没带头时认 ?ticket=(EventSource / 的路)。 + * 方法先归一(HEAD 按 GET),再查表、再判票据 —— 两处都按分发时的方法算。 * ③ Bearer → org.authenticate(验签 + 名册即时更新 + 角色→端);票据 → org.principalOf(从库现算)。 * ④ 401 = 没凭据 / 凭据坏了(该重新登录);403 = 人对了但进不了这个端 / 账号停用(重新登录没用)。 * 401 与 403 不混用 —— 前端靠它分辨「该重新登录」和「没权限」(手册 4.5)。 @@ -56,15 +57,22 @@ const unauthenticated = (message: string): ApiError => ({ code: 'unauthenticated export function createAuthMiddleware(deps: AuthMiddlewareDeps): MiddlewareHandler { return async (c, next) => { c.set('principal', undefined); + /* + * HEAD 按 GET 判。Hono 在 #dispatch 里把 HEAD 改写成 GET 再分发,而 `c.req.method` + * 读的是请求行上的原始方法 —— 不归一的话 lookup 必然 miss,落进 `?? 'any'` 兜底, + * 于是「只要登录就能跑任何 GET 处理器」。**凭据与归属一律按分发时的方法判, + * 不按请求行上的字面方法。** + */ + const method = c.req.method === 'HEAD' ? 'GET' : c.req.method; // 未知路由按 any 处置:先鉴权再 404,与从前一致(不给没登录的人探路由表) - const access = deps.registry.lookup(c.req.method, c.req.path) ?? 'any'; + const access = deps.registry.lookup(method, c.req.path) ?? 'any'; if (access === 'public') { await next(); return undefined; } const bearer = bearerOf(c.req.header('authorization')); - const ticket = c.req.method === 'GET' ? c.req.query('ticket') : undefined; + const ticket = method === 'GET' ? c.req.query('ticket') : undefined; if (bearer === undefined && (ticket === undefined || ticket === '')) { return c.json(unauthenticated('缺少 Bearer token'), 401); }