# 漏洞总结 ## 漏洞概述 该漏洞涉及对ACP(Attachment Control Policy)附件根目录的强制限制。具体表现为: - **ACP附件根目录的强制限制**:确保ACP附件只能在允许的根目录内。 - **ACP附件根目录的强化**:加强ACP附件根目录的安全性。 - **ACP附件缓存读取的强化**:增强ACP附件缓存读取的安全性。 - **ACP附件跳过日志的澄清**:明确ACP附件跳过时的日志记录。 - **ACP附件路径的保持**:保持ACP附件路径的完整性。 ## 影响范围 - **影响文件**: - `src/auto-reply/reply/dispatch-attachment.test.ts` - `src/auto-reply/reply/dispatch-attachment.ts` - `src/media-understanding/attachments.cache.ts` ## 修复方案 - **代码修改**: - 在 `dispatch-attachment.test.ts` 中增加了多个测试用例,确保ACP附件只能在允许的根目录内。 - 在 `dispatch-attachment.ts` 中增加了 `normalizeAttachmentPath` 函数,用于规范化附件路径,确保路径在允许的根目录内。 - 在 `attachments.cache.ts` 中增加了 `MediaAttachmentCache` 类,用于缓存附件,并在读取时进行安全检查。 ### POC代码 ```typescript // src/auto-reply/reply/dispatch-attachment.test.ts it("skips ACP attachments outside allowed inbound roots", async () => { setReadyAcpResolution(); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-accp")); const imagePath = path.join(tempDir, "outside-root.png"); try { await fs.writeFile(imagePath, "image-bytes"); managerMocks.runTurn.mockResolvedValue(undefined); await runDispatch({ bodyForAgent: " ", ctxOverrides: { MediaPath: imagePath, MediaType: "image/png", }, }); expect(managerMocks.runTurn).not.toHaveBeenCalled(); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } }); it("skips file URL ACP attachments outside allowed inbound roots", async () => { setReadyAcpResolution(); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-accp")); const imagePath = path.join(tempDir, "outside-root.png"); try { await fs.writeFile(imagePath, "image-bytes"); managerMocks.runTurn.mockResolvedValue(undefined); await runDispatch({ bodyForAgent: " ", ctxOverrides: { MediaPath: `file://${imagePath}`, MediaType: "image/png", }, }); expect(managerMocks.runTurn).not.toHaveBeenCalled(); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } }); it("skips relative ACP attachment paths that resolve outside allowed inbound roots", async () => { setReadyAcpResolution(); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-accp")); const imagePath = path.join(tempDir, "outside-root.png"); try { await fs.writeFile(imagePath, "image-bytes"); managerMocks.runTurn.mockResolvedValue(undefined); await runDispatch({ bodyForAgent: " ", ctxOverrides: { MediaPath: path.relative(process.cwd(), imagePath), MediaType: "image/png", }, }); expect(managerMocks.runTurn).not.toHaveBeenCalled(); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } }); it("does not fall back to remote URLs when ACP local attachment paths are blocked", async () => { setReadyAcpResolution(); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-accp")); const imagePath = path.join(tempDir, "outside-root.png"); const fetchSpy = vi.fn(); async () => { new Response(Buffer.from("remote-image"), { headers: { "content-type": "image/png", }, }); }; globalThis.fetch = withFetchPreconnect(fetchSpy as typeof fetch); try { await fs.writeFile(imagePath, "image-bytes"); managerMocks.runTurn.mockResolvedValue(undefined); await runDispatch({ bodyForAgent: " ", ctxOverrides: { MediaPath: imagePath, MediaUrl: "https://example.com/image.png", MediaType: "image/png", }, }); expect(fetchSpy).not.toHaveBeenCalled(); expect(managerMocks.runTurn).not.toHaveBeenCalled(); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } }); it("skips ACP turns for non-image attachments when there is no text prompt", async () => { setReadyAcpResolution(); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-accp")); const imagePath = path.join(tempDir, "outside-root.png"); try { await fs.writeFile(imagePath, "image-bytes"); managerMocks.runTurn.mockResolvedValue(undefined); await runDispatch({ bodyForAgent: " ", ctxOverrides: { MediaPath: imagePath, MediaType: "image/png", }, }); expect(managerMocks.runTurn).not.toHaveBeenCalled(); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } }); ``` ```typescript // src/auto-reply/reply/dispatch-attachment.ts const normalizeAttachmentPath = (ctx: FinalizedMsgContext, cfg: OpenIacConfig): Promise => { const mediaAttachments = normalizeAttachments(ctx); const cache = new MediaAttachmentCache(mediaAttachments, { cfg, ctx }); const localPathRoots = resolveMediaAttachmentLocalRoots({ cfg, ctx }); const results: AcpturnAttachment[] = []; for (const attachment of mediaAttachments) { const attachmentType = attachment.mime ?? "application/octet-stream"; if (attachmentType.startsWith("image/")) { continue; } const filePath = normalizeAttachmentPath(attachment.path); if (!filePath) { continue; } if (attachment.path?.trim()) { continue; } try { const stat = await fs.stat(filePath); if (stat.size > ACP_ATTACHMENT_MAX_BYTES) { logVerbose(`dispatch-accp: skipping attachment ${filePath} (${stat.size} bytes exceeds ${ACP_ATTACHMENT_MAX_BYTES} byte limit)`); continue; } const buf = await fs.readFile(filePath); const buffer = await cache.getBuffer({ attachme