P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
287 lines
11 KiB
TypeScript
287 lines
11 KiB
TypeScript
/**
|
||
* SSRF DNS Pinning 测试(v0.7.3 P2-1 → v0.7.5 扩充)
|
||
*
|
||
* 锁定单元:
|
||
* D1 createPinnedLookup —— 只返回校验阶段锁定的 IP 集合(过滤非法 family),
|
||
* 空集合返回 ENOTFOUND(防御)。
|
||
* D2 resolveRedirectTarget —— 重定向状态识别 + 相对/绝对/协议相对 Location 解析 +
|
||
* 非法/缺失 Location 返回 null。
|
||
* D3 resolvePinnedIps —— IP 直连与私网拒绝(走 ssrf-guard 单一事实来源)。
|
||
* D4 ssrfPinnedFetch —— 代理激活退化 / 外部信号中止 / 超时转译 ETIMEDOUT /
|
||
* 正常路径走 pinned undici Agent。
|
||
*/
|
||
|
||
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() },
|
||
}));
|
||
|
||
// ===== DNS 表驱动(resolvePublicAddresses 依赖)=====
|
||
const dnsTable: Record<string, Array<{ address: string; family: number }>> = {
|
||
'public.example.com': [{ address: '93.184.216.34', family: 4 }],
|
||
'nx.example.com': [],
|
||
};
|
||
|
||
vi.mock('node:dns/promises', () => ({
|
||
lookup: vi.fn(async (hostname: string) => {
|
||
if (!(hostname in dnsTable)) {
|
||
throw Object.assign(new Error(`ENOTFOUND ${hostname}`), { code: 'ENOTFOUND' });
|
||
}
|
||
return dnsTable[hostname];
|
||
}),
|
||
}));
|
||
|
||
// ===== 网络代理状态可控 mock(注意相对路径从 __tests__ 到 electron/utils)=====
|
||
const proxyMock = vi.hoisted(() => ({ isProxyActive: vi.fn(() => false) }));
|
||
vi.mock('../../../../utils/network-proxy', () => ({
|
||
isProxyActive: proxyMock.isProxyActive,
|
||
}));
|
||
|
||
// ===== undici 可控 mock(Agent + fetch)=====
|
||
const undiciMock = vi.hoisted(() => {
|
||
const agentInstances: Array<{ closed: boolean; options: unknown }> = [];
|
||
class FakeAgent {
|
||
closed = false;
|
||
constructor(public options: unknown) {
|
||
agentInstances.push(this);
|
||
}
|
||
async close(): Promise<void> {
|
||
this.closed = true;
|
||
}
|
||
}
|
||
const fetch = vi.fn();
|
||
return { agentInstances, FakeAgent, fetch };
|
||
});
|
||
vi.mock('undici', () => ({
|
||
Agent: undiciMock.FakeAgent,
|
||
fetch: undiciMock.fetch,
|
||
}));
|
||
|
||
import {
|
||
createPinnedLookup,
|
||
resolveRedirectTarget,
|
||
resolvePinnedIps,
|
||
ssrfPinnedFetch,
|
||
} from '../ssrf-dispatcher';
|
||
import type { LookupCallback } from '../ssrf-dispatcher';
|
||
|
||
describe('createPinnedLookup', () => {
|
||
function runLookup(lookup: (h: string, o: unknown, cb: LookupCallback) => void, host = 'h') {
|
||
return new Promise<{ address: string; family: number }[]>((resolve, reject) => {
|
||
const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses!));
|
||
lookup(host, {}, cb);
|
||
});
|
||
}
|
||
|
||
it('D1: 仅返回钉死的 IP 集合(忽略 hostname),family 正确标注', async () => {
|
||
const lookup = createPinnedLookup(['93.184.216.34', '2606:2800:220:1:248:1893:25c8:1946']);
|
||
const result = await runLookup(lookup, 'attacker.example');
|
||
expect(result).toHaveLength(2);
|
||
expect(result[0]).toEqual({ address: '93.184.216.34', family: 4 });
|
||
expect(result[1].family).toBe(6);
|
||
});
|
||
|
||
it('D1: 非法 family(非 IPv4/IPv6 字符串)被过滤', async () => {
|
||
const lookup = createPinnedLookup(['not-an-ip']);
|
||
await expect(runLookup(lookup)).rejects.toMatchObject({ code: 'ENOTFOUND' });
|
||
});
|
||
|
||
it('D1: 空集合 → ENOTFOUND(防御:调用方不应构造空 pin dispatcher)', async () => {
|
||
const lookup = createPinnedLookup([]);
|
||
await expect(runLookup(lookup)).rejects.toMatchObject({ code: 'ENOTFOUND' });
|
||
});
|
||
|
||
it('D1: 混合合法 IP 与非法字符串 → 仅返回合法 IP', async () => {
|
||
const lookup = createPinnedLookup(['8.8.8.8', 'garbage', '::1']);
|
||
const result = await runLookup(lookup);
|
||
expect(result).toEqual([
|
||
{ address: '8.8.8.8', family: 4 },
|
||
{ address: '::1', family: 6 },
|
||
]);
|
||
});
|
||
|
||
it('D1: hostname 参数完全被忽略(无论传什么都返回 pin 集合)', async () => {
|
||
const lookup = createPinnedLookup(['1.1.1.1']);
|
||
const result = await runLookup(lookup, 'evil-hostname.example');
|
||
expect(result).toEqual([{ address: '1.1.1.1', family: 4 }]);
|
||
});
|
||
});
|
||
|
||
describe('resolveRedirectTarget', () => {
|
||
const makeResponse = (status: number, location?: string) => ({
|
||
status,
|
||
headers: {
|
||
get: (name: string) => (name.toLowerCase() === 'location' ? (location ?? null) : null),
|
||
},
|
||
});
|
||
|
||
it('D2: 301/302/303/307/308 识别并解析绝对 Location', () => {
|
||
for (const status of [301, 302, 303, 307, 308]) {
|
||
expect(
|
||
resolveRedirectTarget(makeResponse(status, 'https://cdn.example.com/x'), 'https://a.test/'),
|
||
).toBe('https://cdn.example.com/x');
|
||
}
|
||
});
|
||
|
||
it('D2: 相对 Location 以当前 URL 为基解析(RFC 7231)', () => {
|
||
expect(resolveRedirectTarget(makeResponse(302, '/next?a=1'), 'https://a.test/dir/page')).toBe(
|
||
'https://a.test/next?a=1',
|
||
);
|
||
});
|
||
|
||
it('D2: 相对 Location 不带前导斜杠 → 基于目录解析', () => {
|
||
expect(resolveRedirectTarget(makeResponse(302, 'next'), 'https://a.test/dir/page')).toBe(
|
||
'https://a.test/dir/next',
|
||
);
|
||
});
|
||
|
||
it('D2: 协议相对 Location(//host/path)→ 沿用当前协议', () => {
|
||
expect(resolveRedirectTarget(makeResponse(302, '//cdn.example.com/x'), 'https://a.test/')).toBe(
|
||
'https://cdn.example.com/x',
|
||
);
|
||
});
|
||
|
||
it('D2: 带 fragment 的 Location 解析', () => {
|
||
expect(resolveRedirectTarget(makeResponse(301, '/new#section'), 'https://a.test/old')).toBe(
|
||
'https://a.test/new#section',
|
||
);
|
||
});
|
||
|
||
it('D2: 非 3xx 状态 → null(终态)', () => {
|
||
expect(resolveRedirectTarget(makeResponse(200), 'https://a.test/')).toBeNull();
|
||
expect(resolveRedirectTarget(makeResponse(404), 'https://a.test/')).toBeNull();
|
||
expect(resolveRedirectTarget(makeResponse(300), 'https://a.test/')).toBeNull(); // 300 不在集合
|
||
expect(resolveRedirectTarget(makeResponse(304), 'https://a.test/')).toBeNull(); // 304 不在集合
|
||
});
|
||
|
||
it('D2: 缺失/非法 Location → null', () => {
|
||
expect(resolveRedirectTarget(makeResponse(302), 'https://a.test/')).toBeNull();
|
||
expect(resolveRedirectTarget(makeResponse(302, ''), 'https://a.test/')).toBeNull();
|
||
expect(resolveRedirectTarget(makeResponse(302, 'http://[::bad'), 'https://a.test/')).toBeNull();
|
||
});
|
||
|
||
it('D2: Location 为纯路径但当前 URL 含 query → 解析后不丢 query', () => {
|
||
expect(resolveRedirectTarget(makeResponse(303, '/landing'), 'https://a.test/p?x=1')).toBe(
|
||
'https://a.test/landing',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('resolvePinnedIps', () => {
|
||
it('D3: IP 直连 URL —— 公网 IP 直接返回', async () => {
|
||
const ips = await resolvePinnedIps('https://93.184.216.34/x');
|
||
expect(ips).toEqual(['93.184.216.34']);
|
||
});
|
||
|
||
it('D3: 私有/回环 IP 直连被拒(单一事实来源 ssrf-guard)', async () => {
|
||
for (const host of ['127.0.0.1', '10.0.0.5', '169.254.169.254', '192.168.1.1', '[::1]']) {
|
||
await expect(resolvePinnedIps(`http://${host}/latest`)).rejects.toThrow(/Blocked SSRF/);
|
||
}
|
||
});
|
||
|
||
it('D3: 非 http/https 协议被拒', async () => {
|
||
await expect(resolvePinnedIps('ftp://example.com')).rejects.toThrow(/not allowed/);
|
||
});
|
||
|
||
it('D3: 非法 URL 被拒', async () => {
|
||
await expect(resolvePinnedIps('not a url')).rejects.toThrow(/Invalid URL/);
|
||
});
|
||
|
||
it('D3: 公网域名解析返回 pin 集合', async () => {
|
||
const ips = await resolvePinnedIps('http://public.example.com/page');
|
||
expect(ips).toEqual(['93.184.216.34']);
|
||
});
|
||
|
||
it('D3: 无 DNS 记录 → 拒绝', async () => {
|
||
await expect(resolvePinnedIps('http://nx.example.com/')).rejects.toThrow(/no DNS records/);
|
||
});
|
||
});
|
||
|
||
describe('ssrfPinnedFetch — 代理退化 / 超时转译 / 用后即毁', () => {
|
||
beforeEach(() => {
|
||
proxyMock.isProxyActive.mockReturnValue(false);
|
||
undiciMock.fetch.mockReset();
|
||
undiciMock.agentInstances.length = 0; // Agent 实例列表按测试清零
|
||
});
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
proxyMock.isProxyActive.mockReset();
|
||
});
|
||
|
||
it('D4: 代理激活 → 退化为普通 fetch(走全局 fetchWithTimeout,不构造 pinned Agent)', async () => {
|
||
proxyMock.isProxyActive.mockReturnValue(true);
|
||
const glob = vi.fn(async () => new Response('via-proxy', { status: 200 }));
|
||
vi.stubGlobal('fetch', glob);
|
||
|
||
const resp = await ssrfPinnedFetch('http://public.example.com/', { method: 'GET' }, 1000);
|
||
expect(resp.status).toBe(200);
|
||
expect(glob).toHaveBeenCalledTimes(1);
|
||
expect(undiciMock.fetch).not.toHaveBeenCalled();
|
||
// 代理路径无 Agent 实例(无 pin 集合泄漏)
|
||
expect(undiciMock.agentInstances.length).toBe(0);
|
||
});
|
||
|
||
it('D4: 外部信号已中止 → 直接抛 AbortError(不发起请求)', async () => {
|
||
const controller = new AbortController();
|
||
controller.abort();
|
||
await expect(
|
||
ssrfPinnedFetch('http://public.example.com/', {}, 1000, controller.signal),
|
||
).rejects.toMatchObject({ name: 'AbortError' });
|
||
expect(undiciMock.fetch).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('D4: 超时 → 转译为 ETIMEDOUT 错误', async () => {
|
||
undiciMock.fetch.mockImplementation(
|
||
(_url: string, init: { signal?: AbortSignal }) =>
|
||
new Promise((_resolve, reject) => {
|
||
init?.signal?.addEventListener('abort', () => {
|
||
reject(new Error('aborted by timeout'));
|
||
});
|
||
}),
|
||
);
|
||
await expect(ssrfPinnedFetch('http://public.example.com/', {}, 30)).rejects.toMatchObject({
|
||
code: 'ETIMEDOUT',
|
||
message: expect.stringContaining('timed out after 30ms'),
|
||
});
|
||
});
|
||
|
||
it('D4: 正常路径使用 pinned undici Agent(构造一次)且响应透传', async () => {
|
||
undiciMock.fetch.mockResolvedValue(new Response('pinned-ok', { status: 200 }));
|
||
const resp = await ssrfPinnedFetch('http://public.example.com/', { method: 'GET' }, 1000);
|
||
expect(resp.status).toBe(200);
|
||
expect(undiciMock.fetch).toHaveBeenCalledTimes(1);
|
||
expect(undiciMock.agentInstances.length).toBe(1);
|
||
});
|
||
|
||
it('D4: pinned Agent 的 connect.lookup 返回校验 IP 集合(pinning 契约)', async () => {
|
||
undiciMock.fetch.mockResolvedValue(new Response('ok', { status: 200 }));
|
||
await ssrfPinnedFetch('http://public.example.com/', {}, 1000);
|
||
const agent = undiciMock.agentInstances[undiciMock.agentInstances.length - 1];
|
||
const connect = (
|
||
agent.options as { connect: { lookup: (h: string, o: unknown, cb: LookupCallback) => void } }
|
||
).connect;
|
||
expect(typeof connect.lookup).toBe('function');
|
||
const addresses = await new Promise<unknown>((resolve, reject) => {
|
||
connect.lookup('anything.example', {}, (err, addrs) => (err ? reject(err) : resolve(addrs)));
|
||
});
|
||
expect(addresses).toEqual([{ address: '93.184.216.34', family: 4 }]);
|
||
});
|
||
|
||
it('D4: 每次请求构造一次性 Agent,请求结束后关闭(用后即毁)', async () => {
|
||
undiciMock.fetch.mockResolvedValue(new Response('ok', { status: 200 }));
|
||
await ssrfPinnedFetch('http://public.example.com/', {}, 1000);
|
||
await ssrfPinnedFetch('http://public.example.com/', {}, 1000);
|
||
const agents = undiciMock.agentInstances.slice(-2);
|
||
expect(agents.every((a) => a.closed)).toBe(true);
|
||
expect(agents.length).toBe(2); // 两个请求各自独立 Agent,不跨请求复用
|
||
});
|
||
|
||
it('D4: 私有 IP 目标在校验阶段被拒(不构造 Agent、不发请求)', async () => {
|
||
await expect(ssrfPinnedFetch('http://127.0.0.1:9999/x', {}, 1000)).rejects.toThrow(
|
||
/Blocked SSRF/,
|
||
);
|
||
expect(undiciMock.fetch).not.toHaveBeenCalled();
|
||
});
|
||
});
|