fix(security): close two CSS url() bypasses the sanitizer dedup exposed
Both found by review against the correct base, and both are cases the
second stripRemoteCssUrls pass had been catching before this PR removed
it. Verified against the real functions before and after.
An escaped quote outside a string. `\'` is an escaped identifier
character, not a string opener, but the scanner stepped onto the
apostrophe, entered string mode and copied the rest of the stylesheet
unexamined — so `.hero{--marker:\';background:url(https://evil/p.gif)}`
kept a live remote URL. Escapes are now consumed as a unit outside
strings.
An unterminated quote. Trusting one meant a single stray apostrophe
disabled scanning for everything after it. An unclosed quote is a parse
error, so the safe reading is to emit it as an ordinary character and
keep scanning; a newline also ends a string, as it does in CSS.
The entity mismatch behind the second case. sanitize-html writes `"`
inside an attribute as `"`, so the scanner and the recipient's
browser disagreed about where strings begin: in
`style="font-family:"don't";background:url(...)"` the browser
decodes first, reads the apostrophe as ordinary text inside a real
string, and fetches the background — a tracking pixel by another name.
Style attributes are now decoded before scanning and re-encoded after,
which also stops the old code silently deleting quotes from the value.
Also detaches the image handlers before releasing the canvas source.
That one did NOT reproduce: measured in both Chromium and WebKit,
neither fires `error` when the attribute is removed after a successful
load. Applied anyway because the ordering is free and the failure it
would cause is silent — canvasFailed set, the canvas swapped for an
<img>, and the image decoded a second time, the exact opposite of what
the release is for.
Refs #1264, #1287
This commit is contained in:
@@ -140,6 +140,18 @@ function stripDisallowedUrls(css) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- escape OUTSIDE a string -----------------------------------------
|
||||
// `\'` is an escaped identifier character, not the start of a string.
|
||||
// Without this the scanner stepped onto the apostrophe, entered string
|
||||
// mode, and copied the rest of the stylesheet unscanned — so
|
||||
// `.hero{--marker:\';background:url(https://evil.example/p.gif)}` kept a
|
||||
// live remote URL. Consume the escape and its escaped character together.
|
||||
if (input[i] === '\\' && i + 1 < input.length) {
|
||||
out += input.slice(i, i + 2);
|
||||
i += 2;
|
||||
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
|
||||
@@ -147,11 +159,27 @@ function stripDisallowedUrls(css) {
|
||||
if (input[i] === '"' || input[i] === '\'') {
|
||||
const quote = input[i];
|
||||
let j = i + 1;
|
||||
let closed = false;
|
||||
while (j < input.length) {
|
||||
if (input[j] === '\\') { j += 2; continue; }
|
||||
if (input[j] === quote) { j += 1; break; }
|
||||
// A newline ends a string in CSS (it produces a bad-string token), so
|
||||
// an unclosed quote must not run past the end of its own line.
|
||||
if (input[j] === '\n' || input[j] === '\r' || input[j] === '\f') break;
|
||||
if (input[j] === quote) { j += 1; closed = true; break; }
|
||||
j += 1;
|
||||
}
|
||||
// An UNTERMINATED quote is a parse error, and trusting it is how a
|
||||
// stray apostrophe hid everything after it: `font-family:"don't`
|
||||
// opened a string that swallowed the url() following it, while the
|
||||
// recipient's browser — which decodes the entity first — saw the
|
||||
// apostrophe safely inside a real string and made the request. Failing
|
||||
// closed here means emitting the quote as an ordinary character and
|
||||
// carrying on scanning, so a later url() is still examined.
|
||||
if (!closed) {
|
||||
out += input[i];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
out += input.slice(i, Math.min(j, input.length));
|
||||
i = Math.min(j, input.length);
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user