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:
parent
789d36ea04
commit
4a2103c60e
2 changed files with 230 additions and 20 deletions
40
package-lock.json
generated
40
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
210
src/__tests__/browser-url-ssrf.test.ts
Normal file
210
src/__tests__/browser-url-ssrf.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue