fix(security): make the CSS sanitizer's remote-URL block actually block
sanitizeCSS "blocked" a remote url() by prefixing it with a /* BLOCKED URL */ COMMENT and leaving the URL in place. CSS comments are discarded during tokenization, so the declaration a browser parsed still carried the live URL — while adminCssTemplates returned sanitization_warnings claiming it had been stopped. Protection that reports success is worse than none, which is why it survived review. Scope is narrow: sanitizeCss (lowercase, the public-site path) never included the pattern and permits remote URLs by design — a test now pins that. Only sanitizeCSS (uppercase) was affected; outside this repo's newsletter branch its sole caller is adminCssTemplates.js. Migration 200 is required, not cosmetic: gallery.js serves css_templates.css_content VERBATIM as text/css and does not re-sanitize on read, so fixing the write path alone would leave every existing template serving its URL forever. Review follow-ups replaced the regex with a small three-state lexer (comment / string / identifier) over the RAW text, after five further bypasses: a ")" inside a quoted url(), CSS escapes (u\72l), the HTML comment strip JOINING tokens into a live url() after the scan, an escaped quote desynchronising the scan, and a quote inside a comment. Escapes are decoded only to decide, never to rewrite — a clean input now round-trips byte-identical, which also keeps unaffected rows out of the migration's write path. Severity is low (writing a template needs branding.edit) but the harm is a gallery visitor's IP reaching a third party from a page the operator believes carries no remote requests.
This commit is contained in:
@@ -29,8 +29,38 @@ const FORBIDDEN_PATTERNS = [
|
||||
/on\w+\s*=/gi, // onclick=, onload=, etc.
|
||||
];
|
||||
|
||||
// Pattern for external URLs (block external, allow only safe raster data: images)
|
||||
const EXTERNAL_URL_PATTERN = /url\s*\(\s*["']?(?!data:image\/(?:jpeg|jpg|png|gif|webp))/gi;
|
||||
/**
|
||||
* Decode CSS escape sequences.
|
||||
*
|
||||
* `\72` is a legal way to write `r`, so `u\72l(https://evil.example/x.gif)`
|
||||
* IS a url() to a browser while matching no literal pattern for "url(".
|
||||
* Decoding first means the scanner below sees what the browser will see. The
|
||||
* decoded form is what gets stored, which is safe: the same stylesheet,
|
||||
* spelled unambiguously.
|
||||
*
|
||||
* Per CSS syntax: a backslash plus 1-6 hex digits and one optional trailing
|
||||
* whitespace, or a backslash plus any other single character.
|
||||
*/
|
||||
function decodeCssEscapes(css) {
|
||||
return String(css).replace(
|
||||
/\\([0-9a-fA-F]{1,6})[ \t\n\f]?|\\([^0-9a-fA-F])/g,
|
||||
(match, hex, literal) => {
|
||||
if (hex) {
|
||||
const code = parseInt(hex, 16);
|
||||
// Null, out-of-range and surrogate escapes are invalid; leave them
|
||||
// exactly as written rather than throwing.
|
||||
if (!Number.isFinite(code) || code === 0 || code > 0x10FFFF
|
||||
|| (code >= 0xD800 && code <= 0xDFFF)) return match;
|
||||
return String.fromCodePoint(code);
|
||||
}
|
||||
return literal;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// The only target a url() may name: an inline raster data: image. Anything
|
||||
// else is a request to a third party from someone else's browser.
|
||||
const ALLOWED_URL_TARGET = /^data:image\/(?:jpeg|jpg|png|gif|webp)/i;
|
||||
|
||||
// Maximum CSS size in bytes (100KB)
|
||||
const MAX_CSS_SIZE = 100 * 1024;
|
||||
@@ -69,6 +99,163 @@ function sanitizeCss(css) {
|
||||
return sanitized.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every `url(...)` that does not name an inline data: image with the
|
||||
* inert keyword `none`.
|
||||
*
|
||||
* This REPLACES an earlier implementation that prefixed the offending token
|
||||
* with a `/* BLOCKED URL *\/` comment and left the URL in place. CSS comments
|
||||
* are discarded during tokenization, so the declaration a browser actually
|
||||
* parsed still carried the live URL — the "block" was inert, while the
|
||||
* warning returned to the caller said it had worked:
|
||||
*
|
||||
* before: '.a{background:url(https://x/p.gif)}'
|
||||
* → '.a{background:/* BLOCKED URL *\/ url(https://x/p.gif)}'
|
||||
* → parsed as '.a{background: url(https://x/p.gif)}'
|
||||
*
|
||||
* `none` is used rather than deleting the declaration because it is valid in
|
||||
* the shorthand positions these appear in (`background: #fff none no-repeat`)
|
||||
* and leaves the rest of the rule intact.
|
||||
*
|
||||
* @param {string} css
|
||||
* @returns {{ sanitized: string, blocked: number }}
|
||||
*/
|
||||
function stripDisallowedUrls(css) {
|
||||
const input = css == null ? '' : String(css);
|
||||
let out = '';
|
||||
let blocked = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < input.length) {
|
||||
// --- CSS comment ---------------------------------------------------
|
||||
// Its own lexical state. A comment containing an unmatched apostrophe
|
||||
// (`/* don't */`) otherwise put the scanner into string mode and let it
|
||||
// copy the rest of the stylesheet — including a live url() — unscanned,
|
||||
// while a browser ignores the comment entirely and makes the request.
|
||||
if (input[i] === '/' && input[i + 1] === '*') {
|
||||
const close = input.indexOf('*/', i + 2);
|
||||
const stop = close === -1 ? input.length : close + 2;
|
||||
out += input.slice(i, stop);
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- string ----------------------------------------------------------
|
||||
// Escape-aware: `\"` inside a double-quoted string does NOT close it.
|
||||
// Decoding escapes up front (an earlier attempt) turned that into a real
|
||||
// quote, desynchronised the scanner, and hid the url() that followed.
|
||||
if (input[i] === '"' || input[i] === '\'') {
|
||||
const quote = input[i];
|
||||
let j = i + 1;
|
||||
while (j < input.length) {
|
||||
if (input[j] === '\\') { j += 2; continue; }
|
||||
if (input[j] === quote) { j += 1; break; }
|
||||
j += 1;
|
||||
}
|
||||
out += input.slice(i, Math.min(j, input.length));
|
||||
i = Math.min(j, input.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- url( token --------------------------------------------------------
|
||||
const ident = readIdentifier(input, i);
|
||||
if (ident.end > i && decodeCssEscapes(ident.raw).toLowerCase() === 'url') {
|
||||
let j = ident.end;
|
||||
while (j < input.length && /\s/.test(input[j])) j += 1;
|
||||
if (input[j] === '(') {
|
||||
const token = readUrlToken(input, j);
|
||||
if (token) {
|
||||
// The target is decoded only to DECIDE; the original bytes are what
|
||||
// gets emitted when it is allowed, so nothing else in the
|
||||
// stylesheet is rewritten.
|
||||
const target = decodeCssEscapes(token.target).trim();
|
||||
if (ALLOWED_URL_TARGET.test(target)) {
|
||||
out += input.slice(i, token.end);
|
||||
} else {
|
||||
blocked += 1;
|
||||
out += 'none';
|
||||
}
|
||||
i = token.end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Not actually a url() call — emit the identifier and carry on.
|
||||
out += input.slice(i, ident.end);
|
||||
i = ident.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
out += input[i];
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return { sanitized: out, blocked };
|
||||
}
|
||||
|
||||
/** A CSS escape sequence at `start`, or null. */
|
||||
function matchEscape(input, start) {
|
||||
if (input[start] !== '\\') return null;
|
||||
const rest = input.slice(start, start + 8);
|
||||
const m = /^\\(?:[0-9a-fA-F]{1,6}[ \t\n\f]?|[^0-9a-fA-F])/.exec(rest);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a CSS identifier, escapes included, WITHOUT decoding it.
|
||||
*
|
||||
* `u\72l` is a legal spelling of `url`, so the identifier has to be decoded
|
||||
* to be recognised — but only for the comparison. Returning the raw text
|
||||
* means an identifier that is not a url() (`.w-1\/2`, a perfectly ordinary
|
||||
* escaped Tailwind selector) is emitted byte-identical rather than silently
|
||||
* rewritten to `.w-1/2`, which is a different selector.
|
||||
*/
|
||||
function readIdentifier(input, start) {
|
||||
let j = start;
|
||||
let raw = '';
|
||||
while (j < input.length) {
|
||||
const escape = matchEscape(input, j);
|
||||
if (escape) { raw += escape; j += escape.length; continue; }
|
||||
if (/[A-Za-z0-9_-]/.test(input[j])) { raw += input[j]; j += 1; continue; }
|
||||
break;
|
||||
}
|
||||
return { raw, end: j };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a `url( … )` token starting at the opening paren.
|
||||
* @returns {{ target: string, end: number }|null} null when unterminated.
|
||||
*/
|
||||
function readUrlToken(input, openParen) {
|
||||
let j = openParen + 1;
|
||||
let target = '';
|
||||
while (j < input.length && /\s/.test(input[j])) j += 1;
|
||||
|
||||
if (input[j] === '"' || input[j] === '\'') {
|
||||
// Quoted: the quote closes the value, so ")" inside it is content.
|
||||
const quote = input[j];
|
||||
j += 1;
|
||||
while (j < input.length && input[j] !== quote) {
|
||||
if (input[j] === '\\') { target += input.slice(j, j + 2); j += 2; continue; }
|
||||
target += input[j];
|
||||
j += 1;
|
||||
}
|
||||
if (j >= input.length) return null;
|
||||
j += 1;
|
||||
} else {
|
||||
while (j < input.length && input[j] !== ')') {
|
||||
if (input[j] === '\\') { target += input.slice(j, j + 2); j += 2; continue; }
|
||||
target += input[j];
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
while (j < input.length && /\s/.test(input[j])) j += 1;
|
||||
// Unterminated url( — malformed. Leave it alone rather than swallowing the
|
||||
// remainder of the stylesheet.
|
||||
if (input[j] !== ')') return null;
|
||||
return { target, end: j + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced CSS sanitization with warnings
|
||||
* @param {string} cssContent - Raw CSS content
|
||||
@@ -101,17 +288,24 @@ function sanitizeCSS(cssContent) {
|
||||
}
|
||||
}
|
||||
|
||||
// Block external URLs (only allow data: URIs for images)
|
||||
EXTERNAL_URL_PATTERN.lastIndex = 0;
|
||||
if (EXTERNAL_URL_PATTERN.test(sanitized)) {
|
||||
warnings.push('Blocked external URL references. Only data: URIs are allowed for images.');
|
||||
EXTERNAL_URL_PATTERN.lastIndex = 0;
|
||||
sanitized = sanitized.replace(EXTERNAL_URL_PATTERN, '/* BLOCKED URL */ url(');
|
||||
}
|
||||
|
||||
// Remove HTML comments that might be used for injection
|
||||
sanitized = sanitized.replace(/<!--[\s\S]*?-->/g, '');
|
||||
|
||||
// URLs are scanned AFTER the comment strip, not before. Removing
|
||||
// `<!--x-->` from `u<!--x-->rl(https://evil.example/p.gif)` JOINS the
|
||||
// remaining characters into a live `url(...)` — so a scan that ran first
|
||||
// saw no token, reported the input clean, and the transformation below it
|
||||
// then produced exactly the request the scan was there to prevent. Any
|
||||
// pass that can join tokens has to happen before validation, not after.
|
||||
const urlPass = stripDisallowedUrls(sanitized);
|
||||
if (urlPass.blocked > 0) {
|
||||
warnings.push(
|
||||
`Blocked ${urlPass.blocked} external URL reference${urlPass.blocked === 1 ? '' : 's'}. `
|
||||
+ 'Only data: URIs are allowed for images.'
|
||||
);
|
||||
sanitized = urlPass.sanitized;
|
||||
}
|
||||
|
||||
// Remove control characters
|
||||
// eslint-disable-next-line no-control-regex -- intentional: strips control chars from untrusted CSS
|
||||
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
||||
@@ -168,6 +362,7 @@ function scopeToGalleryPage(cssContent) {
|
||||
module.exports = {
|
||||
sanitizeCss,
|
||||
sanitizeCSS,
|
||||
stripDisallowedUrls,
|
||||
validateCSS,
|
||||
scopeToGalleryPage,
|
||||
MAX_CSS_SIZE
|
||||
|
||||
Reference in New Issue
Block a user