Bound PDF print timeout and block WebSocket SSRF in clean-pdf
This commit is contained in:
@@ -8,8 +8,23 @@ import { buildCleanHtml } from "@/lib/clean-pdf/template";
|
|||||||
export class EmptyContentError extends Error {}
|
export class EmptyContentError extends Error {}
|
||||||
export class RenderError 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 GOTO_TIMEOUT_MS = 30_000;
|
||||||
const SETTLE_TIMEOUT_MS = 10_000;
|
const SETTLE_TIMEOUT_MS = 10_000;
|
||||||
|
const PDF_TIMEOUT_MS = 30_000;
|
||||||
const UA =
|
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";
|
"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) => {
|
await context.route("**/*", (route) => {
|
||||||
try {
|
try {
|
||||||
const u = new URL(route.request().url());
|
const u = new URL(route.request().url());
|
||||||
const host = u.hostname.replace(/^\[|\]$/g, "");
|
const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
||||||
if (
|
if (
|
||||||
(u.protocol !== "http:" && u.protocol !== "https:") ||
|
(u.protocol !== "http:" && u.protocol !== "https:") ||
|
||||||
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
|
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
|
||||||
@@ -52,6 +67,26 @@ export async function generateCleanPdf(opts: {
|
|||||||
return route.continue();
|
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();
|
const page = await context.newPage();
|
||||||
await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: GOTO_TIMEOUT_MS }).catch((e) => {
|
await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: GOTO_TIMEOUT_MS }).catch((e) => {
|
||||||
throw new RenderError(`Страница не открылась: ${e.message}`);
|
throw new RenderError(`Страница не открылась: ${e.message}`);
|
||||||
@@ -84,13 +119,18 @@ export async function generateCleanPdf(opts: {
|
|||||||
const pdfPage = await context.newPage();
|
const pdfPage = await context.newPage();
|
||||||
await pdfPage.setContent(cleanHtml, { waitUntil: "networkidle", timeout: SETTLE_TIMEOUT_MS }).catch(() => {});
|
await pdfPage.setContent(cleanHtml, { waitUntil: "networkidle", timeout: SETTLE_TIMEOUT_MS }).catch(() => {});
|
||||||
// page.pdf() в playwright-core не принимает опцию timeout (проверено по
|
// page.pdf() в playwright-core не принимает опцию timeout (проверено по
|
||||||
// node_modules/playwright-core/types/types.d.ts) — брифовский вариант с
|
// node_modules/playwright-core/types/types.d.ts) и не имеет implicit-бага —
|
||||||
// timeout здесь не компилировался, поле убрано.
|
// оборачиваем в app-level withTimeout, чтобы зависание всё же дошло до
|
||||||
const pdf = await pdfPage.pdf({
|
// finally (browser.close()) и не держало CONCURRENT-слот browserless.
|
||||||
|
const pdf = await withTimeout(
|
||||||
|
pdfPage.pdf({
|
||||||
format: opts.format,
|
format: opts.format,
|
||||||
printBackground: true,
|
printBackground: true,
|
||||||
margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" },
|
margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" },
|
||||||
});
|
}),
|
||||||
|
PDF_TIMEOUT_MS,
|
||||||
|
"Печать PDF не завершилась вовремя",
|
||||||
|
);
|
||||||
|
|
||||||
return { pdf, title };
|
return { pdf, title };
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user