import { serve } from '@hono/node-server'; import { Hono } from 'hono'; import { gzipSync } from 'node:zlib'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { buildApp } from '../../src/app/app'; import { makeToken, setupTestJwks } from '../support/token'; // C5:路由的目标地址取自 config,而 config 在模块求值时一次性读 env—— // 注入必须早于 import(vi.hoisted 会被提升到所有 import 之前)。 vi.hoisted(() => { process.env.TASK_SERVICE_URL = 'http://127.0.0.1:18811'; process.env.DATA_SERVICE_URL = 'http://127.0.0.1:1'; // 保证连不上:验 502 }); let downstream: ReturnType; let captured: { auth?: string; fake?: string; path?: string; method?: string; body?: string } = {}; /** 主机保全见证:只有真打到"配置的下游"这台服务的请求才会记进来(C1 回归用) */ const hits: string[] = []; describe('gateway proxy', () => { let token = ''; beforeAll(async () => { await setupTestJwks(); token = await makeToken({ roles: ['user'], aud: 'api-gateway' }); const echo = new Hono(); // 下游压缩响应:undici 会替网关解码 body,content-encoding 不能原样回传(D9) echo.get('/gzip', () => { const body = gzipSync(JSON.stringify({ from: 'downstream' })); return new Response(body, { headers: { 'content-encoding': 'gzip', 'content-type': 'application/json', 'content-length': String(body.byteLength), }, }); }); echo.all('*', async (c) => { captured = { auth: c.req.header('authorization') ?? '', fake: c.req.header('x-user-id') ?? '', path: c.req.path, method: c.req.method, body: await c.req.text(), }; return c.json({ from: 'downstream' }); }); // D11:等 listening 回调再往下走,否则首个用例可能打在还没起来的端口上 // 在路由之前记账:不管 echo 内部匹不匹得上这条路径,只要连上了这台服务就算"主机没被换掉" const witnessed = (req: Request): Response | Promise => { hits.push(new URL(req.url).pathname); return echo.fetch(req); }; downstream = await new Promise>((resolve) => { const s = serve({ fetch: witnessed, port: 18811 }, () => resolve(s)); }); }); afterAll(() => { downstream.close(); }); it('无 token → 401,不打扰下游', async () => { captured = {}; const res = await buildApp().request('/api/task/task/t-1/view'); expect(res.status).toBe(401); expect(captured.path).toBeUndefined(); }); it('带 token 转发:前缀剥除、Authorization 原样、伪造内部头被剥', async () => { const res = await buildApp().request('/api/task/task/t-1/view', { headers: { authorization: `Bearer ${token}`, 'x-user-id': 'forged-user' }, }); expect(res.status).toBe(200); expect(captured.path).toBe('/task/t-1/view'); expect(captured.auth).toBe(`Bearer ${token}`); expect(captured.fake).toBe(''); }); it('POST 转发:方法与请求体原样到下游(F1)', async () => { const res = await buildApp().request('/api/task/task/t-1/claim', { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ note: 'hello' }), }); expect(res.status).toBe(200); expect(captured.method).toBe('POST'); expect(captured.path).toBe('/task/t-1/claim'); expect(JSON.parse(captured.body ?? '')).toEqual({ note: 'hello' }); expect(await res.json()).toEqual({ from: 'downstream' }); }); it('下游不可达 → 502 统一错误形状,点名是哪个下游(C3)', async () => { const res = await buildApp().request('/api/data/ledger/domains', { headers: { authorization: `Bearer ${token}` }, }); expect(res.status).toBe(502); const body = (await res.json()) as { error: string; message: string; hint: string }; expect(body.error).toBe('bad_gateway'); expect(body.message).toContain('http://127.0.0.1:1'); expect(body.hint).toBeTruthy(); }); // C1 回归:剥前缀后余下的路径若以 // 开头就是协议相对 URL,旧写法 new URL(path, target) // 会让它顶掉 target 的 host——Authorization 与请求体直接外泄到攻击者选定的主机。 // 两个变体都选"必然连不上"的主机:位移一旦发生就是 502(端口 1 拒连 / 域名解析失败), // 而 hits 里没有记录;主机保全则相反:不是 502,且配置的下游确实收到了这条路径。 it('路径双斜杠不得位移下游主机:Authorization 不外泄(C1)', async () => { for (const injected of ['//127.0.0.1:1/x', '//evil.example.com/x']) { hits.length = 0; const res = await buildApp().request(`/api/task${injected}`, { headers: { authorization: `Bearer ${token}` }, }); expect(res.status).not.toBe(502); // 502=网关真去连了被注入的主机 expect(hits).toEqual([injected]); // 配置的下游收到了=host 没被换掉 } }); it('下游 gzip 响应:解码后的 body 不得再带 content-encoding(D9)', async () => { const res = await buildApp().request('/api/task/gzip', { headers: { authorization: `Bearer ${token}` }, }); expect(res.status).toBe(200); expect(res.headers.get('content-encoding')).toBeNull(); expect(res.headers.get('content-length')).toBeNull(); expect(await res.json()).toEqual({ from: 'downstream' }); }); });