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
This commit is contained in:
OpenClaw Subagent 2026-03-20 08:11:34 +01:00
parent 789d36ea04
commit 4a2103c60e
2 changed files with 230 additions and 20 deletions

40
package-lock.json generated
View file

@ -1613,9 +1613,9 @@
}
},
"node_modules/bare-fs": {
"version": "4.5.5",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz",
"integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==",
"version": "4.5.6",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.6.tgz",
"integrity": "sha512-1QovqDrR80Pmt5HPAsMsXTCFcDYr+NSUKW6nd6WO5v0JBmnItc/irNRzm2KOQ5oZ69P37y+AMujNyNtG+1Rggw==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.5.4",
@ -1655,12 +1655,12 @@
}
},
"node_modules/bare-stream": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.1.tgz",
"integrity": "sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg==",
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.10.0.tgz",
"integrity": "sha512-DOPZF/DDcDruKDA43cOw6e9Quq5daua7ygcAwJE/pKJsRWhgSSemi7qVNGE5kyDIxIeN1533G/zfbvWX7Wcb9w==",
"license": "Apache-2.0",
"dependencies": {
"streamx": "^2.21.0",
"streamx": "^2.25.0",
"teex": "^1.0.1"
},
"peerDependencies": {
@ -1677,9 +1677,9 @@
}
},
"node_modules/bare-url": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz",
"integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz",
"integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==",
"license": "Apache-2.0",
"dependencies": {
"bare-path": "^3.0.0"
@ -4140,9 +4140,9 @@
}
},
"node_modules/puppeteer": {
"version": "24.39.1",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.39.1.tgz",
"integrity": "sha512-68Zc9QpcVvfxp2C+3UL88TyUogEAn5tSylXidbEuEXvhiqK1+v65zeBU5ubinAgEHMGr3dcSYqvYrGtdzsPI3w==",
"version": "24.40.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.40.0.tgz",
"integrity": "sha512-IxQbDq93XHVVLWHrAkFP7F7iHvb9o0mgfsSIMlhHb+JM+JjM1V4v4MNSQfcRWJopx9dsNOr9adYv0U5fm9BJBQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@ -4150,7 +4150,7 @@
"chromium-bidi": "14.0.0",
"cosmiconfig": "^9.0.0",
"devtools-protocol": "0.0.1581282",
"puppeteer-core": "24.39.1",
"puppeteer-core": "24.40.0",
"typed-query-selector": "^2.12.1"
},
"bin": {
@ -4161,9 +4161,9 @@
}
},
"node_modules/puppeteer-core": {
"version": "24.39.1",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.39.1.tgz",
"integrity": "sha512-AMqQIKoEhPS6CilDzw0Gd1brLri3emkC+1N2J6ZCCuY1Cglo56M63S0jOeBZDQlemOiRd686MYVMl9ELJBzN3A==",
"version": "24.40.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.40.0.tgz",
"integrity": "sha512-MWL3XbUCfVgGR0gRsidzT6oKJT2QydPLhMITU6HoVWiiv4gkb6gJi3pcdAa8q4HwjBTbqISOWVP4aJiiyUJvag==",
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "2.13.0",
@ -4706,9 +4706,9 @@
"license": "MIT"
},
"node_modules/streamx": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
"integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==",
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz",
"integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==",
"license": "MIT",
"dependencies": {
"events-universal": "^1.0.0",

View file

@ -0,0 +1,210 @@
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);
});
});