Skip to content

fix: validate the authority instead of the whole href - #34

Merged
Kikobeats merged 1 commit into
masterfrom
authority-validation
Aug 5, 2026
Merged

Kikobeats merged 1 commit into
masterfrom
authority-validation

Conversation

@Kikobeats

@Kikobeats Kikobeats commented Aug 5, 2026 •

Copy link
Copy Markdown
Owner

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 normalized href back through url-regex-safe, a matcher built to find URLs inside prose.

const isPublicHostname = hostname => {
  if (hostname[0] === '[') return true          // parser validated the IPv6 literal
  if (hostname === 'localhost') return true

  const tldIndex = hostname.lastIndexOf('.') + 1
  const tld = hostname.slice(tldIndex)
  if (REGEX_IPV4_TLD.test(tld)) return true     // parser resolved the host as IPv4

  return tldIndex > 0 && PUBLIC_TLDS.has(tld) && REGEX_LABELS.test(hostname)
}

Why

It closes a hole #32 could not close. url-regex-safe's TLD list stores IDN TLDs in Unicode (рф) and holds zero xn-- 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 on master and 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-safe and the re2 native binding. tlds becomes a direct dependency; it was already transitive.

The path check was doing harm. Every character in url-regex-safe's disallowedChars is already percent-encoded or stripped by WHATWG normalization, and parens: true / apostrophes: true were 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 master over 300k inputs. Every divergence falls in one of four classes, zero unclassified:

before after
https://example.com/a., /?q=1., /#x! false accepted
http://example.com:1/ (ports below 10) false accepted
http://x.a_b.com/ (underscore outside a bare second-level label) false accepted
https://xn--80a0aaa.xn--totallyfaketld/, https://xn--80a0aaa.ñ/ accepted false

The first three are url-regex-safe artifacts, not intent — it forbids a trailing . ? ! because in prose that is sentence punctuation, and it accepted a_b.com while rejecting x.a_b.com. The fourth is the fix. Minor release, not a patch.

Performance

before after
ordinary host 21.6µs 0.29µs
punycode host 3.8µs 0.34µs
IPv6 host 22.5µs 0.22µs

master compiled 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-ASCII new URL() would pay anyway.

REGEX_LABELS has 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.json declares "types": "src/index.d.ts" but the file ships at index.d.ts, so TypeScript consumers get no types. Pre-existing on master.

🤖 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-safe and re2 with isPublicHostname: after new 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 the tlds public list, with REGEX_LABELS guarding 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.

`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
@Kikobeats
Kikobeats merged commit ac55047 into master Aug 5, 2026
3 checks passed
@Kikobeats
Kikobeats deleted the authority-validation branch August 5, 2026 19: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