A differential fuzzer that finds URLs whose hostname differs depending on who parses them.
Allow-lists, open-redirect guards, SSRF filters and ad-review pipelines all need to pull the host
out of an untrusted URL — and most of them do not use a WHATWG-conformant parser. They reach for a
legacy url.parse, a regex copied from Stack Overflow, or a split('/'). When the checker's idea of
the host disagrees with the browser's, you get a bypass: the guard sees good.com and waves the URL
through, the browser connects to evil.com. The known tricks — backslashes, userinfo @, stripped
tabs, numeric IPv4 like 0x7f.1, full-width dots — are scattered across blog posts, and nobody has a
tool that finds new ones against the extractor in your own codebase. hostsplit is that tool.
hostsplit is a differential fuzzer with three parts.
1. The oracle. Node's built-in URL is the Ada parser and is WHATWG-conformant, so
new URL(input).hostname is the ground-truth host. Every candidate is compared against it.
2. A grammar-aware generator. Starting from a seed corpus of benign URLs, a set of mutation
operators inject the exact constructs the spec treats specially, aimed at structural boundaries
(after ://, inside the host, around userinfo @, before the port) located by a lightweight URL
locator:
- backslash vs. slash, userinfo
@, ASCII tab/LF/CR the spec strips, - percent-encoded delimiters (
%2f,%5c,%2e,%40), unicode dot look-alikes IDNA maps to., - numeric IPv4 in hex / octal / dword / short form, stray brackets, trailing dots, leading-zero ports.
Each candidate applies 1–4 operators, so composite bypasses (\ and @, %2f and @)
emerge on their own rather than being hard-coded. Everything is driven by a seeded PRNG
(mulberry32), so --seed 7 reproduces a run exactly. A uniform random-byte mutator is included as a
baseline for the benchmark.
3. A harness + delta-debugging minimizer. For each candidate the harness normalizes both hosts
(fold unicode dots to ., lowercase, drop a trailing dot — see design notes for why it does not
do more), and reports a divergence when they differ. Each divergence is classified:
host-confusion— both parsers found a host and they differ (the security bug),extractor-blind— the oracle found a host, the extractor returned nothing,oracle-rejects— the oracle rejected the input, the extractor invented a host.
One representative per class is shrunk to a 1-minimal URL by ddmin (Zeller & Hildebrand) over characters, then over grammar tokens, then a final per-character pass that guarantees removing any single character breaks the divergence. Crucially the minimizer's predicate preserves the divergence category, so a real host-confusion cannot collapse into a trivial "extractor accepts a non-URL" case. Findings are grouped by a signature: the set of special characters that survive minimization.
input: http://good.com@evil.com
oracle: new URL(...).hostname -> "evil.com" (good.com is userinfo)
extractor: split('//')[1].split('/')[0]
.split('@')[0] -> "good.com" (trusts the wrong side of @)
result: host-confusion, signature [at], minimizes to http://@s ("s" vs no host)
Requires Node.js ≥ 20. No runtime dependencies.
git clone <this repo> && cd hostsplit
npm install # also builds the CLI (dist/) via the prepare script
npm test
Fuzz your own extractor — any (urlString) => host | null function, default-exported from a JS file:
$ node dist/cli.js --extractor ./examples/myHost.js --budget 20000 --seed 7
== ./examples/myHost.js ==
18413 divergences over 20000 inputs, 19 class(es)
[host-confusion] [at] x4418
minimal : "http://@s"
oracle : "s"
extractor: "@s"
[host-confusion] [(plain)] x3692
minimal : "http://0"
oracle : "0.0.0.0"
extractor: "0"
[host-confusion] [backslash] x1666
minimal : "http://m\\"
oracle : "m"
extractor: "m\\"
...
With no --extractor, the bundled zoo of vulnerable extractors is fuzzed instead. Other flags:
--only <name>, --mutator random, --corpus <file>, --no-minimize, --json,
--list-extractors, --help.
import { fuzz, minimize, differ, createGenerator, naiveSplitHost } from 'hostsplit';
const result = fuzz(naiveSplitHost, { seed: 1, budget: 20000 });
for (const f of result.findings) {
console.log(f.category, f.signature, JSON.stringify(f.minimized));
}
// minimize(input, predicate) shrinks any input to a 1-minimal witness:
const witness = minimize('http://good.com@evil.com/a/b?c=d',
(s) => differ(s, naiveSplitHost) !== null);Inputs needed to find the first divergence of each class, averaged over 10 seeds, structural mutator
vs. the uniform random-byte baseline. Budget 20 000 inputs per seed. (k/10) = seeds that found the
class at all. Measured on Apple Silicon (arm64), Node v24.12.0; reproduce with npm run bench.
| class / technique | structural | random baseline |
|---|---|---|
userinfo @ |
4 inputs (10/10) | 17 inputs (10/10) |
backslash + @ |
19 inputs (10/10) | 2915 inputs (10/10) |
| stripped tab | 15 inputs (10/10) | 242 inputs (10/10) |
| hex/short IPv4 | 8 inputs (10/10) | 2230 inputs (1/10) |
| full-width dot | 10 inputs (10/10) | miss (0/10) |
encoded-slash + @ |
53 inputs (10/10) | miss (0/10) |
| total wall-clock | 6 ms | 181 ms |
The structural mutator finds every known class within a few dozen inputs. The random baseline misses
full-width dot and encoded-slash entirely (it draws bytes 0–255, so it can never emit U+FF0E, and is
astronomically unlikely to type the literal %2f), and finds hex IPv4 only once in ten seeds.
Which technique fools which bundled extractor (Y = host-confusion; reproduce via npm run bench):
| technique | url.parse | split | between-slashes | after-last-at | decode-first | before-at | ascii-domain | strip-userinfo-port |
|---|---|---|---|---|---|---|---|---|
userinfo @ |
· | Y | Y | · | · | Y | Y | · |
backslash + @ |
· | Y | Y | Y | · | · | · | Y |
| stripped tab | · | Y | Y | Y | Y | Y | Y | Y |
| hex/short IPv4 | Y | Y | Y | Y | Y | Y | Y | Y |
| full-width dot | · | · | · | · | · | · | Y | · |
encoded-slash + @ |
· | Y | Y | · | Y | Y | Y | · |
Note that on modern Node, url.parse has been hardened against the backslash, tab and userinfo
tricks — its one remaining host-confusion is the numeric IPv4 forms it fails to canonicalize. That is
itself a finding hostsplit surfaces automatically.
Normalization deliberately does not re-parse the host. The obvious way to fold cosmetic
differences is Node's domainToASCII. But domainToASCII is a full WHATWG host parser: it
canonicalizes 0x7f.1 to 127.0.0.1, rewrites backslashes, and strips stray delimiters. Running it
on the extractor's output would re-parse that output the spec's way and silently erase the very
legacy-vs-spec divergences the tool exists to find. So normalization is a hand-written, minimal
transform — map the unicode dot look-alikes to ., lowercase, drop one trailing dot — and nothing
more. The trade-off is that a raw non-ASCII label (bücher.de) is compared literally rather than as
its punycode; the built-in corpus never emits such labels, and folding them would require exactly the
parser we are refusing to trust.
The minimizer preserves the divergence category. Plain ddmin against "does some divergence
still happen?" slides a genuine host-confusion into an unrelated trivial case — e.g. "0", which a
lenient extractor calls a host but the oracle rejects. Constraining the predicate to keep the same
category (host-confusion stays host-confusion) is what makes the minimal witnesses illustrative
instead of degenerate. This is a small, general delta-debugging lesson: minimize toward the specific
failure, not any failure.
- Host only. It compares hostnames, not scheme, port, or path. A guard that also gets the port wrong is out of scope.
- Absolute URLs. The generator mutates absolute URLs; relative-reference resolution (which brings its own base-URL confusions) is not modeled.
- Minimization reshapes class labels. A 1-minimal witness is attributed to its simplest cause,
so a
user@0x7f.1input is reported underat, nothex-ipv4. The known-answer tests therefore verify rediscovery on the raw divergent inputs, and the per-technique table above is computed from canonical inputs, not from minimized labels. - The bundled regexes are representative, not verbatim. Each embodies a real mistake seen in open-redirect guards, SSRF filters and origin checks, but they are distilled patterns, not copies of one specific project.
- No punycode folding of arbitrary IDN labels, by design (see design notes).
MIT © 2026 Ariel Belhamou