fix(pdf): Segoe UI everywhere + rich-text colors survive sanitization

- 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 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-12 08:27:47 +02:00
parent ea3b595b32
commit eb279a87a9
6 changed files with 84 additions and 12 deletions

View File

@@ -94,7 +94,7 @@ export function buildPdfHeaderTemplate(
logoDataUri: string,
heading: string,
): string {
return `<div style="width:100%; font-family:Tahoma, sans-serif; padding:0 12mm; box-sizing:border-box;">
return `<div style="width:100%; font-family:'Segoe UI', Tahoma, Arial, sans-serif; padding:0 12mm; box-sizing:border-box;">
<div style="display:flex; justify-content:space-between; align-items:center; padding-bottom:1mm; border-bottom:2pt solid #de3a3a;">
<div style="flex:0 0 auto;">${logoDataUri ? `<img src="${logoDataUri}" style="max-width:42mm; max-height:22mm; object-fit:contain;" />` : ""}</div>
<div style="font-size:13pt; font-weight:700; color:#de3a3a; text-align:right; letter-spacing:0.03em;">${escapeHtml(heading)}</div>
@@ -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 &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/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) {