132 lines
9.1 KiB
Markdown
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 && go test ./fetchguard -run 'Test(IsReservedOrPrivate|.*Transition|.*NAT64|.*6to4)' -count=1 -race -short && go vet ./fetchguard && 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>
|