diff --git a/package-lock.json b/package-lock.json index 6452b18..bb40b1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/__tests__/browser-url-ssrf.test.ts b/src/__tests__/browser-url-ssrf.test.ts new file mode 100644 index 0000000..ab01c39 --- /dev/null +++ b/src/__tests__/browser-url-ssrf.test.ts @@ -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 = {}) { + 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); + }); +});