From eb279a87a9a6db46d623863ee3d1c1213a3ef499 Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 12 Jun 2026 08:27:47 +0200 Subject: [PATCH] fix(pdf): Segoe UI everywhere + rich-text colors survive sanitization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - section/Quill overrides, footers and the shared header now lead with 'Segoe UI' (installed on prod) instead of Tahoma (not installed — prod silently rendered sections and footers in Noto Sans, mismatching the Segoe UI body; dev previews showed real Tahoma and never matched prod) - cleanQuillHtml no longer deletes ALL style attributes: color and background-color survive with strictly validated literal values (hex, rgb/rgba, keyword) so editor font colors finally reach the PDF; url(), expression() and every other declaration are still stripped — new test file pins both the kept colors and the attack vectors Co-Authored-By: Claude Fable 5 --- src/__tests__/pdf-shared.test.ts | 44 +++++++++++++++++++++++++++ src/routes/admin/invoices-pdf.ts | 6 ++-- src/routes/admin/issued-orders-pdf.ts | 6 ++-- src/routes/admin/offers-pdf.ts | 4 +-- src/routes/admin/orders-pdf.ts | 4 +-- src/utils/pdf-shared.ts | 32 +++++++++++++++++-- 6 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/pdf-shared.test.ts diff --git a/src/__tests__/pdf-shared.test.ts b/src/__tests__/pdf-shared.test.ts new file mode 100644 index 0000000..a283bd7 --- /dev/null +++ b/src/__tests__/pdf-shared.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { cleanQuillHtml } from "../utils/pdf-shared"; + +describe("cleanQuillHtml — inline-style whitelist", () => { + it("keeps Quill text and background colors", () => { + const html = + '

červeně ' + + 'zvýrazněně ' + + 'modře

'; + const out = cleanQuillHtml(html); + expect(out).toContain('style="color: rgb(230, 0, 0)"'); + expect(out).toContain('style="background-color: #ffff00"'); + expect(out).toContain('style="color: #06c"'); + }); + + it("drops every non-color style declaration", () => { + const out = cleanQuillHtml( + 'x', + ); + expect(out).toBe('x'); + }); + + it("drops unsafe color values (url, expression, quotes)", () => { + expect( + cleanQuillHtml('x'), + ).toBe("x"); + expect( + cleanQuillHtml('x'), + ).toBe("x"); + expect(cleanQuillHtml("x")).toBe( + "x", + ); + }); + + it("keeps stripping scripts, event handlers and js: URLs", () => { + const out = cleanQuillHtml( + '

' + + 'x

', + ); + expect(out).not.toContain("script"); + expect(out).not.toContain("onclick"); + expect(out).not.toContain("javascript:"); + }); +}); diff --git a/src/routes/admin/invoices-pdf.ts b/src/routes/admin/invoices-pdf.ts index bc4c0df..005110a 100644 --- a/src/routes/admin/invoices-pdf.ts +++ b/src/routes/admin/invoices-pdf.ts @@ -253,7 +253,7 @@ export function buildInvoicePdfFooter( const t = translations[lang] || translations.cs; const pageWord = lang === "cs" ? "Strana" : "Page"; const ofWord = lang === "cs" ? "z" : "of"; - return `
+ return `
${escapeHtml(t.issued_by)} ${escapeHtml(issuerLine)}
${pageWord} ${ofWord}
@@ -898,7 +898,7 @@ export default async function invoicesPdfRoutes( } .section-content, .section-content * { - font-family: Tahoma, sans-serif !important; + font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; } .section-content { font-size: 14px; } .section-content h1 { font-size: 20px; } @@ -910,7 +910,7 @@ export default async function invoicesPdfRoutes( .section-content li { margin-bottom: 0.2em; } /* Quill fonty – v PDF vynuceno Tahoma */ - [class*="ql-font-"] { font-family: Tahoma, sans-serif !important; } + [class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; } .ql-align-center { text-align: center; } .ql-align-right { text-align: right; } .ql-align-justify { text-align: justify; } diff --git a/src/routes/admin/issued-orders-pdf.ts b/src/routes/admin/issued-orders-pdf.ts index 1bb9a8c..79e5e78 100644 --- a/src/routes/admin/issued-orders-pdf.ts +++ b/src/routes/admin/issued-orders-pdf.ts @@ -267,7 +267,7 @@ export function buildIssuedOrderPdfFooter( const t = translations[lang]; const pageWord = lang === "cs" ? "Strana" : "Page"; const ofWord = lang === "cs" ? "z" : "of"; - return `
+ return `
${escapeHtml(t.issued_by)} ${escapeHtml(issuerName)}
${pageWord} ${ofWord}
@@ -655,7 +655,7 @@ export function renderIssuedOrderHtml( } .section-content, .section-content * { - font-family: Tahoma, sans-serif !important; + font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; } .section-content { font-size: 14px; } .section-content h1 { font-size: 20px; } @@ -667,7 +667,7 @@ export function renderIssuedOrderHtml( .section-content li { margin-bottom: 0.2em; } /* Quill fonty – v PDF vynuceno Tahoma */ - [class*="ql-font-"] { font-family: Tahoma, sans-serif !important; } + [class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; } .ql-align-center { text-align: center; } .ql-align-right { text-align: right; } .ql-align-justify { text-align: justify; } diff --git a/src/routes/admin/offers-pdf.ts b/src/routes/admin/offers-pdf.ts index f076a78..73a0b51 100644 --- a/src/routes/admin/offers-pdf.ts +++ b/src/routes/admin/offers-pdf.ts @@ -293,7 +293,7 @@ export function renderOfferHtml( img, table, pre, code { max-width: 100%; } /* ---- Quill font classes – v PDF vynuceno Tahoma ---- */ - [class*="ql-font-"] { font-family: Tahoma, sans-serif !important; } + [class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; } /* ---- Quill alignment ---- */ .ql-align-center { text-align: center; } @@ -502,7 +502,7 @@ ${indentCSS} } .section-content, .section-content * { - font-family: Tahoma, sans-serif !important; + font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; } .section-content { font-size: 14px; } .section-content h1 { font-size: 20px; } diff --git a/src/routes/admin/orders-pdf.ts b/src/routes/admin/orders-pdf.ts index 5769130..08cfe35 100644 --- a/src/routes/admin/orders-pdf.ts +++ b/src/routes/admin/orders-pdf.ts @@ -570,7 +570,7 @@ export function renderOrderConfirmationHtml( .invoice-notes-content li { margin-bottom: 0.2em; } .invoice-notes-content, .invoice-notes-content * { - font-family: Tahoma, sans-serif !important; + font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; } .invoice-notes-content { font-size: 14px; } .invoice-notes-content h1 { font-size: 20px; } @@ -579,7 +579,7 @@ export function renderOrderConfirmationHtml( .invoice-notes-content h4 { font-size: 15px; } /* Quill fonty – v PDF vynuceno Tahoma */ - [class*="ql-font-"] { font-family: Tahoma, sans-serif !important; } + [class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; } .ql-align-center { text-align: center; } .ql-align-right { text-align: right; } .ql-align-justify { text-align: justify; } diff --git a/src/utils/pdf-shared.ts b/src/utils/pdf-shared.ts index 4358030..d325f76 100644 --- a/src/utils/pdf-shared.ts +++ b/src/utils/pdf-shared.ts @@ -94,7 +94,7 @@ export function buildPdfHeaderTemplate( logoDataUri: string, heading: string, ): string { - return `
+ return `
${logoDataUri ? `` : ""}
${escapeHtml(heading)}
@@ -108,6 +108,13 @@ export function buildPdfHeaderTemplate( * and merge adjacent identical spans. Runs AFTER DOMPurify at every call site * (regex pass = defense-in-depth, not the primary sanitizer). */ +/** + * Literal CSS color values only: #hex, rgb()/rgba() with numeric components, + * or a bare keyword. No quotes, parentheses beyond rgb(a), url(), variables. + */ +const SAFE_CSS_COLOR = + /^(#[0-9a-f]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+)\s*)?\)|[a-z]{3,20})$/i; + export function cleanQuillHtml(html: string | null | undefined): string { if (!html) return ""; let s = html; @@ -134,7 +141,28 @@ export function cleanQuillHtml(html: string | null | undefined): string { ); // Replace   with regular space (outside of tags) s = s.replace(/( )/g, " "); - s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, ""); + // Inline styles: Quill writes text/background colors as style attributes. + // Keep ONLY color/background-color with literal color values — everything + // else (url(), expression(), positioning…) must never reach Puppeteer. + s = s.replace( + /\s+style\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi, + (_m, dq, sq, bare) => { + const kept = String(dq ?? sq ?? bare ?? "") + .split(";") + .map((decl) => { + const i = decl.indexOf(":"); + if (i < 0) return null; + const prop = decl.slice(0, i).trim().toLowerCase(); + const val = decl.slice(i + 1).trim(); + return (prop === "color" || prop === "background-color") && + SAFE_CSS_COLOR.test(val) + ? `${prop}: ${val}` + : null; + }) + .filter((d): d is string => d !== null); + return kept.length ? ` style="${kept.join("; ")}"` : ""; + }, + ); // Merge adjacent spans with same attributes let prev = ""; while (prev !== s) {