Skip to content

fix(security): block control-char-obfuscated javascript: URLs - #12

Merged
nathanialhenniges merged 5 commits into
mainfrom
claude/fix-safeurl-control-chars
Jul 29, 2026
Merged

nathanialhenniges merged 5 commits into
mainfrom
claude/fix-safeurl-control-chars

Conversation

@nathanialhenniges

@nathanialhenniges nathanialhenniges commented Jul 26, 2026

Copy link
Copy Markdown
Member

Summary

A post-migration audit of the Astro rewrite found that safeUrl() regressed from a strict positive allowlist into a permissive fall-through, reopening a same-origin XSS. Also restores a retry that was lost in the bash→Node port of the checksum verifier.

The vulnerability

safeUrl() tested the scheme with /^[a-z][a-z0-9+.-]*:/ and fell through to return url when that test failed:

if (/^[a-z][a-z0-9+.\-]*:/i.test(url)) {
  return /^(https?:|mailto:)/i.test(url) ? url : "#";
}
return url;   // <-- anything with a malformed scheme lands here

A scheme containing an ASCII tab, LF or CR fails that character class, so java<TAB>script:alert(1) was returned unchanged and rendered straight into an href. The WHATWG URL parser strips ASCII tab/LF/CR before resolving the scheme, so the browser resolves it back to javascript: and executes it on click.

Reproduced end-to-end: a fixture tap with homepage "java<TAB>script:alert(document.domain)" emitted

<a id="detail-homepage" href="java	script:alert(document.domain)" ...>

Reachable from any .rb homepage value and from a GitHub release html_url, through both server-rendered hrefs (DetailPage.astro, VersionHistory.astro) and the client-rendered search results (site.js).

The pre-Astro bash safe_url was a strict positive prefix allowlist and blocked this. The migration unified both copies onto the permissive client version — which is load-bearing for internal relative hrefs — costing the external-URL path its strictness.

Fix

Strip C0 controls and DEL before the scheme test, in both the build-time (src/lib/serialize.mjs) and bundled-client (src/scripts/site.js) copies. Filtered by code point so both sources stay pure ASCII, matching the existing convention in serialize.mjs.

Relative, anchor, https:, http: and mailto: URLs are unaffected.

Also included

scripts/verify-checksums.mjs lost curl's --retry 3 --retry-delay 3 in the Node port, so one transient CDN error hard-failed the brew job and blocked the Pages deploy. Restored as a bounded, env-overridable retry loop matching src/lib/releases.mjs. The 180s abort is unchanged.

Tests

  • New tests/safe-url.test.js (37 assertions) runs a blocked/allowed matrix against both copies of safeUrl: tab/LF/CR/NUL/DEL obfuscation, data:, vbscript:, file:, protocol-relative, plus the legitimate URLs that must pass through.
  • tests/xss.test.js now uses the tab-obfuscated payload end-to-end; it previously only asserted the plain javascript: case, which is why the regression went unnoticed.
  • Full suite: 116 passing. node scripts/verify-checksums.mjs verifies both real release artifacts.

Audit context

This came out of a 5-dimension audit of what the Astro migration preserved from the earlier hardening PR: 13 candidate regressions were flagged, 11 refuted on adversarial verification, and these 2 confirmed. 32 guarantees (XSS containment, U+2028 handling, release-fetch timeouts/retries/pagination/OFFLINE/STRICT, JS-off server-rendering, search-dialog a11y, CI gating and SHA pins) were confirmed intact.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Hardened URL sanitization by stripping control characters before scheme validation.
    • Ensured control-character–obfuscated unsafe URLs are neutralized while allowed schemes remain unchanged.
    • Improved checksum verification reliability with bounded retries and per-attempt timeouts.
  • Tests
    • Added URL sanitization test coverage, including build/client parity checks.
    • Updated XSS coverage to confirm obfuscated javascript: inputs collapse to #.
  • Documentation
    • Updated contributor/testing instructions for the Astro-based documentation site and verification scripts.
  • Chores
    • Authenticated CI Homebrew validation requests to reduce intermittent API-rate failures.

nathanialhenniges and others added 2 commits July 26, 2026 06:59
safeUrl() tested the scheme with /^[a-z][a-z0-9+.-]*:/ and fell through to
`return url` when that failed. A scheme containing an ASCII tab, LF or CR
fails that test, so "java<TAB>script:alert(1)" was returned unchanged and
rendered into an href. The WHATWG URL parser strips ASCII tab/LF/CR before
resolving the scheme, so the browser resolves it back to javascript: and
executes it on click — same-origin script execution on the Pages site.

