Skip to content

Network destination hardening: private-range block, auth strip, downgrade refusal, code-loading env (#171, #172) - #176

Merged
howlcipher merged 8 commits into
mainfrom
hf-netblock-rebased
Oct 13, 2026
Merged

howlcipher merged 8 commits into
mainfrom
hf-netblock-rebased

Conversation

@howlcipher

Copy link
Copy Markdown
Owner

Closes #171. Closes #172. Based on main f835767 (after #173).

Fixes (core capability client, generated Go, Node JS):

  • Hostname grants refuse DNS results in loopback, RFC1918, ULA, NAT64 (64:ff9b::/96), link-local and unspecified ranges. The check runs when the connection is made, and the vetted address is pinned. Explicit IP-literal and localhost grants opt out, and the receipt records private_destination_opt_out. Multicast is always refused.
  • Authorization and Cookie are stripped after any host, port or scheme change in a redirect chain.
  • https→http redirect downgrades are refused.
  • process:env= refuses PYTHONPATH, NODE_OPTIONS, BASH_ENV (plus ENV, PYTHONSTARTUP, NODE_PATH, PERL5LIB/OPT, RUBYOPT/LIB), case-insensitively.
  • Trailing-dot hosts now get an accurate "trailing dot" error.

Tests: a shared internal/nettest suite runs against the core client and against the helper gogen emits. It covers the 11-hop redirect cap, the HTTP(S)_PROXY trap, downgrade, credential stripping on a port change, private/NAT64/multicast dials and DNS-name dials. JS has the equivalent tests, plus NODE_USE_ENV_PROXY. Mutation check: 24 of 24 targeted mutations caught (Go core, gogen, JS).

Docs: localhost self-/admin, webhook_relay UPSTREAM_URL, JS previously accepting 127.1 (now refused), Node NODE_USE_ENV_PROXY. Breaking changes are listed in change_log.md.

No production flip, #90 stays Partial, no tip-lock change, no DOM. Pins, freeze, lock, arity and parity tests unchanged. go test ./... exits 0.

@howlcipher howlcipher left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review of #176 at 237bacc. go build and go test ./... pass locally, and CI build is green.

Verified: the private-range block for name grants is checked at connect time on the dialed address and pinned in core, gogen and Node, with no re-resolve. Multicast is refused even with an opt-out. Opt-out applies only to IP-literal and localhost grants (case-insensitive). localhost. is rejected at parse, and localhost.example.com or names resolving to 127.0.0.1 are denied. The receipt records private_destination_opt_out on allowed effects only. https→http redirects are refused. Authorization and Cookie are stripped on any scheme, host or port change, tracked cumulatively across hops. The 11-hop and proxy trap tests (upper and lower case, ALL_PROXY, empty NO_PROXY, plus NODE_USE_ENV_PROXY on JS) exist on all three paths. Env refusal is case-insensitive, with LD_ and DYLD_ prefix matching.

Mutations: 13 of my 14 were caught. The survivor is M4 (skipping the core pre-dial loop), which is covered redundantly by d.Control, though no test pins the pre-check on its own.

Non-blocking follow-ups:

  1. Range gaps (network.go:276, gogen.go:820, JS howlFrameForbiddenAddress): CGNAT 100.64/10, 0.0.0.0/8, 255.255.255.255, IPv4-compatible ::a.b.c.d, 64:ff9b:1::/48, 6to4 2002::/16 and Teredo 2001::/32 embeddings, 198.18/15, 192.0.0/24 and fec0::/10 are all reachable by a name grant. Please use one shared prefix table and unwrap embeddings.
  2. Only Authorization and Cookie are stripped. Proxy-Authorization and custom auth headers survive a cross-origin redirect. Please strip all user-set headers on an origin change, or document this.
  3. The env list misses GOFLAGS (-toolexec), GODEBUG, JAVA_TOOL_OPTIONS, PYTHONHOME, PERLLIB, GCONV_PATH and NODE_EXTRA_CA_CERTS. The list is duplicated in three files; please single-source it and add a parity test.
  4. The localhost opt-out trusts the resolver. Please restrict it to loopback addresses.

Verdict: merge-ready. Items 1–3 should land before any live run that has network or process:env grants.

Assurance (bot-assurance-0001)

@howlcipher howlcipher left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of #176 at 237baccd1ea5753b83aaf2354aa4aa5a35d37bda (base f835767b)

Verdict: not merge-ready. There are 2 blockers. The core design is right: one lookup, every resolved address vetted, the vetted IP dialed, proxies ignored, downgrade refused, and auth stripped when the origin changes. It holds on all three targets. But the private-range set misses a metadata range, and the opt-out leaks through redirects.

I used a fresh worktree, which I removed afterwards. Port 8080 was free and I started no servers. Test servers were httptest or Node servers on ephemeral 127.0.0.1 ports, all closed. I made no real external network calls; 192.0.2.1 (TEST-NET) only proves which address was dialed.

Gates (real exit codes)

  • gofmt -l . is empty. go vet ./... exit 0. go test ./... exit 0 (75 ok, including the gogen consumer suite and the Node suites on Node 20.19.2).
  • Focused -v run exit 0, 18 tests: arity coverage 386 round-trip (126/159), TestProdFlipCriteriaLock, TestHFIRBenchmarkSolutionParity, the 10 authority tests, and the #165/#166 tests.
  • The diff touches no files under prod_flip_criteria_test.go, benchmarks/, experiments/ or docs/reference/lowered_hfir*. The lock, pins and freeze are unchanged, there's no flip and #90 stays Partial.

Breakage attempts

DNS rebinding / TOCTOU (core, generated Go, Node). The lookup returned 192.0.2.1 first and 127.0.0.1 after that.

  • All three targets did exactly 1 lookup and dialed the vetted 192.0.2.1 (timeout). There's no second resolution: core and Go dial IP:port, and Node pins through the lookup option with agent:false.
  • Multi-A records [8.8.8.8, 127.0.0.1] in either order are denied (fail-closed on any bad address). So is ::ffff:127.0.0.1.

Address classes. Core and generated Go share the same predicate. Node's howlFrameForbiddenAddress matches it.

  • Blocked: ::ffff:127.0.0.1, ::ffff:10.0.0.1, ::ffff:169.254.169.254, 0.0.0.0, ::, 169.254.169.254, fd00::1, fe80::1%eth0, 64:ff9b::a00:1, and multicast.
  • Not blocked (a name grant dials them):
    • 100.64.0.0/10, including 100.100.100.200, the Alibaba Cloud metadata service, and Tailscale and CGNAT peers.
    • 64:ff9b:1::/48 (RFC 8215 local-use NAT64). The PR's own test asserts this range is public.
    • 6to4 2002::/16 with an embedded private v4 (2002:7f00:1::1, 2002:a00:1::1), and Teredo 2001::/32.
    • IPv4-compatible ::7f00:1.
    • 0.0.0.0/8 other than 0.0.0.0 itself, 198.18.0.0/15, 192.0.0.0/24, 240.0.0.0/4, 255.255.255.255, and fec0::/10.

Numeric forms and the trailing dot.

  • Core and Go: grants and URLs written 2130706433, 0x7f000001, 0177.0.0.1 or 127.1 are rejected as "ambiguous numeric host".
  • Node: those URLs are denied (WHATWG rewrite detected).
  • example.com. and localhost. are rejected with the trailing-dot diagnostic. LOCALHOST normalizes to localhost.
  • Nit: core accepts [::ffff:127.0.0.1] as a URL (canonicalized to the 127.0.0.1 grant), while Node denies it. The divergence is harmless, since it's the same grant either way, but worth aligning.

Redirects.

  • Literal to literal on another port: Authorization and Cookie are stripped on core and Node. Proxy-Authorization and X-Api-Key are forwarded on core; X-Api-Key is forwarded on Node (nit).
  • Literal to a name grant that resolves to loopback is denied. Literal to an ungranted localhost is denied.
  • https to http on the same host is refused.
  • Case-only host changes count as the same origin. Punycode and Unicode forms count as different origins (strip, the conservative choice).
  • A same-origin redirect loop stops at 10 redirects.
  • A redirect target carrying userinfo (u:p@) makes Node send Authorization: Basic to the next hop. That's the redirecting server's own string, so it's a nit, but refusing userinfo in Location would be cleaner.

Proxies. The PR's suites set HTTP(S)_PROXY, ALL_PROXY, NO_PROXY and NODE_USE_ENV_PROXY (process and startup environment) against a trap server: zero trap connections on core, generated Go and Node. Node uses http.request with agent:false, so the global agent and env-proxy dispatcher are never reached.

Opt-out scope. Blocker 1.

  • A name-granted fetch can be redirected into an opt-out destination whenever the program also holds that grant. On core, with grants api.example.com and 10.0.0.5:80, CheckRedirect allows http://api.example.com/ to redirect to http://10.0.0.5:80/admin. With localhost:80 granted, the same holds for http://localhost/admin.
  • On Node, a localhost-origin fetch redirected to a granted 127.0.0.1 literal also succeeds.
  • The receipt does record private_destination_opt_out on that hop, but the brief was that an opt-out must not let a name-grant fetch reach private addresses through a redirect. As written, a public server can steer a fetch into the operator's private targets.
  • Fix: refuse a redirect from a non-opt-out origin to an opt-out (literal or localhost) destination on all three targets, or require an explicit per-grant redirect opt-in.

Env deny list.

  • Core and Go: the case variants (pythonpath, Ld_Preload) are refused.
  • These pass through: PYTHONHOME, PERL5DB, JAVA_TOOL_OPTIONS/_JAVA_OPTIONS, GCONV_PATH (glibc loads modules from it), GIT_SSH_COMMAND, GIT_CONFIG_*/GIT_EXEC_PATH, SHELLOPTS with PS4 (bash xtrace command substitution), HOME/XDG_CONFIG_HOME (which steer git and ssh config), PATH and IFS.
  • Values come only from the runner's own environment, so the operator controls them. Exploiting this needs the operator to grant one of these names while an attacker influences the host environment. That makes it a follow-up, not a blocker.
  • Recommendation: deny the names above, at least the GIT_* code-execution variables and GCONV_PATH, PYTHONHOME, PERL5DB, JAVA_TOOL_OPTIONS, SHELLOPTS, BASHOPTS and PS4. Longer term, consider an allow-list of known-safe names.

Mutation spot-checks

I applied each mutation locally and reverted it. All three exited 1.

  • Dropping IsPrivate from core forbiddenResolved fails TestFetchSecurity/PrivateRangesDial/{10.1.2.3,172.16.0.1,...}.
  • Removing the Authorization strip from the gogen helper fails TestGoFetchSecurity/TestSecurity/CredentialsPortChange.
  • Removing the JS https: to http: refusal fails TestJSHTTPSDowngrade and TestJSBrowserHTTPSDowngrade.

Blockers

  1. Redirects into opt-out destinations: name origin to a granted private literal or localhost is allowed on core, Go and Node.
  2. 100.64.0.0/10 isn't blocked for name grants. 100.100.100.200 is a cloud metadata endpoint, the same class #171 exists to close. Block it on all three targets, and fix the test that currently asserts the gap.

Follow-ups and nits

  • Block 64:ff9b:1::/48, 6to4 and Teredo with embedded private v4, ::a.b.c.d, 0.0.0.0/8, 198.18/15, 192.0.0/24, 240/4, 255.255.255.255 and fec0::/10. The simplest route is to deny everything that isn't global unicast (!IsGlobalUnicast(), plus the extra ranges) and decode embedded v4.
  • Extend the env deny list as above.
  • Strip Proxy-Authorization on an origin change. Custom auth headers (X-Api-Key and similar) are forwarded today; either document that or strip all non-allow-listed headers across origins.
  • Refuse userinfo in redirect Location.
  • Node pins only addrs[0] and has no fallback when that address fails (availability only).
  • Align core and Node on [::ffff:a.b.c.d] URLs.

@howlcipher howlcipher left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of #176 at 3d4e659b15b670a6d3b254cce088778f8fd6550d (base f835767b)

Verdict: merge-ready. Both blockers from my previous review are fixed on all three targets: core, generated Go (tested directly this time) and Node. I found no way around the same-origin rule and no over-blocking of public addresses. What's left are follow-ups and nits, listed below.

I used a fresh worktree and removed it afterwards. Port 8080 was untouched, I started no servers, and both mutations were reverted.

Gates (real exit codes)

  • gofmt -l is empty (exit 0). go vet ./... exit 0. go test -count=1 ./... exit 0 (75 ok, including the Node suites and the generated-Go consumer suite).
  • Since 237bacc the diff touches 9 files. None are lock, prod-flip, benchmarks/, experiments/ or lowered-HFIR files, so lock, arity, parity, freeze and pins are unchanged (their tests pass inside ./...). There's no flip and #90 stays Partial.

Blocker 1: redirects into opt-out destinations. Fixed.

I fed the same chains to core CheckRedirect, the generated-Go howlFrameFetchClient.CheckRedirect (compiled from fetchHelperSource()) and Node howlFrameFetch (request layer mocked). All three targets gave the same answer unless noted.

Denied:

  • api.example.com to 10.0.0.5, localhost, LOCALHOST, [fd00::5] or [::ffff:10.0.0.5].
  • API.example.com to 10.0.0.5.
  • api.example.com to localhost., localhost.:80, 127.1, 0x7f000001 or 2130706433.
  • Among the loopback forms: localhost to 127.0.0.1, localhost to [::1], 127.0.0.1 to [::1], [::1] to localhost.
  • Multi-hop: api to pub.example.com to 10.0.0.5, and 10.0.0.5 to api to localhost.

Allowed, by design:

  • LOCALHOST to localhost/x.
  • 10.0.0.5 to 10.0.0.5:8081. The same host on another port still needs its own grant.
  • 10.0.0.5 to pub.example.com to 10.0.0.5/x. The chain started on that private host, so a public hop can only bounce back to it.
  • api to api/next.

Diverges between targets: [::ffff:10.0.0.5] and 10.0.0.5 count as the same host on core and Go (canonicalized), but Node denies the hop in both directions. That's the safe direction, so it's a nit.

Typed denial on core: the error is *capability.FetchDenied with Required="network:fetch=10.0.0.5:80". Node raises CAPABILITY_DENIED, and Go raises its denied error.

Blocker 2 and the special-range matrix. Fixed.

I checked core ForbiddenDestination, Go howlFrameForbiddenDest and Node howlFrameForbiddenAddress(ip,false).

  • Forbidden on all three:
    • 100.100.100.200, 100.64.0.1 and ::ffff:100.100.100.200.
    • 6to4 2002:7f00:1::1 and 2002:6464:64c8::1.
    • Teredo 2001:0:4136:e378:8000:63bf:80ff:fffe.
    • ::7f00:1, 255.255.255.255, fec0::1 and 64:ff9b:1::a00:1.
    • Core and Node only: 64:ff9b::808:808, 0.1.2.3, 198.18.0.1, 240.0.0.1, 192.0.0.170 and 0.0.0.0.
  • Allowed on all three (no over-blocking): 8.8.8.8, 2606:4700::1111, 2002:808:808::1 and 100.128.0.1.
    • Core and Node also allow 1.1.1.1, 2001:4860:4860::8888, ::808:808 and 2a00:1450::1; core also allows 100.63.255.255 and 2400:cb00::1.
  • End-to-end: generated Go with a name grant resolving to 100.100.100.200 did 1 lookup and returned capability denied.

Shared table and drift

  • gogen.specialPrefixGoSource() and javascript.specialPrefixJSSource() render capability.SpecialPrefixes when the code is generated, so neither generated table can be edited separately.
  • nettest.BlockedAddresses and PublicAddresses feed core, Node, and Go's PrivateRangesDial. Mutation 1 below shows a single table edit failing on all three targets.
  • Nits:
    • There's no explicit test that compares the emitted literal list to SpecialPrefixes.
    • The generated-Go suite doesn't run PublicAddresses, so over-blocking is only covered on core and Node there. I probed it by hand and it's fine.

Mutations (2 of 44)

I applied each mutation locally and reverted it.

  1. Dropping 100.64.0.0/10 from SpecialPrefixes: exit 1. It fails TestFetchSecurity, TestFetchPrivateRanges, TestGoFetchSecurity and TestJSPrivateRanges (100.64.0.1, 100.100.100.200, 100.127.255.255 and the mapped form are allowed).
  2. Disabling redirectToOptOut in core CheckRedirect: exit 1. It fails TestFetchSecurity/RedirectToOptOut on all 6 deny cases.

Remaining follow-ups and nits (not blocking)

  • Gaps in the special-range table:
    • ::ffff:0:0/96 (SIIT-translated, e.g. ::ffff:0:a00:1) isn't decoded on core.
    • 3fff::/20 (RFC 9637 documentation range) and 5f00::/16 aren't in the table. Neither is 2001:20::/28 (ORCHIDv2), though that's harmless.
  • Unchanged from my last review and still open:
    • The env deny-list gaps (GIT_*, GCONV_PATH, PYTHONHOME, PERL5DB, JAVA_TOOL_OPTIONS, SHELLOPTS/BASHOPTS+PS4, HOME/XDG_CONFIG_HOME, PATH).
    • Proxy-Authorization and custom auth headers aren't stripped on an origin change.
    • Userinfo in a redirect Location isn't refused.
    • Node pins only addrs[0].
    • Core and Node still disagree on [::ffff:a.b.c.d].

@howlcipher
howlcipher marked this pull request as ready for review October 13, 2026 14:08
@howlcipher
howlcipher merged commit e36bd2d into main Oct 13, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant