Repository navigation
Network destination hardening: private-range block, auth strip, downgrade refusal, code-loading env (#171, #172) - #176
Conversation
howlcipher
left a comment
There was a problem hiding this comment.
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:
- 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.
- 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.
- 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.
- The
localhostopt-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
left a comment
There was a problem hiding this comment.
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
-vrun 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/ordocs/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 thelookupoption withagent: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, including100.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::/16with an embedded private v4 (2002:7f00:1::1,2002:a00:1::1), and Teredo2001::/32. - IPv4-compatible
::7f00:1. 0.0.0.0/8other 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, andfec0::/10.
Numeric forms and the trailing dot.
- Core and Go: grants and URLs written
2130706433,0x7f000001,0177.0.0.1or127.1are rejected as "ambiguous numeric host". - Node: those URLs are denied (WHATWG rewrite detected).
example.com.andlocalhost.are rejected with the trailing-dot diagnostic.LOCALHOSTnormalizes tolocalhost.- 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:
AuthorizationandCookieare stripped on core and Node.Proxy-AuthorizationandX-Api-Keyare forwarded on core;X-Api-Keyis forwarded on Node (nit). - Literal to a name grant that resolves to loopback is denied. Literal to an ungranted
localhostis denied. httpstohttpon 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 sendAuthorization: Basicto the next hop. That's the redirecting server's own string, so it's a nit, but refusing userinfo inLocationwould 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.comand10.0.0.5:80,CheckRedirectallowshttp://api.example.com/to redirect tohttp://10.0.0.5:80/admin. Withlocalhost:80granted, the same holds forhttp://localhost/admin. - On Node, a localhost-origin fetch redirected to a granted
127.0.0.1literal also succeeds. - The receipt does record
private_destination_opt_outon 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,SHELLOPTSwithPS4(bash xtrace command substitution),HOME/XDG_CONFIG_HOME(which steer git and ssh config),PATHandIFS. - 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 andGCONV_PATH,PYTHONHOME,PERL5DB,JAVA_TOOL_OPTIONS,SHELLOPTS,BASHOPTSandPS4. 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
IsPrivatefrom coreforbiddenResolvedfailsTestFetchSecurity/PrivateRangesDial/{10.1.2.3,172.16.0.1,...}. - Removing the
Authorizationstrip from the gogen helper failsTestGoFetchSecurity/TestSecurity/CredentialsPortChange. - Removing the JS
https:tohttp:refusal failsTestJSHTTPSDowngradeandTestJSBrowserHTTPSDowngrade.
Blockers
- Redirects into opt-out destinations: name origin to a granted private literal or localhost is allowed on core, Go and Node.
100.64.0.0/10isn't blocked for name grants.100.100.100.200is 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.255andfec0::/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-Authorizationon an origin change. Custom auth headers (X-Api-Keyand 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
left a comment
There was a problem hiding this comment.
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 -lis 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.comto10.0.0.5,localhost,LOCALHOST,[fd00::5]or[::ffff:10.0.0.5].API.example.comto10.0.0.5.api.example.comtolocalhost.,localhost.:80,127.1,0x7f000001or2130706433.- Among the loopback forms:
localhostto127.0.0.1,localhostto[::1],127.0.0.1to[::1],[::1]tolocalhost. - Multi-hop:
apitopub.example.comto10.0.0.5, and10.0.0.5toapitolocalhost.
Allowed, by design:
LOCALHOSTtolocalhost/x.10.0.0.5to10.0.0.5:8081. The same host on another port still needs its own grant.10.0.0.5topub.example.comto10.0.0.5/x. The chain started on that private host, so a public hop can only bounce back to it.apitoapi/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.1and::ffff:100.100.100.200.- 6to4
2002:7f00:1::1and2002:6464:64c8::1. - Teredo
2001:0:4136:e378:8000:63bf:80ff:fffe. ::7f00:1,255.255.255.255,fec0::1and64: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.170and0.0.0.0.
- Allowed on all three (no over-blocking):
8.8.8.8,2606:4700::1111,2002:808:808::1and100.128.0.1.- Core and Node also allow
1.1.1.1,2001:4860:4860::8888,::808:808and2a00:1450::1; core also allows100.63.255.255and2400:cb00::1.
- Core and Node also allow
- End-to-end: generated Go with a name grant resolving to
100.100.100.200did 1 lookup and returned capability denied.
Shared table and drift
gogen.specialPrefixGoSource()andjavascript.specialPrefixJSSource()rendercapability.SpecialPrefixeswhen the code is generated, so neither generated table can be edited separately.nettest.BlockedAddressesandPublicAddressesfeed core, Node, and Go'sPrivateRangesDial. 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.
- There's no explicit test that compares the emitted literal list to
Mutations (2 of 44)
I applied each mutation locally and reverted it.
- Dropping
100.64.0.0/10fromSpecialPrefixes: exit 1. It failsTestFetchSecurity,TestFetchPrivateRanges,TestGoFetchSecurityandTestJSPrivateRanges(100.64.0.1, 100.100.100.200, 100.127.255.255 and the mapped form are allowed). - Disabling
redirectToOptOutin coreCheckRedirect: exit 1. It failsTestFetchSecurity/RedirectToOptOuton 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) and5f00::/16aren't in the table. Neither is2001: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-Authorizationand custom auth headers aren't stripped on an origin change.- Userinfo in a redirect
Locationisn't refused. - Node pins only
addrs[0]. - Core and Node still disagree on
[::ffff:a.b.c.d].
Closes #171. Closes #172. Based on main f835767 (after #173).
Fixes (core capability client, generated Go, Node JS):
localhostgrants opt out, and the receipt recordsprivate_destination_opt_out. Multicast is always refused.process:env=refuses PYTHONPATH, NODE_OPTIONS, BASH_ENV (plus ENV, PYTHONSTARTUP, NODE_PATH, PERL5LIB/OPT, RUBYOPT/LIB), case-insensitively.Tests: a shared
internal/nettestsuite 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_relayUPSTREAM_URL, JS previously accepting127.1(now refused), NodeNODE_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.