Bound PDF print timeout and block WebSocket SSRF in clean-pdf

This commit is contained in:
2026-07-06 12:21:24 +05:00
parent 89383fab1d
commit 0c56b0b809
+48 -8
View File
@@ -8,8 +8,23 @@ import { buildCleanHtml } from "@/lib/clean-pdf/template";
export class EmptyContentError extends Error {}
export class RenderError extends Error {}
/**
* Оборачивает промис в app-level таймаут: если `p` не завершится за `ms`,
* гонка завершится отказом с RenderError, и внешний `finally` (browser.close())
* освободит слот browserless вместо того, чтобы держать его до зависшего вызова.
*/
function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const guard = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new RenderError(message)), ms);
timer.unref?.();
});
return Promise.race([p, guard]).finally(() => clearTimeout(timer)) as Promise<T>;
}
const GOTO_TIMEOUT_MS = 30_000;
const SETTLE_TIMEOUT_MS = 10_000;
const PDF_TIMEOUT_MS = 30_000;
const UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
@@ -38,7 +53,7 @@ export async function generateCleanPdf(opts: {
await context.route("**/*", (route) => {
try {
const u = new URL(route.request().url());
const host = u.hostname.replace(/^\[|\]$/g, "");
const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
if (
(u.protocol !== "http:" && u.protocol !== "https:") ||
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
@@ -52,6 +67,26 @@ export async function generateCleanPdf(opts: {
return route.continue();
});
// context.route НЕ перехватывает WebSocket — отдельная защита от SSRF через ws://
await context.routeWebSocket("**/*", (ws) => {
try {
const u = new URL(ws.url());
const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
if (
(u.protocol !== "ws:" && u.protocol !== "wss:") ||
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
(isIP(host) !== 0 && isPrivateAddress(host))
) {
ws.close();
return;
}
} catch {
ws.close();
return;
}
ws.connectToServer();
});
const page = await context.newPage();
await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: GOTO_TIMEOUT_MS }).catch((e) => {
throw new RenderError(`Страница не открылась: ${e.message}`);
@@ -84,13 +119,18 @@ export async function generateCleanPdf(opts: {
const pdfPage = await context.newPage();
await pdfPage.setContent(cleanHtml, { waitUntil: "networkidle", timeout: SETTLE_TIMEOUT_MS }).catch(() => {});
// page.pdf() в playwright-core не принимает опцию timeout (проверено по
// node_modules/playwright-core/types/types.d.ts) — брифовский вариант с
// timeout здесь не компилировался, поле убрано.
const pdf = await pdfPage.pdf({
format: opts.format,
printBackground: true,
margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" },
});
// node_modules/playwright-core/types/types.d.ts) и не имеет implicit-бага —
// оборачиваем в app-level withTimeout, чтобы зависание всё же дошло до
// finally (browser.close()) и не держало CONCURRENT-слот browserless.
const pdf = await withTimeout(
pdfPage.pdf({
format: opts.format,
printBackground: true,
margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" },
}),
PDF_TIMEOUT_MS,
"Печать PDF не завершилась вовремя",
);
return { pdf, title };
} finally {