119 lines
6.1 KiB
Markdown
119 lines
6.1 KiB
Markdown
---
|
|
phase: 06-http-routing-auth-groups-and-rate-limiting
|
|
plan: 13
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- fetchguard/ip.go
|
|
- fetchguard/fetch.go
|
|
autonomous: true
|
|
gap_closure: true
|
|
requirements: [HTTP-07]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "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"
|
|
artifacts:
|
|
- path: fetchguard/ip.go
|
|
provides: "Complete special-use prefix tables and zone-stripping classifier"
|
|
- path: fetchguard/fetch.go
|
|
provides: "Dial-time rejection of zoned addresses"
|
|
key_links:
|
|
- from: fetchguard/fetch.go
|
|
to: fetchguard/ip.go
|
|
via: "dialControl -> isReservedOrPrivate"
|
|
pattern: "isReservedOrPrivate"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Complete non-public prefix tables and zone-stripping classifier (fetchguard/ip.go)</name>
|
|
<files>fetchguard/ip.go</files>
|
|
<read_first>fetchguard/ip.go, fetchguard/ip_test.go (existing table, must stay green), 06-VERIFICATION.md truth 4</read_first>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>go vet ./fetchguard/... && go test ./fetchguard/... -count=1 -short</automated>
|
|
</verify>
|
|
<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>
|
|
<done>Classifier covers the full non-public set and is zone-insensitive.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Reject zoned addresses at the dial boundary (fetchguard/fetch.go)</name>
|
|
<files>fetchguard/fetch.go</files>
|
|
<read_first>fetchguard/fetch.go lines 145-175, fetchguard/fetch_test.go TestDialControlRejectsUnsafeIPv6Transitions</read_first>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>go vet ./fetchguard/... && go test ./fetchguard/... -count=1 -race -short</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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
|
|
</acceptance_criteria>
|
|
<done>Zoned scoped addresses fail with private_ip at dial time.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
`go vet ./... && go test ./... -count=1 -race -short` green in summercms.go.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
Both truths hold, suite green, code-only commit, no co-author tags.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-13-SUMMARY.md` when done
|
|
</output>
|