docfast/src/__tests__/browser-url-ssrf.test.ts
OpenClaw Subagent 4a2103c60e chore: update puppeteer 24.40.0, add SSRF DNS pinning tests (TDD)
- Updated puppeteer 24.39.1 → 24.40.0
- Added 7 TDD tests for renderUrlPdf SSRF protection branches:
  - HTTP request rewrite to pinned IP with Host header
  - HTTPS request passthrough (cert compatibility)
  - Blocking requests to non-target hosts
  - Blocking cloud metadata endpoint (169.254.169.254)
  - No interception when hostResolverRules absent
  - No interception when invalid MAP format
  - Request interception setup verification
- Tests: 834 passing (82 files), ZERO failures
2026-03-20 08:11:34 +01:00

210 lines
6.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
/**
* TDD tests for renderUrlPdf SSRF protection (DNS pinning via hostResolverRules).
* Covers branches in browser.ts lines ~300-331:
* - HTTP request rewrite to pinned IP
* - HTTPS request passthrough
* - Blocking requests to non-target hosts
* - No interception when hostResolverRules absent
* - No interception when hostResolverRules doesn't match MAP regex
* - Blocking cloud metadata endpoint requests
*/
vi.unmock("../services/browser.js");
vi.mock("../services/logger.js", () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
function createMockPage(overrides: Record<string, any> = {}) {
return {
setJavaScriptEnabled: vi.fn().mockResolvedValue(undefined),
setContent: vi.fn().mockResolvedValue(undefined),
addStyleTag: vi.fn().mockResolvedValue(undefined),
pdf: vi.fn().mockResolvedValue(Buffer.from("%PDF-1.4 test")),
goto: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
setRequestInterception: vi.fn().mockResolvedValue(undefined),
removeAllListeners: vi.fn().mockReturnThis(),
createCDPSession: vi.fn().mockResolvedValue({
send: vi.fn().mockResolvedValue(undefined),
detach: vi.fn().mockResolvedValue(undefined),
}),
cookies: vi.fn().mockResolvedValue([]),
deleteCookie: vi.fn(),
on: vi.fn(),
...overrides,
} as any;
}
function createMockBrowser(pagesPerBrowser = 2) {
const pages = Array.from({ length: pagesPerBrowser }, () => createMockPage());
let pageIndex = 0;
return {
newPage: vi.fn().mockImplementation(() => Promise.resolve(pages[pageIndex++] || createMockPage())),
close: vi.fn().mockResolvedValue(undefined),
_pages: pages,
} as any;
}
process.env.BROWSER_COUNT = "1";
process.env.PAGES_PER_BROWSER = "2";
describe("renderUrlPdf SSRF DNS pinning", () => {
let browserModule: typeof import("../services/browser.js");
let mockBrowsers: any[] = [];
beforeEach(async () => {
mockBrowsers = [];
vi.resetModules();
vi.doMock("puppeteer", () => ({
default: {
launch: vi.fn().mockImplementation(() => {
const b = createMockBrowser(2);
mockBrowsers.push(b);
return Promise.resolve(b);
}),
},
}));
vi.doMock("../services/logger.js", () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
browserModule = await import("../services/browser.js");
await browserModule.initBrowser();
});
afterEach(async () => {
try { await browserModule.closeBrowser(); } catch {}
});
function getUsedPage() {
return mockBrowsers
.flatMap((b: any) => b._pages)
.find((p: any) => p.on.mock.calls.some((c: any) => c[0] === "request"));
}
function getRequestHandler(page: any) {
const call = page.on.mock.calls.find((c: any) => c[0] === "request");
return call ? call[1] : null;
}
it("sets up request interception when valid MAP rule provided", async () => {
await browserModule.renderUrlPdf("http://example.com", {
hostResolverRules: "MAP example.com 93.184.216.34",
});
const page = getUsedPage();
expect(page).toBeDefined();
expect(page.setRequestInterception).toHaveBeenCalledWith(true);
});
it("rewrites HTTP requests to use pinned IP with Host header", async () => {
await browserModule.renderUrlPdf("http://example.com/path", {
hostResolverRules: "MAP example.com 93.184.216.34",
});
const handler = getRequestHandler(getUsedPage());
expect(handler).not.toBeNull();
const req = {
url: () => "http://example.com/path?q=1",
headers: () => ({ accept: "text/html" }),
continue: vi.fn(),
abort: vi.fn(),
};
handler(req);
expect(req.continue).toHaveBeenCalledWith({
url: "http://93.184.216.34/path?q=1",
headers: { accept: "text/html", host: "example.com" },
});
expect(req.abort).not.toHaveBeenCalled();
});
it("continues HTTPS requests without URL rewrite (cert compatibility)", async () => {
await browserModule.renderUrlPdf("https://example.com", {
hostResolverRules: "MAP example.com 93.184.216.34",
});
const handler = getRequestHandler(getUsedPage());
expect(handler).not.toBeNull();
const req = {
url: () => "https://example.com/page",
headers: () => ({}),
continue: vi.fn(),
abort: vi.fn(),
};
handler(req);
expect(req.continue).toHaveBeenCalledWith();
expect(req.abort).not.toHaveBeenCalled();
});
it("aborts requests to non-target hosts (prevents redirect SSRF)", async () => {
await browserModule.renderUrlPdf("http://example.com", {
hostResolverRules: "MAP example.com 93.184.216.34",
});
const handler = getRequestHandler(getUsedPage());
const req = {
url: () => "http://evil.internal/admin",
headers: () => ({}),
continue: vi.fn(),
abort: vi.fn(),
};
handler(req);
expect(req.abort).toHaveBeenCalledWith("blockedbyclient");
expect(req.continue).not.toHaveBeenCalled();
});
it("blocks cloud metadata endpoint (169.254.169.254)", async () => {
await browserModule.renderUrlPdf("http://example.com", {
hostResolverRules: "MAP example.com 93.184.216.34",
});
const handler = getRequestHandler(getUsedPage());
const req = {
url: () => "http://169.254.169.254/latest/meta-data/",
headers: () => ({}),
continue: vi.fn(),
abort: vi.fn(),
};
handler(req);
expect(req.abort).toHaveBeenCalledWith("blockedbyclient");
});
it("does NOT set up interception when hostResolverRules is absent", async () => {
await browserModule.renderUrlPdf("http://example.com");
const page = mockBrowsers.flatMap((b: any) => b._pages)
.find((p: any) => p.goto.mock.calls.length > 0);
expect(page).toBeDefined();
// Should not have called setRequestInterception(true) — only false from recyclePage
const trueCalls = page.setRequestInterception.mock.calls.filter(
(c: any) => c[0] === true
);
expect(trueCalls.length).toBe(0);
});
it("skips DNS pinning when hostResolverRules doesn't match MAP regex", async () => {
await browserModule.renderUrlPdf("http://example.com", {
hostResolverRules: "BADFORMAT",
});
// CDP session was created (Network.enable), but no request interception
const page = mockBrowsers.flatMap((b: any) => b._pages)
.find((p: any) => p.goto.mock.calls.length > 0);
const trueCalls = page.setRequestInterception.mock.calls.filter(
(c: any) => c[0] === true
);
expect(trueCalls.length).toBe(0);
// No request handler registered
const requestCalls = page.on.mock.calls.filter((c: any) => c[0] === "request");
expect(requestCalls.length).toBe(0);
});
});