Skip to content

fix(server): harden the console Host/Origin guards and fuzz them - #4

Merged
DevCop95 merged 1 commit into
mainfrom
hardening/console-guard-fuzzing
Oct 17, 2026
Merged

DevCop95 merged 1 commit into
mainfrom
hardening/console-guard-fuzzing

Conversation

@DevCop95

Copy link
Copy Markdown
Owner

Follow-up to #2: the last unfuzzed surface, which is the one that matters —
the console's guard. Rainy-day findings, and I want to be precise about how
exploitable they were, because overstating them would be the same failure as
hiding them.

Findings

🟢 validHost trusted net.SplitHostPort — it validates the shape of an
address, not its contents:

net.SplitHostPort("127.0.0.1:8080/evil") -> host="127.0.0.1" port="8080/evil" err=<nil>

So validHost returned true for 127.0.0.1:8080/evil, 127.0.0.1:@evil,
localhost:80?x=1 and 127.0.0.1:8a08. The stdlib HTTP server rejects those
Host headers (httpguts.ValidHostHeader) before a handler ever runs, so this
was not a live bypass — but this is the DNS-rebinding control, and it was
deciding differently from the string it was handed. The port must now be
numerically valid or the value is refused.

🟢 allowedOrigin read a remote origin as local — Go's url.Parse puts
userinfo in u.User:

url.Parse("http://evil.com@127.0.0.1") -> User="evil.com" Host="127.0.0.1"

The guard only inspected u.Host, so it accepted it. A browser never sends
userinfo in Origin, so this was not exploitable either — it becomes one
the moment the check is reused or moved in front of something else. An accepted
Origin is now exactly scheme://loopback-host[:port]: no userinfo, no path,
no query, no fragment.

🟢 origin is not validated for a trailing-slash-free form — folded into
the same fix; http://127.0.0.1/#x and http://127.0.0.1/?a=b were accepted.

No finding is CRITICAL or HIGH, and I would rather say that plainly than pad the
list. What made these worth fixing is that they are all in the guard, where a
single mistake exposes every module at once.

Tests with teeth

Four fuzz targets plus two invariants:

  • FuzzValidHost / FuzzAllowedOrigin, each checked against an independent
    oracle
    that restates the policy naively (only the canonical loopback forms,
    optionally with a numeric port). An oracle that shares an implementation with
    the code under test proves nothing, so it is deliberately dumb.
  • FuzzRoutesRejectBadToken drives the whole mux with an arbitrary Host,
    Origin and body, and a token that cannot match by construction (a byte
    is prepended). It therefore only ever exercises refusals — it can never reach
    a state-changing handler, so it is safe to run unattended.
  • FuzzEqual pins token comparison to exact equality: prefixes, truncations and
    case variants must never compare equal, since one Equal call gates
    everything.
  • TestGuardedRoutesRejectBadToken walks the route table and fails if a route is
    registered without s.guard(...) — the realistic way a module gets exposed by
    accident. TestRouteTableMatchesServer fails if the table drifts from
    routes().

I verified the targets are not vacuous by reverting both fixes: the seed corpus
alone reproduces all of them (and only them) —

--- FAIL: FuzzValidHost/seed#9   validHost("127.0.0.1:8080/evil") = true, but it is not a loopback authority
--- FAIL: FuzzAllowedOrigin/seed#7  allowedOrigin("http://evil.com@127.0.0.1") = true, but an Origin carries no userinfo

Fuzzing results

FuzzValidHost              291,243 execs / 30s   PASS
FuzzAllowedOrigin          133,446 execs / 30s   PASS
FuzzRoutesRejectBadToken   109,680 execs / 30s   PASS
FuzzEqual                1,866,815 execs / 20s   PASS

No crashers, no stray testdata/ artifacts.

Also

  • fuzz-smoke now runs nine targets (30s for the parsers, 20s for the console
    ones, which are cheaper per execution and carry hostile seeds).
  • Coverage 44.7% → 45.2% (validHost, isPort, guard at 100%), floor
    42 → 43.
  • gofmt, go vet ./..., GOOS=linux go vet ./... all clean.

Refs #2.

Three findings from fuzzing the console, all in the guard rather than in a
module — which is where a single mistake exposes everything at once.

validHost trusted net.SplitHostPort, which validates the shape of an address
but not its contents: it returns host "127.0.0.1" and port "8080/evil" for
"127.0.0.1:8080/evil", and the same for "127.0.0.1:@evil", "localhost:80?x=1"
and "127.0.0.1:8a08". The guard accepted all four. The stdlib HTTP server
rejects those Host headers before a handler runs, so this was hardening and not
a live bypass — but the DNS-rebinding control was deciding differently from the
string it was handed, and now the port must be numerically valid or the value
is refused.

allowedOrigin checked only the parsed host, and Go's url.Parse puts the userinfo
in u.User: "http://evil.com@127.0.0.1" parses with u.Host == "127.0.0.1", so a
remote origin read as local. Browsers never send userinfo in Origin, so again
this was not exploitable — it becomes one the moment the check is reused.
An accepted Origin is now exactly scheme://loopback-host[:port]: no userinfo,
no path, no query, no fragment.

The guards now have four fuzz targets and two invariant tests:
  - FuzzValidHost / FuzzAllowedOrigin, both checked against an independent
    oracle that restates the policy naively ("only canonical loopback forms,
    optionally with a numeric port"). Verified to reproduce all three findings
    above on the pre-fix code from the seed corpus alone.
  - FuzzRoutesRejectBadToken drives the whole mux with an arbitrary Host,
    Origin and body and a token that cannot match by construction, so it only
    ever exercises refusals.
  - FuzzEqual pins token comparison to exact equality (prefixes, truncations).
  - TestGuardedRoutesRejectBadToken walks the route table in fuzz_test.go: a
    route registered without s.guard(...) fails it, which is how a module gets
    exposed by accident. TestRouteTableMatchesServer fails if the table drifts
    from routes().

Coverage 44.7% -> 45.2%, floor 42 -> 43. fuzz-smoke now runs nine targets.
@DevCop95
DevCop95 merged commit c1abf32 into main Oct 17, 2026
5 checks passed
@DevCop95
DevCop95 deleted the hardening/console-guard-fuzzing branch October 17, 2026 16:49
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