Reachable from any .rb `homepage` value and from a GitHub release html_url,
via both server-rendered hrefs (DetailPage, VersionHistory) and the
client-rendered search results.

Strip C0 controls and DEL before the scheme test, in both the build-time
(src/lib/serialize.mjs) and bundled-client (src/scripts/site.js) copies.
Filtered by code point so both sources stay pure ASCII.

Tests: new tests/safe-url.test.js covers the blocked/allowed matrix against
BOTH copies (tab/LF/CR/NUL/DEL obfuscation, data:, vbscript:, file:,
protocol-relative) and the xss fixture now uses the tab-obfuscated payload
end-to-end. The previous test only asserted the plain javascript: case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bash->Node port dropped curl's `--retry 3 --retry-delay 3`, so a single
transient CDN error hard-failed the brew job and, since deploy needs it,
blocked the Pages deploy. Re-add a bounded retry loop (env-overridable,
same shape as src/lib/releases.mjs); the 180s abort is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@nathanialhenniges, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 46 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 64ebfd8d-9ff9-4c30-a23c-e5c9e330c17e

📥 Commits

Reviewing files that changed from the base of the PR and between 364f8e5 and 1f021bb.

📒 Files selected for processing (4)
  • .github/workflows/ci.yml
  • package.json
  • scripts/verify-checksums.mjs
  • tests/xss.test.js

Walkthrough

The PR hardens build-time and client-side URL sanitization, expands security tests, adds configurable timeout and retry handling to checksum downloads, documents the Astro workflow, and authenticates Homebrew CI GitHub API requests.

Changes

URL Sanitization

Layer / File(s) Summary
Control-character URL sanitization and validation
src/lib/serialize.mjs, src/scripts/site.js, tests/safe-url.test.js, tests/xss.test.js
safeUrl strips C0 control characters and DEL before scheme validation. Tests cover blocked and allowed URLs, nullish inputs, client/build parity, and obfuscated javascript: URLs.

Checksum Download Reliability

Layer / File(s) Summary
Retryable checksum artifact downloads
scripts/verify-checksums.mjs
Checksum downloads use configurable retries and delays with per-attempt abort timeouts, while the verification loop delegates download lifecycle management to the shared helper.

Workflow Documentation and Authentication

Layer / File(s) Summary
Astro build and CI documentation
AGENTS.md
Repository guidance describes the Astro layout, npm-based builds, offline testing, verification commands, CI stages, and deployment workflow.
Authenticated Homebrew validation
.github/workflows/ci.yml
The Homebrew validation job provides HOMEBREW_GITHUB_API_TOKEN for GitHub API requests.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Poem

A bunny scrubbed schemes from every link,
And watched checksum retries hop.
The Astro paths are neatly mapped,
While CI calls no longer stop.
Safe burrows all around!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main security fix: blocking control-character-obfuscated javascript: URLs.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/fix-safeurl-control-chars

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/xss.test.js (1)

68-74: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use Cheerio to assert the rendered link attribute.

