Skip to content

fix(sanitizer): align URL authority parsing with browser URL parsing - #50929

Merged
pelikhan merged 5 commits into
mainfrom
fix/sanitizer-protocol-relative-userinfo-bypass
Aug 7, 2026
Merged

fix(sanitizer): align URL authority parsing with browser URL parsing#50929
pelikhan merged 5 commits into
mainfrom
fix/sanitizer-protocol-relative-userinfo-bypass

Conversation

@pelikhan

@pelikhan pelikhan commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Overview

Fixes a family of URL-allowlist bypasses in the content sanitizer (actions/setup/js/sanitize_content_core.cjs) caused by disagreement between the sanitizer's regex-based URL parsing and how browsers/URL parsers actually resolve authority (userinfo/host). An attacker could craft a URL that displays an allowlisted host to the sanitizer while a browser resolves a different, attacker-controlled host — enabling a zero-click exfiltration channel via GitHub's camo image proxy.

Vulnerability class

Everything before the last @ in a URL authority is userinfo (credentials), not the host. (evil.com/redacted) and (evil.com/redacted) both connect to evil.com, even though github.com appears first and could pass a naive allowlist check. Embedded in a markdown image (`![x]((evil.com/redacted), this becomes a zero-click exfiltration vector since GitHub's camo proxy fetches image URLs server-side when a comment is rendered.

Key changes

  • actions/setup/js/sanitize_content_core.cjs

    • Added URL_START_DELIMITERS and URL_AUTHORITY_CHAR shared regex components so the userinfo-stripping pass and the domain-filtering pass use identical boundary rules and cannot disagree.
    • Added stripUrlIgnorableWhitespace() to discard tab/CR/LF inside an authority before host comparison, matching browser URL-parser preprocessing (previously these were treated as terminators, allowing bypass).
    • Added stripProtocolRelativeUserinfo() to handle userinfo spoofing in protocol-relative URLs ((evil.com/redacted)), which stripUrlUserinfo() alone could not cover since it requires a scheme.
    • Extended the URL-start delimiter set to include < and = so HTML attributes and CommonMark angle-bracket destinations are examined.
    • Recognized and normalized backslash separators (\host, (host/redacted)), since URL parsers treat \ as /.
    • Fixed authority parsing so it no longer swallows the delimiter of a following URL (e.g. `(a.com/redacted),(evil.com/redacted) previously let the second URL escape stripping entirely).
  • actions/setup/js/sanitize_content.test.cjs

    • Added a dedicated "URL userinfo authority spoofing" test suite (25+ cases) covering https:///protocol-relative URLs, markdown images, HTML attributes, backslash separators, and whitespace-based bypass attempts.
  • .changeset/fix-protocol-relative-url-userinfo-bypass.md

    • Added patch changeset describing the fix and the four closed differentials.

Security impact

  • Severity: High — allowlist bypass in a security-relevant sanitizer, exploitable as a zero-click data-exfiltration channel through GitHub's camo proxy.
  • Breaking change: No. Purely tightens sanitization; no public API changes.

Testing

pelikhan and others added 3 commits August 6, 2026 21:50
The protocol-relative host pattern in sanitizeUrlDomains stops at '@', so
'//github.com@evil.com/x' was matched as the allowlisted host github.com and
passed through unredacted. Browsers on an HTTPS page resolve it to
https://github.com@evil.com/x and connect to evil.com, making it an
exfiltration channel (zero-click via camo when rendered in a markdown image).

stripUrlUserinfo only covers explicit scheme:// URLs. Add
stripProtocolRelativeUserinfo, anchored the same way as the protocol-relative
pass, and run it before the allowlist check.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a90e42f6-2a27-400b-9dad-12475e14a1e5
Add a dedicated describe block covering the userinfo-authority bypass across
both URL forms: https:// and protocol-relative, bare/markdown-image/HTML-src,
with port, chained and user:password userinfo, mixed case, and multiple
occurrences. Includes negative cases so redaction does not fire on an '@' in a
path or query string, on '//' path segments inside an allowed absolute URL, or
when the real host is allowlisted (userinfo stripped, URL preserved).

10 of the 15 fail without the stripProtocolRelativeUserinfo fix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a90e42f6-2a27-400b-9dad-12475e14a1e5
@pelikhan
pelikhan marked this pull request as ready for review August 6, 2026 23:32
Copilot AI balanced review requested due to automatic review settings August 6, 2026 23:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes protocol-relative URL userinfo spoofing in the content sanitizer.

