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:
@@ -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 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) {
|
||||
|
||||
Reference in New Issue
Block a user