Files
summercms/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-13-PLAN.md
2026-09-21 19:30:50 +02:00

6.1 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, gap_closure, requirements, must_haves
phase plan type wave depends_on files_modified autonomous gap_closure requirements must_haves
06-http-routing-auth-groups-and-rate-limiting 13 execute 1
fetchguard/ip.go
fetchguard/fetch.go
true true
HTTP-07
truths artifacts key_links
Every IANA special-use IPv4 and IPv6 non-public range in the plan table is classified non-public, including 198.18.0.0/15, 192.0.0.0/24 and 240.0.0.0/4
Zoned IPv6 addresses (for example fe80::1%eth0) are rejected with private_ip at dial time and classify identically to their unzoned form
Public controls (8.8.8.8, 1.1.1.1, 2606:4700:4700::1111) remain allowed
Existing NAT64/6to4 embedded-IPv4 recursion still works
path provides
fetchguard/ip.go Complete special-use prefix tables and zone-stripping classifier
path provides
fetchguard/fetch.go Dial-time rejection of zoned addresses
from to via pattern
fetchguard/fetch.go fetchguard/ip.go dialControl -> isReservedOrPrivate isReservedOrPrivate
Close the HTTP-07 SSRF gap: replace the partial PHP-literal table with the full IANA special-use non-public set and stop zoned IPv6 from evading Prefix.Contains.

Purpose: the outbound fetch helper must reject every non-public destination at the actual dial boundary. Output: edits to fetchguard/ip.go and fetchguard/fetch.go. Full boundary tests are Plan 06-14; add only a small smoke test here.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@CLAUDE.md @.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-VERIFICATION.md @fetchguard/ip.go @fetchguard/fetch.go @fetchguard/ip_test.go Task 1: Complete non-public prefix tables and zone-stripping classifier (fetchguard/ip.go) fetchguard/ip.go fetchguard/ip.go, fetchguard/ip_test.go (existing table, must stay green), 06-VERIFICATION.md truth 4 Keep the existing variable names privateV4 / privateV6 and the transition handling. Extend privateV4 to the full IANA IPv4 special-purpose set: 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 (240/4 also covers 255.255.255.255). Extend privateV6 to: ::/96 (unspecified plus deprecated IPv4-compatible, includes ::1), 100::/64, 2001::/23 (IETF protocol assignments incl. Teredo and ORCHID), 2001:db8::/32, 3fff::/20, 5f00::/16, fc00::/7, fe80::/10, fec0::/10, ff00::/8. Update the doc comment: it is no longer a literal PHP port but a strict superset of ManualCoverUrlFetcher.php's lists, chosen per 06-VERIFICATION gap 3.

In isReservedOrPrivate, immediately after the IsValid check, strip any IPv6 zone with addr.WithZone("") so zone text never changes Prefix.Contains results, then Unmap as before. Keep the recursive embedded-IPv4 path (it receives the unzoned address). Keep the IsMulticast/IsUnspecified shortcuts. 2002::/16 and 64:ff9b::/96 stay handled by embeddedTransitionIPv4 (public embedded IPv4 remains allowed), so do not add them to the tables. go vet ./fetchguard/... && go test ./fetchguard/... -count=1 -short <acceptance_criteria> - isReservedOrPrivate(198.18.0.1), (192.0.0.1), (240.0.0.1), (255.255.255.255), (2001:db8::1), (fec0::1), (fe80::1%eth0) all true (smoke test) - isReservedOrPrivate(8.8.8.8), (1.1.1.1), (2606:4700:4700::1111) false - Existing TestIsReservedOrPrivate and TestIsReservedOrPrivateIPv6Transitions still pass </acceptance_criteria> Classifier covers the full non-public set and is zone-insensitive.

Task 2: Reject zoned addresses at the dial boundary (fetchguard/fetch.go) fetchguard/fetch.go fetchguard/fetch.go lines 145-175, fetchguard/fetch_test.go TestDialControlRejectsUnsafeIPv6Transitions In dialControl, after netip.ParseAddr succeeds and before Unmap/classification, if addr.Zone() != "" return errPrivateIP (a scoped literal is never a routable public destination, so it is rejected outright rather than normalized; mapTransportError then yields ReasonPrivateIP). Leave policy.skipReservedCheck handling untouched. Add a smoke test invoking dialControl with address "[fe80::1%eth0]:443" expecting errPrivateIP, and "198.18.0.1:443" expecting errPrivateIP. go vet ./fetchguard/... && go test ./fetchguard/... -count=1 -race -short - dialControl on "[fe80::1%eth0]:443" returns an error satisfying errors.Is(err, errPrivateIP) - `grep -n "Zone()" fetchguard/fetch.go` shows the check precedes isReservedOrPrivate - go vet and go test ./fetchguard/... exit 0 Zoned scoped addresses fail with private_ip at dial time.

<threat_model>

Trust Boundaries

Boundary Description
caller URL / DNS answer -> dial target untrusted destination reaches net.Dialer.Control

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-06-30 Elevation of Privilege (SSRF) fetchguard/ip.go mitigate full IANA special-use IPv4/IPv6 prefix tables; public controls stay allowed
T-06-31 Elevation of Privilege (SSRF) fetchguard/fetch.go dialControl mitigate zone stripped for classification, zoned dial targets rejected outright
</threat_model>
`go vet ./... && go test ./... -count=1 -race -short` green in summercms.go.

<success_criteria> Both truths hold, suite green, code-only commit, no co-author tags. </success_criteria>

Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-13-SUMMARY.md` when done