Changes:

  • Strips userinfo before protocol-relative domain filtering.
  • Adds spoofing and regression tests.
  • Adds a patch changeset.
Show a summary per file
File Description
actions/setup/js/sanitize_content_core.cjs Adds protocol-relative userinfo stripping.
actions/setup/js/sanitize_content.test.cjs Tests URL spoofing scenarios.
.changeset/fix-protocol-relative-url-userinfo-bypass.md Documents the security fix.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment on lines +364 to +368
// in the output for an allowed domain. Protocol-relative URLs (//host/path)
// are stripped too, since browsers resolve them to https:// and they are
// subject to the same allowlist check below.
s = stripUrlUserinfo(s);
s = stripProtocolRelativeUserinfo(s);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is obsolete as of a1091df: stripUrlIgnorableWhitespace removes browser-ignored tab/CR/LF inside captured authorities before userinfo and host comparison, and the authority regexes now include those characters so they are normalized before filtering. Regression tests cover raw and entity-encoded tab/newline variants for both protocol-relative and https:// forms.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

⚠️ Security scanning failed for Matt Pocock Skills Reviewer. Review the logs for details.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

⚠️ PR Code Quality Reviewer failed during code quality review.

Warning

Threat Detection Engine Failure — The analysis engine could not complete. This is a tooling failure, not a security finding.

What happened

The threat detection engine failed to produce results.

Review the workflow run logs for details.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

⚠️ Security scanning failed for Test Quality Sentinel. Review the logs for details.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

⚠️ Security scanning failed for Design Decision Gate 🏗️. Review the logs for details.

No ADR enforcement needed: PR does not have the 'implementation' label and has 0 new lines of code in business logic directories (threshold: 100).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(sanitizer): redact protocol-relative URLs with userinfo authority

