Files
metona-ai-desktop/electron/harness/adapters/shared/__tests__/ssrf-image-fetch.test.ts
T
thzxx 4cd6e997b5
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s
feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
2026-09-08 14:30:27 +08:00

180 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v0.8.2 P0-1: 适配器图片下载 SSRF 安全通道测试
*
* 锁定单元:
* - sniffImageMediaType 魔数嗅探
* - 协议白名单(仅 http/https
* - IP 直连私网/云元数据拒绝(resolvePublicAddresses 单一事实来源)
* - DNS 解析到私网拒绝(rebinding 形态)
* - 成功路径:下载 → base64 + 类型钳制(png/jpeg/gif/webp 白名单)
* - 非图片 content-type 拒绝
* - 字节上限(maxBytes + content-length 双闸)
* - 逐跳重定向复检(每一跳重新进入完整校验)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
const proxyMock = vi.hoisted(() => ({ isProxyActive: vi.fn(() => false) }));
vi.mock('../../../../utils/network-proxy', () => ({
isProxyActive: proxyMock.isProxyActive,
}));
// undici mockssrfPinnedFetch 的 pinned 路径需要 Agent + fetch
const undiciMock = vi.hoisted(() => {
class FakeAgent {
async close(): Promise<void> {}
}
const fetch = vi.fn();
return { FakeAgent, fetch };
});
vi.mock('undici', () => ({
Agent: undiciMock.FakeAgent,
fetch: undiciMock.fetch,
}));
import { fetchImageAsBase64, sniffImageMediaType, __imageFetcher } from '../ssrf-image-fetch';
import type { ImageFetcher } from '../ssrf-image-fetch';
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]);
function imageResponse(bytes: Uint8Array, contentType = 'image/png'): Response {
return new Response(bytes, { status: 200, headers: { 'content-type': contentType } });
}
beforeEach(() => {
undiciMock.fetch.mockReset();
proxyMock.isProxyActive.mockReturnValue(false);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('sniffImageMediaType', () => {
it('识别 PNG / JPEG / GIF / WEBP 魔数', () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
expect(sniffImageMediaType(png)).toBe('image/png');
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0]);
expect(sniffImageMediaType(jpeg)).toBe('image/jpeg');
const gif = Buffer.from('GIF89a');
expect(sniffImageMediaType(gif)).toBe('image/gif');
const webp = Buffer.concat([
Buffer.from('RIFF'),
Buffer.from([0x00, 0x00, 0x00, 0x00]),
Buffer.from('WEBP'),
]);
expect(sniffImageMediaType(webp)).toBe('image/webp');
});
it('非图片内容返回 null', () => {
expect(sniffImageMediaType(Buffer.from('hello world, plain text'))).toBeNull();
expect(sniffImageMediaType(Buffer.alloc(0))).toBeNull();
});
});
describe('fetchImageAsBase64 — 校验拒绝矩阵', () => {
it('拒绝非 http/https 协议', async () => {
await expect(fetchImageAsBase64('file:///etc/passwd')).rejects.toThrow(/protocol not allowed/);
await expect(fetchImageAsBase64('ftp://example.com/a.png')).rejects.toThrow(
/protocol not allowed/,
);
});
it('拒绝 IP 直连私网/回环/云元数据', async () => {
await expect(fetchImageAsBase64('http://127.0.0.1/img.png')).rejects.toThrow(/Blocked SSRF/);
await expect(fetchImageAsBase64('http://10.1.2.3/img.png')).rejects.toThrow(/Blocked SSRF/);
await expect(fetchImageAsBase64('http://192.168.1.1/img.png')).rejects.toThrow(/Blocked SSRF/);
await expect(fetchImageAsBase64('http://172.16.0.9/img.png')).rejects.toThrow(/Blocked SSRF/);
// 可回读通道的核心威胁:云元数据
await expect(fetchImageAsBase64('http://169.254.169.254/latest/meta-data')).rejects.toThrow(
/Blocked SSRF/,
);
});
it('拒绝域名解析到私网(DNS rebinding 形态)', async () => {
await expect(fetchImageAsBase64('http://nx.invalid.example/img.png')).rejects.toThrow(
/Blocked SSRF/,
);
});
});
describe('fetchImageAsBase64 — 成功与钳制路径', () => {
it('公网图片下载 → base64 + mediaTypeIP 直连,无需 DNS', async () => {
const original = __imageFetcher.current;
__imageFetcher.current = (async () => imageResponse(PNG_MAGIC)) as unknown as ImageFetcher;
try {
const r = await fetchImageAsBase64('http://93.184.216.34/a.png');
expect(r.mediaType).toBe('image/png');
expect(Buffer.from(r.base64, 'base64').equals(PNG_MAGIC)).toBe(true);
} finally {
__imageFetcher.current = original;
}
});
it('非图片 content-typetext/html)拒绝', async () => {
const original = __imageFetcher.current;
__imageFetcher.current = (async () =>
imageResponse(Buffer.from('<html></html>'), 'text/html')) as unknown as ImageFetcher;
try {
await expect(fetchImageAsBase64('http://93.184.216.34/a.png')).rejects.toThrow(
/non-image content-type/,
);
} finally {
__imageFetcher.current = original;
}
});
it('白名单外图片类型(image/bmp)拒绝', async () => {
const original = __imageFetcher.current;
__imageFetcher.current = (async () =>
imageResponse(Buffer.from('BMxx'), 'image/bmp')) as unknown as ImageFetcher;
try {
await expect(fetchImageAsBase64('http://93.184.216.34/a.bmp')).rejects.toThrow(
/unsupported media type/,
);
} finally {
__imageFetcher.current = original;
}
});
it('字节上限:超过 maxBytes 拒绝(防大图内存峰值)', async () => {
const original = __imageFetcher.current;
__imageFetcher.current = (async () =>
imageResponse(Buffer.alloc(64, 1))) as unknown as ImageFetcher;
try {
await expect(
fetchImageAsBase64('http://93.184.216.34/a.png', { maxBytes: 8 }),
).rejects.toThrow(/size limit/);
} finally {
__imageFetcher.current = original;
}
});
it('逐跳重定向复检:302 跳转后以下一跳 URL 再次下载,终图可用', async () => {
const original = __imageFetcher.current;
const seen: string[] = [];
const fetcher: ImageFetcher = async (url: string) => {
seen.push(url);
if (seen.length === 1) {
return new Response(null, {
status: 302,
headers: { location: 'http://93.184.216.34/final.png' },
});
}
return imageResponse(PNG_MAGIC);
};
__imageFetcher.current = fetcher;
try {
const r = await fetchImageAsBase64('http://93.184.216.34/redirect.png');
expect(r.mediaType).toBe('image/png');
expect(seen).toEqual(['http://93.184.216.34/redirect.png', 'http://93.184.216.34/final.png']);
} finally {
__imageFetcher.current = original;
}
});
});