diff --git a/lib/rich-text.test.ts b/lib/rich-text.test.ts index 2be939d..6a0a2a1 100644 --- a/lib/rich-text.test.ts +++ b/lib/rich-text.test.ts @@ -15,6 +15,14 @@ describe("rich text sanitization", () => { ); }); + it("normalizes browser-generated RGB text colors to safe hex colors", () => { + expect(sanitizeRichTextHtml( + '
green yellow
', + )).toBe( + 'green yellow
', + ); + }); + it("removes executable markup, unsafe links, and unsupported styles", () => { expect(sanitizeRichTextHtml( 'Safe textlink
', diff --git a/lib/rich-text.ts b/lib/rich-text.ts index ec7978b..7281851 100644 --- a/lib/rich-text.ts +++ b/lib/rich-text.ts @@ -22,6 +22,18 @@ const HTML_COMMENT = //g; const TAG = /<\/?([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/g; const ATTRIBUTE = /([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/g; const HEX_COLOR = /^#[0-9a-fA-F]{6}$/; +const RGB_COLOR = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i; + +function normalizeRichTextColor(value: string): string | null { + const color = value.trim(); + if (HEX_COLOR.test(color)) return color.toUpperCase(); + + const rgb = color.match(RGB_COLOR); + if (!rgb) return null; + const channels = rgb.slice(1).map(Number); + if (channels.some((channel) => channel > 255)) return null; + return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`.toUpperCase(); +} export function sanitizeRichTextUrl(value: string): string { const url = value.trim(); @@ -75,9 +87,11 @@ export function sanitizeRichTextHtml(value: string): string { if (name === "span") { const style = attributeValue(rawAttributes, "style"); - const color = style.match(/(?:^|;)\s*color\s*:\s*(#[0-9a-fA-F]{6})\s*(?:;|$)/)?.[1]; - return color && HEX_COLOR.test(color) - ? `` + const color = normalizeRichTextColor( + style.match(/(?:^|;)\s*color\s*:\s*([^;]+?)\s*(?:;|$)/i)?.[1] ?? "", + ); + return color + ? `` : ""; }