Parse html and assert $("#detail-homepage").attr("href") rather than depending on attribute order and quote serialization in a regex. Confirm cheerio is declared before updating the test. As per coding guidelines, “tests/*.test.js: Write automated tests with Vitest and use Cheerio for HTML DOM assertions where appropriate.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/xss.test.js` around lines 68 - 74, Update the test around the
“control-char-obfuscated javascript: homepage URL” case to use Cheerio for DOM
assertions: first confirm the test dependencies declare cheerio, parse html, and
assert the rendered `#detail-homepage` href via
$("`#detail-homepage`").attr("href"). Preserve the existing expectations that the
href is "#" and contains no script: payload.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/verify-checksums.mjs`:
- Around line 26-31: Update the fetch/retry logic in the checksum verification
flow so non-ok permanent HTTP responses, including 401, 403, and 404, are
handled immediately outside the retry path; consume the response body before
throwing. Keep retries limited to transient HTTP failures and network errors,
while preserving the existing lastErr handling for retryable failures.
- Around line 11-12: Validate CHECKSUM_RETRIES when initializing RETRIES,
rejecting NaN, Infinity, negative, and non-integer values while preserving 0 as
valid. Ensure invalid configuration fails explicitly before the retry loop can
run.

---

Nitpick comments:
In `@tests/xss.test.js`:
- Around line 68-74: Update the test around the “control-char-obfuscated
javascript: homepage URL” case to use Cheerio for DOM assertions: first confirm
the test dependencies declare cheerio, parse html, and assert the rendered
`#detail-homepage` href via $("`#detail-homepage`").attr("href"). Preserve the
existing expectations that the href is "#" and contains no script: payload.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 71b0be48-e2e9-464f-883f-49ebab80ee2f

📥 Commits

Reviewing files that changed from the base of the PR and between 5f4ecd2 and 2d96c15.

📒 Files selected for processing (5)
  • scripts/verify-checksums.mjs
  • src/lib/serialize.mjs
  • src/scripts/site.js
  • tests/safe-url.test.js
  • tests/xss.test.js

Comment thread scripts/verify-checksums.mjs Outdated
Comment thread scripts/verify-checksums.mjs
AGENTS.md still described the retired bash pipeline — scripts/build-site.sh,
site/{template,detail-template}.html, site/partials/, site/shared.js's
initDetailPage(), site/input.css, deploy-site.yml — none of which exist. An
agent following it would run a missing script and look for missing files.

Rewrite against the actual tree: Astro static site (base /homebrew-den, outDir
_site), src/lib/*.mjs as the single sources of truth for catalog parsing,
stability and release fetching, npm run dev/build/preview, the build-once
test setup in tests/global-setup.js, and the real two-job-then-gated-deploy
ci.yml. Adds a "Security notes (do not regress)" section covering
serializeJson, the deliberately duplicated safeUrl and its control-char
stripping, and the set:html hazard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@AGENTS.md`:
- Around line 108-109: Add a package.json engines.node declaration requiring
Node.js 22 or newer, matching the workflow matrix. Do not alter the existing
package manager or dependency configuration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 084abf63-feef-42c8-ae72-064b713b01e8

📥 Commits

Reviewing files that changed from the base of the PR and between 2d96c15 and 0ec756b.

📒 Files selected for processing (1)
  • AGENTS.md

Comment thread AGENTS.md
brew audit --strict --online and brew livecheck call the GitHub API
directly. Unauthenticated, they share a 60 req/hr per-IP limit with every
other job on the shared runner, so the job failed with

  exception while auditing wolfwave: GitHub API Error:
  API rate limit exceeded ... Rate limit exceeded for core resource (60 limit).

on a docs-only commit, with the formula and cask byte-identical to two runs
that had passed. Set HOMEBREW_GITHUB_API_TOKEN from the job-scoped
secrets.GITHUB_TOKEN, at job level so every brew step is covered. Raises the
limit to 5000/hr and makes the job deterministic rather than a coin flip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Around line 57-63: Remove the job-level HOMEBREW_GITHUB_API_TOKEN environment
setting and add it only to the individual brew audit and brew livecheck steps.
Keep checksum verification, formula/cask installation, and repository-controlled
brew test steps free of this token.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6719972f-9b74-4c86-995f-0ef9bcd296cb

📥 Commits

Reviewing files that changed from the base of the PR and between 0ec756b and 364f8e5.

📒 Files selected for processing (1)
  • .github/workflows/ci.yml

Comment thread .github/workflows/ci.yml Outdated
- ci: scope HOMEBREW_GITHUB_API_TOKEN to the brew audit and livecheck steps
  instead of the whole job. Those are the only steps that call the GitHub
  API; brew install and brew test execute formula-controlled Ruby and must
  not see the token.
- verify-checksums: validate CHECKSUM_RETRIES / CHECKSUM_RETRY_DELAY_MS /
  CHECKSUM_TIMEOUT_MS as non-negative integers. Number("abc") is NaN and
  `attempt <= NaN` is false, so a typo'd override silently skipped every
  attempt and threw an undefined error. 0 remains valid ("no retries").
- verify-checksums: do not retry permanent 4xx responses (bad URL, renamed
  asset, private repo); they cannot succeed and only burn RETRIES * delay.
  408/429 stay retryable. Drain the body before throwing.
- tests/xss: assert the href with Cheerio rather than a regex over raw HTML,
  per the repo's testing guideline.
- package.json: declare engines.node >=22 to match CI and the docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nathanialhenniges
nathanialhenniges merged commit 71957ec into main Jul 29, 2026
5 checks passed
@nathanialhenniges
nathanialhenniges deleted the claude/fix-safeurl-control-chars branch July 29, 2026 15:22
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.

1 participant