Archived
鉴权中间件:HEAD 按 GET 判 —— 不归一等于「只要登录就能跑任何 GET 处理器」
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
This commit is contained in:
@@ -66,6 +66,8 @@ const get = (path: string, headers: Record<string, string> = {}): Promise<Respon
|
|||||||
Promise.resolve(app.request(path, { headers }));
|
Promise.resolve(app.request(path, { headers }));
|
||||||
const post = (path: string, headers: Record<string, string> = {}): Promise<Response> =>
|
const post = (path: string, headers: Record<string, string> = {}): Promise<Response> =>
|
||||||
Promise.resolve(app.request(path, { method: 'POST', headers }));
|
Promise.resolve(app.request(path, { method: 'POST', headers }));
|
||||||
|
const head = (path: string, headers: Record<string, string> = {}): Promise<Response> =>
|
||||||
|
Promise.resolve(app.request(path, { method: 'HEAD', headers }));
|
||||||
type Body = { code: string; message: string; detail?: { need: string[]; ends: string[] } };
|
type Body = { code: string; message: string; detail?: { need: string[]; ends: string[] } };
|
||||||
|
|
||||||
describe('鉴权中间件', () => {
|
describe('鉴权中间件', () => {
|
||||||
@@ -122,6 +124,20 @@ describe('鉴权中间件', () => {
|
|||||||
const { ticket } = tickets.issue('sub-1', now);
|
const { ticket } = tickets.issue('sub-1', now);
|
||||||
expect((await get(`/api/gov/tasks?ticket=${encodeURIComponent(ticket)}`, { authorization: 'Bearer admin' })).status).toBe(200);
|
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 () => {
|
it('未知路由:没凭据先 401,有凭据再 404', async () => {
|
||||||
expect((await get('/api/nope')).status).toBe(401);
|
expect((await get('/api/nope')).status).toBe(401);
|
||||||
expect((await get('/api/nope', { authorization: 'Bearer user' })).status).toBe(404);
|
expect((await get('/api/nope', { authorization: 'Bearer user' })).status).toBe(404);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*
|
*
|
||||||
* ① 查路由注册表拿 access;public 直接放行。
|
* ① 查路由注册表拿 access;public 直接放行。
|
||||||
* ② 取凭据:Authorization: Bearer;GET 且没带头时认 ?ticket=(EventSource / <a href> 的路)。
|
* ② 取凭据:Authorization: Bearer;GET 且没带头时认 ?ticket=(EventSource / <a href> 的路)。
|
||||||
|
* 方法先归一(HEAD 按 GET),再查表、再判票据 —— 两处都按分发时的方法算。
|
||||||
* ③ Bearer → org.authenticate(验签 + 名册即时更新 + 角色→端);票据 → org.principalOf(从库现算)。
|
* ③ Bearer → org.authenticate(验签 + 名册即时更新 + 角色→端);票据 → org.principalOf(从库现算)。
|
||||||
* ④ 401 = 没凭据 / 凭据坏了(该重新登录);403 = 人对了但进不了这个端 / 账号停用(重新登录没用)。
|
* ④ 401 = 没凭据 / 凭据坏了(该重新登录);403 = 人对了但进不了这个端 / 账号停用(重新登录没用)。
|
||||||
* 401 与 403 不混用 —— 前端靠它分辨「该重新登录」和「没权限」(手册 4.5)。
|
* 401 与 403 不混用 —— 前端靠它分辨「该重新登录」和「没权限」(手册 4.5)。
|
||||||
@@ -56,15 +57,22 @@ const unauthenticated = (message: string): ApiError => ({ code: 'unauthenticated
|
|||||||
export function createAuthMiddleware(deps: AuthMiddlewareDeps): MiddlewareHandler<AppEnv> {
|
export function createAuthMiddleware(deps: AuthMiddlewareDeps): MiddlewareHandler<AppEnv> {
|
||||||
return async (c, next) => {
|
return async (c, next) => {
|
||||||
c.set('principal', undefined);
|
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,与从前一致(不给没登录的人探路由表)
|
// 未知路由按 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') {
|
if (access === 'public') {
|
||||||
await next();
|
await next();
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bearer = bearerOf(c.req.header('authorization'));
|
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 === '')) {
|
if (bearer === undefined && (ticket === undefined || ticket === '')) {
|
||||||
return c.json(unauthenticated('缺少 Bearer token'), 401);
|
return c.json(unauthenticated('缺少 Bearer token'), 401);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user