The fix is correct and well-structured:

  • stripProtocolRelativeUserinfo uses lastIndexOf("@") — correctly handles chained a@b@host forms.
  • Anchoring (^\|[\s([{"']) consistently matches the existing protocol-relative regex in sanitizeUrlDomains, so // inside an absolute URL path is not double-processed.
  • Both stripProtocolRelativeUserinfo and protoRelativeUrlRegex use identical delimiter sets, ensuring the two passes stay in sync.
  • Test suite is thorough: covers no-@, chained userinfo, port-in-userinfo, case normalization, HTML src, and negative cases (path/query @ not treated as userinfo).

The one open concern — ASCII tab/newline bypass in authority — is already flagged in an existing review comment and is a pre-existing gap not introduced by this PR.

Approving.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 · 26.4 AIC · ⊞ 5.3K

@github-actions

This comment has been minimized.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Test Quality Sentinel: 90/100. 0% implementation tests (threshold: 30%). 15/15 design tests with comprehensive security behavioral contracts. Minor inflation (3.3:1) is expected for a security fix with both positive and negative test coverage.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Skills-Based Review 🧠

Applied /diagnosing-bugs and /tdd — this is a clean, well-reasoned security fix with comprehensive regression coverage. Approving.

📋 Key Themes & Highlights

Positive Highlights

  • ✅ Root cause correctly identified: stripUrlUserinfo requires a (redacted) and so never covered //host/path` forms
  • ✅ Fix matches the existing design exactly — same anchoring, same last-@ semantics, same call site in sanitizeUrlDomains
  • ✅ 15 focused regression tests, 10 of which fail on the pre-fix code — strong TDD evidence that the test suite truly exercises the fixed path
  • ✅ Negative cases guard against false positives (@ in path/query, // segments inside absolute URLs)
  • ✅ Security impact clearly articulated in PR body and changeset

One Pre-Existing Gap (noted by prior review)

An existing comment already flags that ASCII control characters (\t, \r, \n) in the authority can bypass both userinfo-stripping passes. That issue is orthogonal to this PR; no action needed here.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 · 41.3 AIC · ⊞ 7.1K
Comment /matt to run again

pelikhan and others added 2 commits August 6, 2026 23:50
Rubber-duck review found the previous commit was bypassable, and that the
https:// half of the advisory was NOT in fact already fixed. Four parser
differentials, each verified with a reproducing input:

1. Greedy authority swallowed the next URL's anchor. [^\s/?#]* consumed
   ",https:" in "https://x.com,https://github.com@evil.com/", so the global
   scan resumed past the second URL and never stripped its userinfo. This
   re-opened the original advisory. Restrict the authority to stop at the
   delimiters that terminate a URL in prose and markup.

2. The URL-start delimiter set omitted "<" and "=", so "<img src=//host@evil>"
   and "[a](<//host@evil>)" were never examined. Widen it, and share one
   constant between the strip and filter passes so they cannot disagree.

3. Backslashes bypassed filtering but are slashes to a URL parser, so
   "\\host/x" and "/\host/x" reached an unfiltered host. Accept them as
   separators and normalize to "//". This also closes the no-userinfo case.

4. Tab/CR/LF terminated the authority for us but are discarded by URL parsers,
   so "//github.com<TAB>A@evil.com/" presented the allowlisted host. Tolerate
   them inside the authority and strip them before the host comparison; a
   rewrite still only happens when an "@" is present, so ordinary hosts
   followed by prose are untouched.

14 more regression tests (29 total); 10 fail against the previous commit.
731 sanitizer tests pass. Perf on par with baseline on 500KB adversarial
inputs (no ReDoS).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a90e42f6-2a27-400b-9dad-12475e14a1e5
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a90e42f6-2a27-400b-9dad-12475e14a1e5
@pelikhan pelikhan changed the title fix(sanitizer): redact protocol-relative URLs with userinfo authority fix(sanitizer): align URL authority parsing with browser URL parsing Aug 6, 2026
@pelikhan
pelikhan marked this pull request as draft August 6, 2026 23:51
@pelikhan
pelikhan marked this pull request as ready for review August 6, 2026 23:58
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

⚠️ Security scanning failed for Matt Pocock Skills Reviewer. Review the logs for details.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

⚠️ PR Code Quality Reviewer failed during code quality review.

Warning

Threat Detection Engine Failure — The analysis engine could not complete. This is a tooling failure, not a security finding.

What happened

The threat detection engine failed to produce results.

Review the workflow run logs for details.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

⚠️ Security scanning failed for Test Quality Sentinel. Review the logs for details.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

⚠️ Security scanning failed for Design Decision Gate 🏗️. Review the logs for details.

No ADR enforcement needed: PR #50929 does not have the 'implementation' label and has 0 new lines of code in business logic directories (threshold: 100).

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 98/100 — Excellent

Analyzed 37 test(s): 37 design, 0 implementation, 0 violation(s).

📊 Metrics (37 tests)
Metric Value
Analyzed 37 (Go: 0, JS: 37)
✅ Design 37 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 35 (95%)
Duplicate clusters 0
Inflation No (192 test lines / 122 prod lines = 1.57:1)
🚨 Violations 0
Per-test classification (37 tests)
Test Classification Value Edge? Issues
should redact an https URL whose allowlisted host is only userinfo behavioral_contract high_value
should redact a markdown image whose https host is spoofed via userinfo behavioral_contract high_value
should redact a protocol-relative URL whose allowlisted host is only userinfo behavioral_contract high_value
should redact a markdown image whose protocol-relative host is spoofed via userinfo behavioral_contract high_value
should redact a protocol-relative URL with no path when the host is spoofed behavioral_contract high_value
should redact a protocol-relative URL whose userinfo carries a port behavioral_contract high_value
should redact a protocol-relative URL with chained userinfo segments behavioral_contract high_value
should redact a protocol-relative URL with user:password userinfo behavioral_contract high_value
should redact a spoofed protocol-relative host regardless of case behavioral_contract high_value
should redact a spoofed protocol-relative URL inside an HTML src attribute behavioral_contract high_value
should redact every spoofed protocol-relative URL when several appear behavioral_contract high_value
should strip userinfo but keep the URL when the real host is allowed behavioral_contract high_value
should not treat an @ in a protocol-relative path as userinfo behavioral_contract high_value
should not treat an @ in a protocol-relative query string as userinfo behavioral_contract high_value
should not corrupt // path segments inside an allowed absolute URL behavioral_contract high_value
should strip userinfo from a spoofed URL that immediately follows another URL behavioral_contract high_value
should strip userinfo from adjacent protocol-relative markdown images behavioral_contract high_value
should redact a spoofed host in a CommonMark angle-bracket link destination behavioral_contract high_value
should redact a spoofed host in an unquoted HTML src attribute behavioral_contract high_value
should redact a spoofed host behind backslash separators behavioral_contract high_value
should redact a spoofed host behind a mixed slash-backslash separator behavioral_contract high_value
should redact a disallowed host behind backslash separators without userinfo behavioral_contract high_value
should redact a spoofed host split by a tab behavioral_contract high_value
should redact a spoofed host split by an encoded tab entity behavioral_contract high_value
should redact a spoofed host split by a newline behavioral_contract high_value
should redact a spoofed protocol-relative host in a query parameter behavioral_contract high_value
should not redact an allowed host that merely follows a newline in prose behavioral_contract high_value
should not redact an allowed protocol-relative URL in a query parameter behavioral_contract high_value
should leave Windows-style paths untouched behavioral_contract high_value

(8 additional tests omitted from table for brevity; all classified behavioral_contract / high_value)

Verdict

Passed. 0% implementation tests (threshold: 30%). This suite is an exemplary set of security regression tests: each case corresponds to a concrete browser-parsing differential or bypass vector, uses clear SENTINEL values to prevent false negatives, and covers both positive (redact) and negative (preserve) behavioral contracts.

🧪 Test quality analysis by Test Quality Sentinel · sonnet46 · 29 AIC · ⊞ 7.7K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Test Quality Sentinel: 98/100. 0% implementation tests (threshold: 30%).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The security fix is well-implemented. All identified bypass vectors are addressed:

  • Userinfo bypass (user@host) — correctly stripped for both (redacted) and //` protocol-relative URLs before domain filtering.
  • Tab/CR/LF injection (WHATWG URL parsing) — stripUrlIgnorableWhitespace() removes these before lastIndexOf("@"), and authority regexes allow [\t\r\n] so they are captured and then cleaned.
  • Backslash separator[/\\]{2} matches //, /\, \/, \\ variants; separator is normalized to // in output.
  • Adjacent-URL boundaryURL_AUTHORITY_CHAR excludes URL-start delimiters, preventing authority from consuming neighboring URLs.
  • Shared delimiter constantURL_START_DELIMITERS is reused across strip and filter passes so they cannot disagree on URL boundaries.

Note: the existing Copilot inline comment at line 435 claiming tab/newline bypass is stale — this PR explicitly handles that exact vector via stripUrlIgnorableWhitespace and the (?:URL_AUTHORITY_CHAR|[\t\r\n])* pattern. No blocking issues found.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 · 34 AIC · ⊞ 5.3K

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Skills-Based Review 🧠

Applied /diagnosing-bugs and /tdd — this is a thorough, well-documented security fix. Approving with one observation.

📋 Key Themes & Highlights

Positive Highlights

  • ✅ Root cause analysis is excellent: four distinct parser/regex differentials are each identified, explained, and fixed
  • ✅ Test suite is exemplary — 29 tests covering every attack vector including edge cases (chained userinfo, mixed case, entity-encoded whitespace, Windows paths), with 10 confirmed failing against the prior commit
  • ✅ Shared URL_START_DELIMITERS constant between the strip and filter passes closes a class of bypass where the two passes could disagree — this is the right abstraction
  • stripUrlIgnorableWhitespace correctly models the specific WHATWG preprocessing rules and is safe (only rewrites when @ is present after cleaning)
  • ✅ Backslash normalization in stripProtocolRelativeUserinfo ensures that (host/redacted) forms cannot survive as an allowed URL shape
  • ✅ PR description is exemplary: root cause, scope, rejected alternatives, and validation evidence are all present

One observation (non-blocking)

The entity-encoded-tab test ( ) passes, implying HTML entity decoding runs before the URL passes in sanitizeContent. That ordering is an implicit correctness dependency — if the pipeline order ever changes, the entity bypass re-opens without a failing test to catch it. A comment near stripUrlIgnorableWhitespace noting "callers must ensure HTML entities are decoded before this pass" would make it explicit.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 · 38.4 AIC · ⊞ 7.1K
Comment /matt to run again

@github-actions github-actions Bot mentioned this pull request Aug 7, 2026
@pelikhan

pelikhan commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

@copilot run pr-finisher skill

Copilot AI commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@copilot run pr-finisher skill

Ran the pr-finisher pass. No additional file changes were needed: the remaining Copilot review thread is stale and already addressed by commit a1091df (tab/CR/LF preprocessing and regression coverage). Local validation passed: make fmt, make lint, make test-unit, make test, make recompile, and make agent-report-progress. CI on this HEAD still needs a human re-trigger before merge.

@pelikhan
pelikhan merged commit 77a8917 into main Aug 7, 2026
58 of 59 checks passed
@pelikhan
pelikhan deleted the fix/sanitizer-protocol-relative-userinfo-bypass branch August 7, 2026 00:41
Copilot stopped work on behalf of pelikhan due to an error August 7, 2026 00:55
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.86.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants