feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s

This commit is contained in:
2026-09-08 14:30:27 +08:00
parent 69776e447f
commit 4cd6e997b5
86 changed files with 4303 additions and 956 deletions
@@ -0,0 +1,179 @@
/**
* 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;
}
});
});