- 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
210 lines
6.7 KiB
TypeScript
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);
|
|
});
|
|
});
|