diff --git a/craft/src/utils/style-helpers.test.ts b/craft/src/utils/style-helpers.test.ts index 6b10569..d79a2f6 100644 --- a/craft/src/utils/style-helpers.test.ts +++ b/craft/src/utils/style-helpers.test.ts @@ -35,4 +35,22 @@ describe('cssPropsToString sanitizes emitted values (A5)', () => { expect(out).toContain('https://example.com/img.jpg'); expect(out).not.toContain('javascript:'); }); + + test('data-URI background image survives with its semicolon separator intact (A3)', () => { + const out = cssPropsToString({ + backgroundImage: 'url(data:image/png;base64,iVBORw0KGgo=)', + } as any); + // The `;` between the MIME type and `base64` is a required part of the + // data-URI syntax -- it must not be stripped by breakout sanitization. + expect(out).toContain('data:image/png;base64,iVBORw0KGgo='); + }); + + test('quote/semicolon breakout is still neutralized alongside a url()', () => { + const out = cssPropsToString({ + color: 'red";background:url(x)"', + } as any); + expect(out).not.toContain('"'); + const withoutEntities = out.replace(/&(?:quot|amp|#39|#96|#x[0-9a-f]+|#[0-9]+);/gi, ''); + expect(withoutEntities).not.toContain(';'); + }); }); diff --git a/craft/src/utils/style-helpers.ts b/craft/src/utils/style-helpers.ts index d419fb2..9a84418 100644 --- a/craft/src/utils/style-helpers.ts +++ b/craft/src/utils/style-helpers.ts @@ -6,24 +6,41 @@ const camelToKebab = (str: string): string => const URL_RE = /url\(\s*(['"]?)([\s\S]*?)\1\s*\)/gi; +// Outside of a url(...) reference, a `;` is never legitimate (declarations +// are separated by it) -- stray semicolons are how a breakout injects a +// second property -- and a raw `"` would close the `style="..."` attribute +// early. Inside url('...') the content has already been made safe via +// escapeAttr(safeUrl(...)), including any `;` required by data-URI syntax +// (`data:;base64,`), so this must never be applied there. +const sanitizeBreakoutChars = (s: string): string => s.replace(/;/g, '').replace(/"/g, '"'); + /** * Sanitizes a single CSS declaration value so it can never terminate the * `style="..."` attribute early, inject an extra declaration via a stray * `;`, or smuggle a `javascript:`/`vbscript:`/`data:text/html` URL through * a `url(...)` reference. Legitimate multi-part values (box-shadow, * gradients, etc.) that contain none of these characters pass through - * unchanged. + * unchanged, and legitimate `;`-containing data-URIs inside url(...) are + * preserved intact. */ function sanitizeCssValue(raw: string): string { - // Neutralize url(...) references: validate/strip the scheme and re-wrap - // in single quotes with the contents escaped for attribute safety. - let val = raw.replace(URL_RE, (_m, _q, inner) => `url('${escapeAttr(safeUrl(inner.trim()))}')`); - // A `;` in a CSS value is never legitimate (declarations are separated by - // it) -- stray semicolons are how a breakout injects a second property. - val = val.replace(/;/g, ''); - // Any remaining raw `"` would close the `style="..."` attribute early. - val = val.replace(/"/g, '"'); - return val; + let out = ''; + let lastIndex = 0; + URL_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = URL_RE.exec(raw)) !== null) { + // Sanitize breakout characters only in the segment before this url(...) + // reference -- never inside the reference itself. + out += sanitizeBreakoutChars(raw.slice(lastIndex, m.index)); + // Neutralize the url(...) reference: validate/strip the scheme and + // re-wrap in single quotes with the contents escaped for attribute + // safety. This is already fully safe, `;` and all. + const inner = m[2]; + out += `url('${escapeAttr(safeUrl(inner.trim()))}')`; + lastIndex = URL_RE.lastIndex; + } + out += sanitizeBreakoutChars(raw.slice(lastIndex)); + return out; } export function cssPropsToString(style: CSSProperties | undefined): string {