fix: 终审修复——网关代理主机不可位移(堵 SSRF/令牌外泄)+onError 统一错误形状
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,11 @@ import { uploadRoutes } from '../modules/upload/routes';
|
|||||||
export function buildApp(): Hono {
|
export function buildApp(): Hono {
|
||||||
registerHealthCheck('nats', checkNats);
|
registerHealthCheck('nats', checkNats);
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
// 统一错误形状:任何未捕获异常都收敛成 {error,message} 的 500,不漏 Hono 默认裸文本/栈
|
||||||
|
app.onError((err, c) => {
|
||||||
|
console.error(err);
|
||||||
|
return c.json({ error: 'internal_error', message: '服务内部错误——已记录,稍后重试或联系维护' }, 500);
|
||||||
|
});
|
||||||
app.route('/health', healthRoute()); // 公开:不过 requireAuth
|
app.route('/health', healthRoute()); // 公开:不过 requireAuth
|
||||||
// 顺序要紧:/api/upload 必须先于 /api 代理挂载,否则被代理的 catch-all 吞掉
|
// 顺序要紧:/api/upload 必须先于 /api 代理挂载,否则被代理的 catch-all 吞掉
|
||||||
app.route('/api/upload', uploadRoutes());
|
app.route('/api/upload', uploadRoutes());
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ const STRIP_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-
|
|||||||
|
|
||||||
/** 验签后转发:Authorization 原样带上(下游自己再验一遍——纵深防御,指南 5.6) */
|
/** 验签后转发:Authorization 原样带上(下游自己再验一遍——纵深防御,指南 5.6) */
|
||||||
export async function forward(c: Context, target: string, stripPrefix: string): Promise<Response> {
|
export async function forward(c: Context, target: string, stripPrefix: string): Promise<Response> {
|
||||||
const search = new URL(c.req.url).search;
|
// 主机不可位移(C1):绝不能 new URL(路径, target)——剥前缀后若余下 `//evil.example.com/x`
|
||||||
const path = c.req.path.slice(stripPrefix.length) || '/';
|
// 这种协议相对路径,URL 解析只保留 target 的 scheme 并把 host 整个换掉,调用方的
|
||||||
|
// Authorization 与请求体就发去攻击者指定的主机,响应还原样回传(SSRF+令牌外泄)。
|
||||||
|
// 改成在 target 上赋 pathname:赋 pathname 只动路径,永远动不了 host/port。
|
||||||
|
const url = new URL(target);
|
||||||
|
const sub = c.req.path.slice(stripPrefix.length) || '/';
|
||||||
|
url.pathname = url.pathname.replace(/\/$/, '') + (sub.startsWith('/') ? sub : `/${sub}`);
|
||||||
|
url.search = new URL(c.req.url).search;
|
||||||
const headers = new Headers(c.req.raw.headers);
|
const headers = new Headers(c.req.raw.headers);
|
||||||
for (const h of STRIP_HEADERS) headers.delete(h);
|
for (const h of STRIP_HEADERS) headers.delete(h);
|
||||||
const method = c.req.method;
|
const method = c.req.method;
|
||||||
@@ -23,7 +29,7 @@ export async function forward(c: Context, target: string, stripPrefix: string):
|
|||||||
}
|
}
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(new URL(path + search, target), init);
|
res = await fetch(url, init);
|
||||||
} catch {
|
} catch {
|
||||||
// 下游没起/连不上:给统一错误形状的 502,并点名是哪个下游
|
// 下游没起/连不上:给统一错误形状的 502,并点名是哪个下游
|
||||||
return c.json(
|
return c.json(
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ vi.hoisted(() => {
|
|||||||
|
|
||||||
let downstream: ReturnType<typeof serve>;
|
let downstream: ReturnType<typeof serve>;
|
||||||
let captured: { auth?: string; fake?: string; path?: string; method?: string; body?: string } = {};
|
let captured: { auth?: string; fake?: string; path?: string; method?: string; body?: string } = {};
|
||||||
|
/** 主机保全见证:只有真打到"配置的下游"这台服务的请求才会记进来(C1 回归用) */
|
||||||
|
const hits: string[] = [];
|
||||||
|
|
||||||
describe('gateway proxy', () => {
|
describe('gateway proxy', () => {
|
||||||
let token = '';
|
let token = '';
|
||||||
@@ -43,8 +45,13 @@ describe('gateway proxy', () => {
|
|||||||
return c.json({ from: 'downstream' });
|
return c.json({ from: 'downstream' });
|
||||||
});
|
});
|
||||||
// D11:等 listening 回调再往下走,否则首个用例可能打在还没起来的端口上
|
// D11:等 listening 回调再往下走,否则首个用例可能打在还没起来的端口上
|
||||||
|
// 在路由之前记账:不管 echo 内部匹不匹得上这条路径,只要连上了这台服务就算"主机没被换掉"
|
||||||
|
const witnessed = (req: Request): Response | Promise<Response> => {
|
||||||
|
hits.push(new URL(req.url).pathname);
|
||||||
|
return echo.fetch(req);
|
||||||
|
};
|
||||||
downstream = await new Promise<ReturnType<typeof serve>>((resolve) => {
|
downstream = await new Promise<ReturnType<typeof serve>>((resolve) => {
|
||||||
const s = serve({ fetch: echo.fetch, port: 18811 }, () => resolve(s));
|
const s = serve({ fetch: witnessed, port: 18811 }, () => resolve(s));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
@@ -92,6 +99,21 @@ describe('gateway proxy', () => {
|
|||||||
expect(body.hint).toBeTruthy();
|
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 () => {
|
it('下游 gzip 响应:解码后的 body 不得再带 content-encoding(D9)', async () => {
|
||||||
const res = await buildApp().request('/api/task/gzip', {
|
const res = await buildApp().request('/api/task/gzip', {
|
||||||
headers: { authorization: `Bearer ${token}` },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
|||||||
Reference in New Issue
Block a user