fix: validate the authority instead of the whole href - #34
Merged
Merged
Conversation
`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. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3AcnZdy222rWCkptbywJT
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
new URL()already applies IDNA, IPv4/IPv6 validation and percent-encoding. The only question it leaves open is whether the host is one the public internet can resolve. This answers that directly instead of round-tripping the normalizedhrefback throughurl-regex-safe, a matcher built to find URLs inside prose.Why
It closes a hole #32 could not close.
url-regex-safe's TLD list stores IDN TLDs in Unicode (рф) and holds zeroxn--entries, so #32 had to hand the regex the host's own TLD — a check that passes by construction.https://xn--80a0aaa.xn--totallyfaketld/was accepted onmasterand no amount of tuning that branch would reject it. Mapping the list to punycode once at load makes the punycode case fall out for free rather than needing a special case.The special cases collapse. The IPv6 exact-match exception (#30), the TLD injection (#32) and the second origin-only pass (#31) were three bandaids on the same mismatch. All three are gone, and with them
url-regex-safeand there2native binding.tldsbecomes a direct dependency; it was already transitive.The path check was doing harm. Every character in
url-regex-safe'sdisallowedCharsis already percent-encoded or stripped by WHATWG normalization, andparens: true/apostrophes: truewere passed precisely to disable the two checks that survive. What was left rejected ordinary URLs — see the behavior changes below.Behavior changes
Fuzz-differentialled against
masterover 300k inputs. Every divergence falls in one of four classes, zero unclassified:https://example.com/a.,/?q=1.,/#x!falsehttp://example.com:1/(ports below 10)falsehttp://x.a_b.com/(underscore outside a bare second-level label)falsehttps://xn--80a0aaa.xn--totallyfaketld/,https://xn--80a0aaa.ñ/falseThe first three are
url-regex-safeartifacts, not intent — it forbids a trailing. ? !because in prose that is sentence punctuation, and it accepteda_b.comwhile rejectingx.a_b.com. The fourth is the fix. Minor release, not a patch.Performance
mastercompiled a fresh RE2 regex on every call; construction was ~99% of the work. Require time is 3.9ms, dominated by a one-time ICU init that the first non-ASCIInew URL()would pay anyway.REGEX_LABELShas no cross-group ambiguity (labels are separated by a mandatory literal.) and backtracks linearly within a label. Probed to 200k chars on backtrack-forcing inputs — linear, no cliff. It does not need RE2.Tests
npx ava— 5 pass. New cases:localhost:3000,0.0.0.0:1,sub.dom_ain.example.com, the three trailing-punctuation paths,xn--80a0aaa.xn--totallyfaketld,xn--80a0aaa.ñ,kikobeats.com.,a-.com,_dmarc.example.com, plus a test that an IDN TLD is matched by its punycode form.Note, not in this PR
package.jsondeclares"types": "src/index.d.ts"but the file ships atindex.d.ts, so TypeScript consumers get no types. Pre-existing onmaster.🤖 Generated with Claude Code
https://claude.ai/code/session_01G3AcnZdy222rWCkptbywJT
Note
Medium Risk
Changes URL acceptance rules for security-sensitive validation; fixes prior false positives but alters which URLs pass (paths with punctuation, underscore subdomains, low ports).
Overview
Replaces
url-regex-safeandre2withisPublicHostname: afternew URL()enforces http(s) and no credentials, acceptance depends on whether the host is IPv6,localhost, IPv4, or a domain whose punycode TLD is in thetldspublic list, withREGEX_LABELSguarding hostname shape.This closes the fake-IDN-TLD hole (e.g.
xn--totallyfaketld) and drops the IPv6/IDN regex workarounds. Intentional behavior shifts: more real URLs with trailing.,?, or!in path/query/fragment are accepted; fake TLDs, trailing-dot hosts, and some invalid labels are rejected. README documents the public-host rule; tests cover the new cases and punycode IDN TLD matching.Reviewed by Cursor Bugbot for commit e48e578. Bugbot is set up for automated code reviews on this repo. Configure here.