feat: 网关按域路由——验签拦门、剥伪造内部头、Authorization 原样转发

This commit is contained in:
lovefyf
2026-08-13 05:04:10 +08:00
parent 6beb663cf0
commit af1c1a8eb4
4 changed files with 151 additions and 1 deletions
+3 -1
View File
@@ -1,10 +1,12 @@
import { Hono } from 'hono';
import { healthRoute, registerHealthCheck } from './health';
import { checkNats } from '../shared/bus/nats';
import { proxyRoutes } from '../modules/proxy/routes';
export function buildApp(): Hono {
registerHealthCheck('nats', checkNats);
const app = new Hono();
app.route('/health', healthRoute());
app.route('/health', healthRoute()); // 公开:不过 requireAuth
app.route('/api', proxyRoutes());
return app;
}
+37
View File
@@ -0,0 +1,37 @@
import type { Context } from 'hono';
const STRIP_HEADERS = ['x-user-id', 'x-user-roles', 'x-internal-token', 'host', 'content-length'];
/**
* 回传时必须丢掉的下游响应头:undici 已按 content-encoding 解过压,
* body 是明文,再原样回传 content-encoding/content-length/transfer-encoding
* 就是自相矛盾的响应(客户端会二次解压失败或按错长度截断)。
*/
const STRIP_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding'];
/** 验签后转发:Authorization 原样带上(下游自己再验一遍——纵深防御,指南 5.6) */
export async function forward(c: Context, target: string, stripPrefix: string): Promise<Response> {
const search = new URL(c.req.url).search;
const path = c.req.path.slice(stripPrefix.length) || '/';
const headers = new Headers(c.req.raw.headers);
for (const h of STRIP_HEADERS) headers.delete(h);
const method = c.req.method;
const init: RequestInit & { duplex?: 'half' } = { method, headers };
if (method !== 'GET' && method !== 'HEAD') {
init.body = c.req.raw.body;
init.duplex = 'half'; // Node fetch 流式请求体必需
}
let res: Response;
try {
res = await fetch(new URL(path + search, target), init);
} catch {
// 下游没起/连不上:给统一错误形状的 502,并点名是哪个下游
return c.json(
{ error: 'bad_gateway', message: `下游服务 ${target} 不可达`, hint: '确认目标服务已启动' },
502,
);
}
const outHeaders = new Headers(res.headers);
for (const h of STRIP_RESPONSE_HEADERS) outHeaders.delete(h);
return new Response(res.body, { status: res.status, headers: outHeaders });
}
+22
View File
@@ -0,0 +1,22 @@
import { Hono } from 'hono';
import { config } from '../../shared/config';
import { requireAuth } from '../../shared/keycloak-auth';
import { forward } from './forward';
/** 目标地址取自 config——env+缺省只在 config.ts 读一次,此处不再二次读 env */
function target(name: 'TASK' | 'DATA' | 'AGENT'): string {
return {
TASK: config.taskServiceUrl,
DATA: config.dataServiceUrl,
AGENT: config.agentServiceUrl,
}[name];
}
export function proxyRoutes(): Hono {
const r = new Hono();
r.use('*', requireAuth());
r.all('/task/*', (c) => forward(c, target('TASK'), '/api/task'));
r.all('/data/*', (c) => forward(c, target('DATA'), '/api/data'));
r.all('/agent/*', (c) => forward(c, target('AGENT'), '/api/agent'));
return r;
}
+89
View File
@@ -0,0 +1,89 @@
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<typeof serve>;
let captured: { auth?: string; fake?: string; path?: 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('*', (c) => {
captured = {
auth: c.req.header('authorization') ?? '',
fake: c.req.header('x-user-id') ?? '',
path: c.req.path,
};
return c.json({ from: 'downstream' });
});
// D11:等 listening 回调再往下走,否则首个用例可能打在还没起来的端口上
downstream = await new Promise<ReturnType<typeof serve>>((resolve) => {
const s = serve({ fetch: echo.fetch, 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('下游不可达 → 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();
});
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' });
});
});