Skip to content

Sanitizer: validate srcset URLs and fail closed without DOMParser - #42840

Open
aljojoby9 wants to merge 6 commits into
twbs:mainfrom
aljojoby9:fix/sanitizer-srcset-and-fail-closed
Open

Sanitizer: validate srcset URLs and fail closed without DOMParser#42840
aljojoby9 wants to merge 6 commits into
twbs:mainfrom
aljojoby9:fix/sanitizer-srcset-and-fail-closed

Conversation

@aljojoby9

Copy link
Copy Markdown

Description

DefaultAllowlist permits srcset on img, but srcset was not treated as a URI attribute. The sanitizer checked src / href against SAFE_URL_PATTERN and left srcset untouched, so a javascript: candidate in srcset was kept.

Checking the raw srcset string as one URI is not enough. The value is a list of candidates, and data: URLs contain commas. This change parses each candidate (without splitting inside a data: payload) and rejects the attribute if any URL fails SAFE_URL_PATTERN.

If DOMParser is missing, clobbered, or throws, return an empty string. Do not return the original markup. That fail-open path is how older Bootstrap skipped sanitization after DOM clobbering.

Changes

  • Parse and validate each srcset candidate before allowing the attribute
  • Fail closed (empty string) when DOMParser cannot be used
  • Unit tests for safe srcset, mixed/unsafe srcset, data-URI commas, and parser failure

Related

img srcset is in the allowList but was not treated as a URI attribute,
so javascript: (and other unsafe) candidates were never checked. Parse
srcset per candidate so commas inside data: URLs are not treated as
separators.

If DOMParser is missing, clobbered, or throws, return an empty string
instead of the original markup. Returning the input would skip
sanitization.
@aljojoby9
aljojoby9 requested a review from a team as a code owner August 13, 2026 08:07
Copilot AI lite review requested due to automatic review settings August 13, 2026 08:07
Silence no-script-url on the srcset XSS fixtures and bump the gzip
caps to cover the srcset parser.
ESLint flags the assertion literal, not the template line, and treats
an unused disable-next-line as an error under --report-unused-disable-directives.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 363e0fbaea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread js/src/util/sanitizer.js Outdated
Comment on lines +81 to +85
const descriptor = rest.match(SRCSET_DESCRIPTOR)

if (descriptor) {
urls.push(rest.slice(0, descriptor.index).trim())
rest = rest.slice(descriptor.index + descriptor[0].length).replace(/^,/, '').trim()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Split data srcset candidates before trusting the scheme

When a data: candidate omits its descriptor, e.g. srcset="data:image/png;base64,AAAA , javascript:alert(1) 2x", this branch searches the entire remaining string for the next 1x/2x descriptor and treats everything before it as a single data: URL. The javascript: candidate is then never passed to isSafeUrl, so the sanitizer keeps a mixed srcset that the browser parses as separate candidates. Stop the data-URL scan at a real candidate comma, including whitespace-before-comma, or fail closed when the boundary is ambiguous.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 718eba2. A data: payload now ends at the first comma-plus-space after the header comma, so a later candidate is parsed on its own and rejected if it is unsafe.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the HTML sanitizer by ensuring img[srcset] is treated as a URI-bearing attribute and that sanitizer operation fails closed when DOMParser is unavailable or throws, preventing legacy DOM-clobbering fail-open behavior.

Changes:

  • Validate each srcset candidate URL against SAFE_URL_PATTERN rather than allowing raw srcset through.
  • Fail closed by returning an empty string when DOMParser is missing, clobbered, or throws during parsing.
  • Add unit tests covering safe/unsafe srcset and DOMParser failure scenarios.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
js/src/util/sanitizer.js Adds srcset to URI attributes, parses/validates srcset candidates, and changes sanitizer to return '' when DOMParser can’t be used.
js/tests/unit/util/sanitizer.spec.js Adds unit tests for srcset sanitization behavior and DOMParser failure handling.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread js/src/util/sanitizer.js
Comment on lines +80 to +92
if (/^data:/i.test(rest)) {
const descriptor = rest.match(SRCSET_DESCRIPTOR)

if (descriptor) {
urls.push(rest.slice(0, descriptor.index).trim())
rest = rest.slice(descriptor.index + descriptor[0].length).replace(/^,/, '').trim()
} else {
urls.push(rest)
rest = ''
}

continue
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 718eba2. extractSrcsetUrls no longer takes the first 1x/2x/w in the remainder as belonging to the current data: URL.

Comment on lines +191 to +200
it('should keep a data-URI srcset whose commas belong to the payload', () => {
const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/'
const template = `<img src="safe.jpg" srcset="${dataUrl} 1x">`

const result = sanitizeHtml(template, DefaultAllowlist, null)

expect(result).toContain('srcset=')
expect(result).toContain('data:image/png;base64,')
})

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a regression test for data:..., javascript:alert(1) 2x in 718eba2.

A descriptor on a later candidate must not extend a data: URL that has
no descriptor of its own. Split on a comma-plus-space after the data
payload so javascript: (and other) candidates are checked separately.
CI measured a few hundred extra gzipped bytes over the previous caps.
Raise the limits by a quarter to half kilobyte so a later comment or
test does not fail bundlewatch again.
A later javascript: URL after data:image/png;base64,AAAA,payload
was still swallowed when there was no space around the comma.
Treat a comma followed by a scheme, path, or file as a new candidate.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants