Skip to content

Repository files navigation

hostsplit

A differential fuzzer that finds URLs whose hostname differs depending on who parses them.

The problem

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.

How it works

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.

Worked example

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)

Install and usage

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.

Library API

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);

Results

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.

Design notes

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.

Limitations

  • 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.1 input is reported under at, not hex-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).

License

MIT © 2026 Ariel Belhamou

About

Differential fuzzer that finds URLs whose hostname differs depending on who parses them

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages