Skip to content

fix: reject punycode hosts with non-public ASCII TLDs - #32

Merged
Kikobeats merged 1 commit into
masterfrom
cursor/critical-bug-management-9f33
Aug 5, 2026
Merged

Kikobeats merged 1 commit into
masterfrom
cursor/critical-bug-management-9f33

Conversation

@cursor

@cursor cursor Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Bug and impact

When the hostname contains a punycode (xn--) label, url-http disables url-regex-safe's public TLD list (tlds: []). That lets IDN hosts with non-public ASCII suffixes pass validation while the equivalent ASCII hosts are rejected.

Concrete triggers that previously returned the href instead of false:

  • https://example.local/ → false (correct)
  • https://xn--e1afmkfd.local/ → accepted (incorrect)
  • https://пример.local/ → accepted (incorrect; normalizes to the punycode form above)
  • https://xn--80a0aaa.internal/ → accepted (incorrect)
  • https://xn--80a0aaa.invalidtld/ → accepted (incorrect)

Callers that treat a truthy result as a validated public HTTP(S) URL can accept private/special-use or garbage suffixes via an IDN label — an SSRF-adjacent validation bypass.

This is distinct from #30 (credentialed-URL bypass) and #31 (path-substring authority bypass).

Root cause

Punycode matching uses tlds: [] so IDN TLDs like xn--p1ai still match (the default list has no xn-- entries). An empty TLD alternation effectively skips public-suffix validation for any host that merely contains a punycode label.

Fix

After a successful punycode match, require the hostname's final label to either:

  1. be an IDN TLD (xn--*), or
  2. pass an exact url-regex-safe check with the default public TLD list (via origin + '/')

Valid cases such as https://xn--80a0aaa.com/ and https://example.xn--p1ai/ still accept.

Validation

  • Added regression cases for .local / .internal / arbitrary suffix punycode hosts
  • npm test passes (lint + Ava, 100% coverage)
Open in Web View Automation 

Note

Medium Risk
Changes URL validation behavior for IDN/punycode hosts (SSRF-adjacent); intended to tighten acceptance but could affect edge-case URLs that previously passed.

Overview
Fixes a validation bypass where any hostname containing an xn-- label used tlds: [], so url-regex-safe skipped public-suffix checks and accepted IDN hosts on .local, .internal, or fake suffixes while the ASCII equivalents were rejected.

TLD handling now derives the last label only: custom tlds is passed when that label is an IDN TLD (xn--*), otherwise the default public TLD list applies. Punycode in non-TLD labels no longer disables suffix validation. The punycode-regex dependency is removed; IPv6 still uses non-exact matching only.

Regression tests cover punycode/Unicode hosts on private and invalid suffixes.

Reviewed by Cursor Bugbot for commit 802d669. Bugbot is set up for automated code reviews on this repo. Configure here.

@Kikobeats
Kikobeats marked this pull request as ready for review August 5, 2026 09:21
Punycode matching disabled url-regex-safe's TLD list so IDN labels
could accept .local/.internal/arbitrary suffixes that ASCII hosts reject.
Derive the TLD list from the final hostname label instead, which also
drops the punycode-regex dependency.

Co-authored-by: kikohumanbeatbox <kikohumanbeatbox@gmail.com>
@Kikobeats
Kikobeats force-pushed the cursor/critical-bug-management-9f33 branch from 7c5bc61 to 802d669 Compare August 5, 2026 15:33
@Kikobeats
Kikobeats merged commit af64b2f into master Aug 5, 2026
4 checks passed
@Kikobeats
Kikobeats deleted the cursor/critical-bug-management-9f33 branch August 5, 2026 15:35
Kikobeats added a commit that referenced this pull request Aug 5, 2026
`new URL()` already applies IDNA, IPv4/IPv6 validation and percent-encoding,
so the only question left is whether the host is one the public internet can
resolve. Answering that directly replaces the round-trip through
url-regex-safe, a matcher built to find URLs inside prose.

Closes the fake-IDN-TLD hole: url-regex-safe's list holds no `xn--` entries,
so #32 had to inject the host's own TLD, which is a check that passes by
construction. `https://xn--80a0aaa.xn--totallyfaketld/` was accepted and the
design could not reject it. Mapping the list to punycode once at load makes
the punycode case fall out instead of needing a special case, so the IPv6
exception, the TLD injection and the second origin-only pass all go away
along with the url-regex-safe and re2 dependencies.

Fuzz-differentialled against the previous implementation over 300k inputs.
Every divergence falls in one of four classes:

- paths ending in prose punctuation (`/a.`, `/?q=1.`, `/#x!`) are now
  accepted; url-regex-safe forbids a trailing `. ? !` because in prose it is
  sentence punctuation
- ports below 10 (`http://example.com:1/`) are now accepted
- an underscore is now allowed in any interior label, not only in a bare
  second-level one (`x.a_b.com` was rejected while `a_b.com` was accepted)
- a host whose TLD is not in the public suffix list is now rejected in
  punycode form too

Per call: 21.6us to 0.29us on ordinary hosts, 3.8us to 0.34us on punycode.


Claude-Session: https://claude.ai/code/session_01G3AcnZdy222rWCkptbywJT

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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