Files
summercms/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-08-PLAN.md
2026-09-20 16:14:21 +02:00

132 lines
9.1 KiB
Markdown

---
phase: 06-http-routing-auth-groups-and-rate-limiting
plan: 08
type: execute
wave: 6
depends_on: ["06-06"]
files_modified:
- summercms.go/fetchguard/ip.go
- summercms.go/fetchguard/ip_test.go
- summercms.go/fetchguard/fetch_test.go
autonomous: true
gap_closure: true
requirements: [HTTP-07]
must_haves:
truths:
- "NAT64 64:ff9b::/96, local-use NAT64 64:ff9b:1::/48, and 6to4 2002::/16 addresses embedding loopback, RFC1918, or 169.254.169.254 are rejected as private_ip"
- "The same three transition formats embedding a public IPv4 address remain classifiable as public rather than being blanket-rejected"
- "The dial-time control path, not only a standalone helper test, rejects unsafe transition addresses before connection"
- "Existing IPv4, IPv4-mapped IPv6, native private IPv6, CGNAT, multicast, and unspecified-address behavior remains intact"
artifacts:
- path: summercms.go/fetchguard/ip.go
provides: "IPv4 extraction/classification for the three supported IPv6 transition prefixes"
- path: summercms.go/fetchguard/ip_test.go
provides: "Transition-address tables covering embedded loopback, RFC1918, metadata, and public IPv4"
- path: summercms.go/fetchguard/fetch_test.go
provides: "dialControl regression proving transition rejection occurs at the actual connect boundary"
key_links:
- from: summercms.go/fetchguard/fetch.go
to: summercms.go/fetchguard/ip.go
via: "dialControl parses the actual dial address and invokes isReservedOrPrivate after Unmap"
pattern: "isReservedOrPrivate\(addr\)"
---
<objective>
Close the transition-address SSRF bypass by decoding embedded IPv4 from both NAT64 prefixes and 6to4 before the dial-time allow decision.
Purpose: HTTP-07 treats fetchguard as a security boundary; an environment-dependent path through an IPv6 translator to loopback, RFC1918, or cloud metadata must be rejected exactly like the plain IPv4 target.
Output: transition-aware classification plus table-driven helper and dial-control security regressions.
</objective>
<execution_context>
@/home/jin/.codex/get-shit-done/workflows/execute-plan.md
@/home/jin/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md
@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-RESEARCH.md
@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-VERIFICATION.md
@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-REVIEW.md
@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-04-SUMMARY.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Decode and reclassify embedded IPv4 at the dial-time SSRF boundary</name>
<files>summercms.go/fetchguard/ip.go, summercms.go/fetchguard/ip_test.go, summercms.go/fetchguard/fetch_test.go</files>
<behavior>
- `64:ff9b::7f00:1`, `64:ff9b::a00:1`, and `64:ff9b::a9fe:a9fe` classify private; `64:ff9b::808:808` classifies public.
- RFC 6052 /48 encodings under `64:ff9b:1::/48` of 127.0.0.1, 10.0.0.1, and 169.254.169.254 classify private; the encoding of 8.8.8.8 classifies public.
- `2002:7f00:1::`, `2002:a00:1::`, and `2002:a9fe:a9fe::` classify private; `2002:808:808::` classifies public.
- Calling the production dialControl callback for every unsafe transition literal returns an error mapped to ReasonPrivateIP before using the RawConn.
</behavior>
<read_first>
summercms.go/fetchguard/ip.go (existing privateV4/privateV6 tables and classifier)
summercms.go/fetchguard/ip_test.go (existing boundary cases, including IPv4-mapped Unmap behavior)
summercms.go/fetchguard/fetch.go (dialControl and mapTransportError; the production connection-time link)
summercms.go/fetchguard/fetch_test.go (existing real-network private-IP and reason assertions)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md (D-11 through D-14)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-VERIFICATION.md (second authoritative gap)
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-REVIEW.md (CR-03 transition examples)
RFC 6052 section 2.2 (for /96 and /48 extraction: /48 uses bits 48-63 plus 72-87 and skips the zero u octet at bits 64-71)
RFC 3056 section 2 (6to4 embeds IPv4 in bits 16-47)
</read_first>
<action>
In `ip.go`, normalize with `addr.Unmap()` inside the classifier so direct and dial-time callers cannot forget mapped-IPv4 normalization. Add package-level prefixes for `64:ff9b::/96`, `64:ff9b:1::/48`, and `2002::/16`, then an unexported extraction helper returning `(netip.Addr, bool)` for only these formats. Use `addr.As16()` and exact byte positions: the /96 NAT64 IPv4 is bytes 12-15; the RFC 6052 /48 local-use NAT64 IPv4 is bytes 6-7 followed by bytes 9-10 (byte 8 is the required zero `u` octet); 6to4 IPv4 is bytes 2-5. If a `64:ff9b:1::/48` address has a non-zero u octet, fail closed as reserved rather than treating it as native public IPv6.
Before returning public for a native IPv6 address, if the helper recognizes one of the three transition prefixes, pass the extracted IPv4 back through the ordinary IPv4 private/reserved/CGNAT/metadata classification. Reject only when the embedded address is unsafe (or the form is malformed); keep a correctly encoded public IPv4 result public. Preserve the existing native IPv6 prefix table and multicast/unspecified handling.
Add table-driven tests for all four categories (loopback, RFC1918, metadata link-local, public) under each of the three formats. Use exact /48 literals following RFC 6052, including `64:ff9b:1:7f00:0:100::` for 127.0.0.1, `64:ff9b:1:a00:0:100::` for 10.0.0.1, `64:ff9b:1:a9fe:a9:fe00::` for 169.254.169.254, and `64:ff9b:1:808:8:800::` for 8.8.8.8. Add a malformed non-zero-u /48 case and assert fail-closed.
In `fetch_test.go`, call the real `dialControl(Policy{Mode: PublicOnlyMode})` callback with bracketed IPv6 host:443 addresses and nil RawConn (the callback classifies before touching RawConn). Cover at least one loopback, one RFC1918, and the metadata address in every transition prefix; assert `errors.Is(err, errPrivateIP)` and `mapTransportError(err).Reason == ReasonPrivateIP`. This test must exercise production dialControl, not only `isReservedOrPrivate`.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go &amp;&amp; go test ./fetchguard -run 'Test(IsReservedOrPrivate|.*Transition|.*NAT64|.*6to4)' -count=1 -race -short &amp;&amp; go vet ./fetchguard &amp;&amp; go test ./fetchguard -count=1 -race -short</automated>
</verify>
<acceptance_criteria>
- Tests cover embedded 127.0.0.1, 10.0.0.1, and 169.254.169.254 for `64:ff9b::/96`, `64:ff9b:1::/48`, and `2002::/16`.
- Tests cover an embedded public 8.8.8.8 in all three formats and assert it is not classified private.
- The /48 decoder skips exactly the RFC 6052 u octet and a non-zero u octet fails closed.
- A dialControl-level table proves every unsafe transition address returns the private-IP sentinel and maps to `ReasonPrivateIP` before connection.
- Existing plain IPv4, mapped IPv4, native IPv6, CGNAT, metadata, multicast, and unspecified tests remain present and green under `-race`.
</acceptance_criteria>
<done>All three supported IPv6 transition formats receive the same private/reserved IPv4 policy at dial time, while public embedded IPv4 remains allowed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| DNS result -> TCP dial address | An attacker-controlled hostname can resolve to an IPv6 transition address whose embedded IPv4 targets private infrastructure |
| IPv6 syntax -> IPv4 policy | NAT64/6to4 representation must not bypass the ordinary loopback, RFC1918, link-local metadata, or CGNAT table |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-25 | Elevation of Privilege / Information Disclosure | `fetchguard.isReservedOrPrivate` and `dialControl` | mitigate | Decode RFC 6052 /96 and /48 plus 6to4 embedded IPv4, reapply the private table, fail closed on malformed local-use NAT64, and prove the production dial hook rejects unsafe cases |
| T-06-SC | Tampering | package supply chain | accept | No dependencies or manifests change; implementation uses `net/netip` and existing fetchguard code |
</threat_model>
<verification>
Run transition-specific tables and the full fetchguard package under the race detector. Confirm the tests distinguish unsafe embedded IPv4 from public 8.8.8.8 for each supported transition prefix and include dialControl-level evidence.
</verification>
<success_criteria>
- NAT64 and 6to4 cannot encode loopback, RFC1918, or metadata IPv4 past fetchguard.
- The rejection is enforced at actual dial-address classification.
- Correct public embeddings are not blanket-blocked.
- Existing SSRF, redirect, timeout, and byte-cap behavior remains green.
</success_criteria>
<output>
Create `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-08-SUMMARY.md` when done.
</output>