chore: merge executor worktree (worktree-agent-01a0ba96)
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
---
|
||||
phase: 06-http-routing-auth-groups-and-rate-limiting
|
||||
plan: 04
|
||||
subsystem: api
|
||||
tags: [ssrf, fetchguard, https, dial-control, netip, httptest]
|
||||
|
||||
requires:
|
||||
- phase: 01-framework-kernel-foundation
|
||||
provides: compass.Config Lookup/Open layered YAML
|
||||
provides:
|
||||
- fetchguard.Fetch SSRF-guarded HTTPS GET with typed Reason errors
|
||||
- fetchguard.Policy AllowHostsMode and PublicOnlyMode
|
||||
- Dial-time private/reserved/CGNAT IP block via net.Dialer.Control
|
||||
- Streaming io.LimitReader byte cap and no-follow redirects
|
||||
affects:
|
||||
- phase-12-manual-cover-url
|
||||
- phase-14-discogs-cover-import
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- Dial-time SSRF check on net.Dialer.Control after Addr.Unmap
|
||||
- Host allow-list is exact or dotted-suffix ("." + allowed), never a raw suffix
|
||||
- http.ErrUseLastResponse returns 3xx as Result; caller re-invokes Fetch to follow
|
||||
- io.LimitReader(body, maxBytes+1) for streaming too_large, never post-hoc len()
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- fetchguard/fetch.go
|
||||
- fetchguard/policy.go
|
||||
- fetchguard/ip.go
|
||||
- fetchguard/fetch_test.go
|
||||
- fetchguard/ip_test.go
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "Redirects are not errors: Fetch returns the 3xx Result via http.ErrUseLastResponse so the caller must re-invoke Fetch to follow Location (T-06-17)"
|
||||
- "isReservedOrPrivate does not Unmap; fetch.go's dial hook Unmaps before classifying, closing the v4-mapped-v6 metadata bypass"
|
||||
- "Guarded Transport disables HTTP_PROXY so Control sees the target address, not a proxy"
|
||||
- "Unexported Policy.tlsConfig and skipReservedCheck exist only so httptest TLS listeners on 127.0.0.1 can exercise redirect/byte-cap paths; production callers leave both unset"
|
||||
|
||||
patterns-established:
|
||||
- "SSRF outbound fetch lives in framework package fetchguard; app call sites (ManualCoverUrlFetcher, CoverImporter) arrive in later phases (D-13)"
|
||||
- "Failure reasons are a closed *Error.Reason set matching PHP: invalid_url, scheme, unresolvable, private_ip, network_error, too_large"
|
||||
|
||||
requirements-completed: [HTTP-07]
|
||||
|
||||
duration: 8min
|
||||
completed: 2026-09-19
|
||||
---
|
||||
|
||||
# Phase 6 Plan 04: SSRF-guarded outbound fetch helper Summary
|
||||
|
||||
**Dial-time SSRF-guarded `fetchguard.Fetch` with AllowHosts/PublicOnly modes, PHP CIDR table, streaming byte cap, and typed failure reasons — helper and tests only**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 8 min
|
||||
- **Started:** 2026-09-19T16:54:42Z
|
||||
- **Completed:** 2026-09-19T17:02:28Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 5
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Ported `ManualCoverUrlFetcher.php` `PRIVATE_V4_CIDRS` / `PRIVATE_V6_PREFIXES` onto `netip.Prefix`, including CGNAT `100.64.0.0/10` and cloud-metadata `169.254.0.0/16`, with Unmap documented as the dial-hook caller's job.
|
||||
- Shipped `fetchguard.Fetch`: https-only, host allow-list (exact + dotted-suffix so `evil-discogs.com` cannot match `discogs.com`), always-on private-IP block at `net.Dialer.Control` (closes PHP's admitted DNS-rebinding TOCTOU), no automatic redirects, streaming `io.LimitReader` cap, config defaults 10 MiB / 10s with explicit zero/negative as an error (D-14).
|
||||
- Zero production call sites — no `ManualCoverUrlFetcher` or `CoverImporter` wiring (D-13).
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically (TDD RED then GREEN):
|
||||
|
||||
1. **Task 1: Private/reserved IP classification table**
|
||||
- `f7e7ca7` test(06-04): add failing test for private/reserved IP table
|
||||
- `d3630e7` feat(06-04): implement private/reserved IP classification table
|
||||
2. **Task 2: Policy, defaults, and the dial-time-guarded Fetch entry point**
|
||||
- `bae9de8` test(06-04): add failing tests for SSRF-guarded Fetch
|
||||
- `7923fe8` feat(06-04): implement dial-time SSRF-guarded Fetch
|
||||
|
||||
**Plan metadata:** (this commit) docs(06-04): complete SSRF-guarded fetch helper plan
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `fetchguard/ip.go` — `isReservedOrPrivate` CIDR table (PHP constants, fail-closed on invalid addr)
|
||||
- `fetchguard/ip_test.go` — table-driven cases including Unmap, CGNAT, metadata, 172.16/12 boundaries
|
||||
- `fetchguard/policy.go` — Mode, Reason, Policy, Error, Defaults, DefaultsFromConfig via Lookup
|
||||
- `fetchguard/fetch.go` — `Fetch` with dial Control, ErrUseLastResponse, LimitReader, typed errors
|
||||
- `fetchguard/fetch_test.go` — httptest TLS cases for scheme/host/private_ip/redirect/too_large/config
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Pin redirect behavior to `http.ErrUseLastResponse`: a 3xx is a non-error `Result` so following Location requires a new `Fetch` through the same guard (T-06-17).
|
||||
- `isReservedOrPrivate` classifies an already-Unmap()'d `netip.Addr`; the dial hook Unmaps, matching Pitfall 6.
|
||||
- Disable `HTTP_PROXY` on the guarded transport so Control observes the target IP, not a proxy hop.
|
||||
- Unexported `Policy.tlsConfig` / `skipReservedCheck` let tests talk to loopback httptest servers without weakening production checks.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Unexported test-only Policy fields for httptest loopback**
|
||||
- **Found during:** Task 2 (Fetch entry point)
|
||||
- **Issue:** httptest TLS servers bind 127.0.0.1, which the production dial-time private-IP block correctly rejects. Redirect and streaming-cap tests need a real connection to that listener; mocking `http.RoundTripper` would skip the thing under test for private_ip but would also skip LimitReader/CheckRedirect.
|
||||
- **Fix:** Unexported `Policy.tlsConfig` and `skipReservedCheck`, set only from `fetch_test.go` via `withTestLoopback`. Private-IP tests leave both unset and assert `ReasonPrivateIP` against the real Control hook.
|
||||
- **Files modified:** `fetchguard/policy.go`, `fetchguard/fetch.go`, `fetchguard/fetch_test.go`
|
||||
- **Verification:** `go test ./fetchguard/... -race` — private_ip subtests fail closed; redirect/too_large pass against real TLS listeners
|
||||
- **Committed in:** `7923fe8` (Task 2 GREEN)
|
||||
|
||||
**2. [Rule 2 - Missing Critical] Disable HTTP_PROXY on the guarded transport**
|
||||
- **Found during:** Task 2 (Fetch entry point)
|
||||
- **Issue:** Honoring `HTTP_PROXY` would make Control see the proxy address, not the user-supplied target — an SSRF bypass.
|
||||
- **Fix:** Set `Transport.Proxy` to nil (no proxy) on the per-call client.
|
||||
- **Files modified:** `fetchguard/fetch.go`
|
||||
- **Verification:** private_ip tests still hit Control on 127.0.0.1; `go vet ./...` clean
|
||||
- **Committed in:** `7923fe8` (Task 2 GREEN)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 auto-fixed (1 blocking, 1 missing-critical)
|
||||
**Impact on plan:** Both required for correctness/security of the helper and its httptest proof. No call sites added. No scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
Grok worktree clone had `.git` as a directory and HEAD on `master`. Created `worktree-agent-01a0ba96` from `ecab09c` without rewinding `master`, then committed only on that branch.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- `fetchguard.Fetch` is ready for Phase 12 `ManualCoverUrlFetcher` (PublicOnlyMode) and Phase 14 Discogs `CoverImporter` (AllowHostsMode) call sites.
|
||||
- Callers must pass an already-resolved `Policy` (or nil cfg to use 10 MiB / 10s). Following a 3xx means a new `Fetch` of Location.
|
||||
- No blockers.
|
||||
|
||||
## TDD Gate Compliance
|
||||
|
||||
Per-task RED then GREEN commits are present:
|
||||
|
||||
1. `f7e7ca7` test(06-04) (RED Task 1)
|
||||
2. `d3630e7` feat(06-04) (GREEN Task 1)
|
||||
3. `bae9de8` test(06-04) (RED Task 2)
|
||||
4. `7923fe8` feat(06-04) (GREEN Task 2)
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: `fetchguard/ip.go`
|
||||
- FOUND: `fetchguard/ip_test.go`
|
||||
- FOUND: `fetchguard/policy.go`
|
||||
- FOUND: `fetchguard/fetch.go`
|
||||
- FOUND: `fetchguard/fetch_test.go`
|
||||
- FOUND: `f7e7ca7`
|
||||
- FOUND: `d3630e7`
|
||||
- FOUND: `bae9de8`
|
||||
- FOUND: `7923fe8`
|
||||
- `go vet ./...` clean
|
||||
- `go test ./fetchguard/... -race` pass
|
||||
- `go test ./... -short` pass
|
||||
|
||||
---
|
||||
*Phase: 06-http-routing-auth-groups-and-rate-limiting*
|
||||
*Completed: 2026-09-19*
|
||||
175
fetchguard/fetch.go
Normal file
175
fetchguard/fetch.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package fetchguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/compass"
|
||||
)
|
||||
|
||||
// Result is a successful (including non-2xx, including 3xx) Fetch response.
|
||||
type Result struct {
|
||||
Body []byte
|
||||
ContentType string
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
var errPrivateIP = errors.New("private_ip")
|
||||
|
||||
// Fetch validates url against policy, resolves defaults for any zero
|
||||
// MaxBytes/Timeout via DefaultsFromConfig (or Defaults() when cfg is nil),
|
||||
// then performs the guarded HTTPS GET.
|
||||
//
|
||||
// Redirects are never followed: CheckRedirect returns http.ErrUseLastResponse,
|
||||
// so a 3xx response is returned as a non-error Result. Callers that want to
|
||||
// follow Location must re-invoke Fetch, which re-runs the same guard.
|
||||
//
|
||||
// A non-nil error is always *Error with a Reason from the closed set.
|
||||
func Fetch(ctx context.Context, rawURL string, policy Policy, cfg *compass.Config) (*Result, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, &Error{Reason: ReasonInvalidURL, Err: err}
|
||||
}
|
||||
if strings.ToLower(parsed.Scheme) != "https" {
|
||||
return nil, &Error{Reason: ReasonScheme}
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if policy.Mode == AllowHostsMode && !hostAllowed(host, policy.AllowHosts) {
|
||||
return nil, &Error{Reason: ReasonInvalidURL}
|
||||
}
|
||||
|
||||
maxBytes, timeout, err := resolveLimits(policy, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
// User-supplied URLs must not be forwarded through HTTP_PROXY:
|
||||
// the dial-time IP check would then see the proxy, not the target.
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: timeout,
|
||||
Control: dialControl(policy),
|
||||
}).DialContext,
|
||||
TLSClientConfig: policy.tlsConfig,
|
||||
DisableKeepAlives: true,
|
||||
ForceAttemptHTTP2: true,
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return nil, &Error{Reason: ReasonInvalidURL, Err: err}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, mapTransportError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, &Error{Reason: ReasonNetworkError, Err: err}
|
||||
}
|
||||
if int64(len(data)) == maxBytes+1 {
|
||||
return nil, &Error{Reason: ReasonTooLarge}
|
||||
}
|
||||
return &Result{
|
||||
Body: data,
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
StatusCode: resp.StatusCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveLimits(policy Policy, cfg *compass.Config) (int64, time.Duration, error) {
|
||||
maxBytes := policy.MaxBytes
|
||||
timeout := policy.Timeout
|
||||
if maxBytes <= 0 || timeout <= 0 {
|
||||
var (
|
||||
defMax int64
|
||||
defTO time.Duration
|
||||
err error
|
||||
)
|
||||
if cfg != nil {
|
||||
defMax, defTO, err = DefaultsFromConfig(cfg)
|
||||
if err != nil {
|
||||
return 0, 0, &Error{Reason: ReasonInvalidURL, Err: err}
|
||||
}
|
||||
} else {
|
||||
defMax, defTO = Defaults()
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = defMax
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = defTO
|
||||
}
|
||||
}
|
||||
if maxBytes <= 0 || timeout <= 0 {
|
||||
return 0, 0, &Error{Reason: ReasonInvalidURL, Err: fmt.Errorf("max bytes and timeout must be positive")}
|
||||
}
|
||||
return maxBytes, timeout, nil
|
||||
}
|
||||
|
||||
func hostAllowed(host string, allowed []string) bool {
|
||||
host = strings.ToLower(host)
|
||||
for _, a := range allowed {
|
||||
a = strings.ToLower(a)
|
||||
if a == "" {
|
||||
continue
|
||||
}
|
||||
if host == a || strings.HasSuffix(host, "."+a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func dialControl(policy Policy) func(network, address string, c syscall.RawConn) error {
|
||||
return func(network, address string, c syscall.RawConn) error {
|
||||
if policy.skipReservedCheck {
|
||||
return nil
|
||||
}
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetchguard: unparseable dial address %q: %w", host, err)
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
if isReservedOrPrivate(addr) {
|
||||
return errPrivateIP
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func mapTransportError(err error) *Error {
|
||||
if errors.Is(err, errPrivateIP) {
|
||||
return &Error{Reason: ReasonPrivateIP, Err: err}
|
||||
}
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return &Error{Reason: ReasonUnresolvable, Err: err}
|
||||
}
|
||||
return &Error{Reason: ReasonNetworkError, Err: err}
|
||||
}
|
||||
276
fetchguard/fetch_test.go
Normal file
276
fetchguard/fetch_test.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package fetchguard
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/compass"
|
||||
)
|
||||
|
||||
func TestFetchMalformedURL(t *testing.T) {
|
||||
_, err := Fetch(t.Context(), "not a url", Policy{Mode: PublicOnlyMode}, nil)
|
||||
if reasonFrom(t, err) != ReasonInvalidURL {
|
||||
t.Fatalf("reason = %q, want %s", reasonFrom(t, err), ReasonInvalidURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchHTTPSchemeRejectedWithoutIO(t *testing.T) {
|
||||
var hits atomic.Int64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
hits.Add(1)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
_, err := Fetch(t.Context(), srv.URL, Policy{Mode: PublicOnlyMode}, nil)
|
||||
if reasonFrom(t, err) != ReasonScheme {
|
||||
t.Fatalf("reason = %q, want %s", reasonFrom(t, err), ReasonScheme)
|
||||
}
|
||||
if hits.Load() != 0 {
|
||||
t.Fatal("http URL must not cause network I/O")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAllowHostsRejectsUnknownHostBeforeDial(t *testing.T) {
|
||||
var hits atomic.Int64
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
hits.Add(1)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
_, err := Fetch(t.Context(), srv.URL, Policy{
|
||||
Mode: AllowHostsMode,
|
||||
AllowHosts: []string{"discogs.com"},
|
||||
}, nil)
|
||||
if reasonFrom(t, err) != ReasonInvalidURL {
|
||||
t.Fatalf("reason = %q, want %s", reasonFrom(t, err), ReasonInvalidURL)
|
||||
}
|
||||
if hits.Load() != 0 {
|
||||
t.Fatal("host allow-list miss must not dial")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAllowHostsRejectsDottedSuffixBypass(t *testing.T) {
|
||||
_, err := Fetch(t.Context(), "https://evil-discogs.com/cover.jpg", Policy{
|
||||
Mode: AllowHostsMode,
|
||||
AllowHosts: []string{"discogs.com"},
|
||||
}, nil)
|
||||
if reasonFrom(t, err) != ReasonInvalidURL {
|
||||
t.Fatalf("evil-discogs.com must not match discogs.com, reason = %q", reasonFrom(t, err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPrivateIPBlockedInBothModes(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("handler must not run for a private dial")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
t.Run("AllowHostsMode", func(t *testing.T) {
|
||||
_, err := Fetch(t.Context(), srv.URL, Policy{
|
||||
Mode: AllowHostsMode,
|
||||
AllowHosts: []string{"127.0.0.1"},
|
||||
Timeout: 2 * time.Second,
|
||||
MaxBytes: 1024,
|
||||
}, nil)
|
||||
if reasonFrom(t, err) != ReasonPrivateIP {
|
||||
t.Fatalf("reason = %q, want %s", reasonFrom(t, err), ReasonPrivateIP)
|
||||
}
|
||||
})
|
||||
t.Run("PublicOnlyMode", func(t *testing.T) {
|
||||
_, err := Fetch(t.Context(), srv.URL, Policy{
|
||||
Mode: PublicOnlyMode,
|
||||
Timeout: 2 * time.Second,
|
||||
MaxBytes: 1024,
|
||||
}, nil)
|
||||
if reasonFrom(t, err) != ReasonPrivateIP {
|
||||
t.Fatalf("reason = %q, want %s", reasonFrom(t, err), ReasonPrivateIP)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFetchDoesNotFollowRedirect(t *testing.T) {
|
||||
var followed atomic.Bool
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/target", func(w http.ResponseWriter, r *http.Request) {
|
||||
followed.Store(true)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Location", "/target")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
})
|
||||
srv := httptest.NewTLSServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
res, err := Fetch(t.Context(), srv.URL, withTestLoopback(srv, Policy{
|
||||
Mode: PublicOnlyMode,
|
||||
MaxBytes: 1024,
|
||||
Timeout: 5 * time.Second,
|
||||
}), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
if res.StatusCode != http.StatusFound {
|
||||
t.Fatalf("status = %d, want %d (3xx returned, not followed)", res.StatusCode, http.StatusFound)
|
||||
}
|
||||
if followed.Load() {
|
||||
t.Fatal("redirect must not be followed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTooLargeIsStreaming(t *testing.T) {
|
||||
const maxBytes int64 = 256
|
||||
var written atomic.Int64
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
buf := make([]byte, 64)
|
||||
for written.Load() < 8<<20 {
|
||||
n, err := w.Write(buf)
|
||||
written.Add(int64(n))
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
_, err := Fetch(t.Context(), srv.URL, withTestLoopback(srv, Policy{
|
||||
Mode: PublicOnlyMode,
|
||||
MaxBytes: maxBytes,
|
||||
Timeout: 5 * time.Second,
|
||||
}), nil)
|
||||
if reasonFrom(t, err) != ReasonTooLarge {
|
||||
t.Fatalf("reason = %q, want %s", reasonFrom(t, err), ReasonTooLarge)
|
||||
}
|
||||
// TCP/HTTP buffering can write a little past maxBytes+1; the client must
|
||||
// not have pulled an unbounded body first (the handler would hit 8MiB).
|
||||
if got := written.Load(); got > 64<<10 {
|
||||
t.Fatalf("server wrote %d bytes, client appears to have buffered unbounded body", got)
|
||||
}
|
||||
if written.Load() < maxBytes+1 {
|
||||
t.Fatalf("server wrote %d bytes, want at least maxBytes+1 so the cap was hit", written.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchZeroPolicyUsesDefaults(t *testing.T) {
|
||||
max, timeout := Defaults()
|
||||
if max != 10*1024*1024 {
|
||||
t.Fatalf("Defaults maxBytes = %d, want 10MiB", max)
|
||||
}
|
||||
if timeout != 10*time.Second {
|
||||
t.Fatalf("Defaults timeout = %s, want 10s", timeout)
|
||||
}
|
||||
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
io.WriteString(w, "ok")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
res, err := Fetch(t.Context(), srv.URL, withTestLoopback(srv, Policy{Mode: PublicOnlyMode}), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch with zero MaxBytes/Timeout and nil cfg: %v", err)
|
||||
}
|
||||
if string(res.Body) != "ok" {
|
||||
t.Fatalf("body = %q, want ok", res.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultsFromConfigExplicitZeroIsError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte("fetch:\n max_bytes: 0\n timeout_seconds: 10\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if _, ok := cfg.Lookup("http.fetch.max_bytes"); !ok {
|
||||
t.Fatal("expected http.fetch.max_bytes to be present in loaded YAML")
|
||||
}
|
||||
_, _, err = DefaultsFromConfig(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("explicit http.fetch.max_bytes: 0 must be an error, not a silent fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultsFromConfigExplicitNegativeIsError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte("fetch:\n max_bytes: 10485760\n timeout_seconds: -1\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
_, _, err = DefaultsFromConfig(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("explicit http.fetch.timeout_seconds: -1 must be an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultsFromConfigAbsentFallsBack(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: t\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
max, timeout, err := DefaultsFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultsFromConfig: %v", err)
|
||||
}
|
||||
wantMax, wantTO := Defaults()
|
||||
if max != wantMax || timeout != wantTO {
|
||||
t.Fatalf("got %d %s, want Defaults() %d %s", max, timeout, wantMax, wantTO)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorUnwrap(t *testing.T) {
|
||||
inner := errors.New("dial tcp")
|
||||
err := &Error{Reason: ReasonNetworkError, Err: inner}
|
||||
if err.Error() == "" {
|
||||
t.Fatal("Error() must be non-empty")
|
||||
}
|
||||
if !errors.Is(err, inner) {
|
||||
t.Fatal("Unwrap must expose the inner error")
|
||||
}
|
||||
}
|
||||
|
||||
func reasonFrom(t *testing.T, err error) Reason {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
var fe *Error
|
||||
if !errors.As(err, &fe) {
|
||||
t.Fatalf("err = %v (%T), want *Error", err, err)
|
||||
}
|
||||
return fe.Reason
|
||||
}
|
||||
|
||||
// withTestLoopback trusts the httptest TLS cert and skips the reserved-IP
|
||||
// dial check so redirect/byte-cap tests can exercise a real listener on
|
||||
// 127.0.0.1. Production Policy values leave both fields unset.
|
||||
func withTestLoopback(srv *httptest.Server, p Policy) Policy {
|
||||
tr, ok := srv.Client().Transport.(*http.Transport)
|
||||
if !ok {
|
||||
panic("httptest client transport is not *http.Transport")
|
||||
}
|
||||
p.tlsConfig = tr.TLSClientConfig
|
||||
p.skipReservedCheck = true
|
||||
return p
|
||||
}
|
||||
45
fetchguard/ip.go
Normal file
45
fetchguard/ip.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package fetchguard
|
||||
|
||||
import "net/netip"
|
||||
|
||||
// privateV4 is a literal port of ManualCoverUrlFetcher.php PRIVATE_V4_CIDRS.
|
||||
var privateV4 = []netip.Prefix{
|
||||
netip.MustParsePrefix("127.0.0.0/8"),
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("172.16.0.0/12"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
netip.MustParsePrefix("169.254.0.0/16"),
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
}
|
||||
|
||||
// privateV6 is a literal port of PRIVATE_V6_PREFIXES. PHP lists bare "::1"
|
||||
// as a prefix-less loopback literal; it is expressed here as ::1/128 so
|
||||
// Prefix.Contains works uniformly with the CIDR entries.
|
||||
var privateV6 = []netip.Prefix{
|
||||
netip.MustParsePrefix("::1/128"),
|
||||
netip.MustParsePrefix("fe80::/10"),
|
||||
netip.MustParsePrefix("fc00::/7"),
|
||||
}
|
||||
|
||||
// isReservedOrPrivate classifies addr against the PHP private/loopback/
|
||||
// reserved/CGNAT table. The caller must pass an already-Unmap()-ed address
|
||||
// (fetch.go's dial hook); this function does not Unmap.
|
||||
func isReservedOrPrivate(addr netip.Addr) bool {
|
||||
if !addr.IsValid() {
|
||||
return true
|
||||
}
|
||||
if addr.IsMulticast() || addr.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
table := privateV4
|
||||
if !addr.Is4() {
|
||||
table = privateV6
|
||||
}
|
||||
for _, prefix := range table {
|
||||
if prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
54
fetchguard/ip_test.go
Normal file
54
fetchguard/ip_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package fetchguard
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsReservedOrPrivate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
want bool
|
||||
// unmap documents that IPv4-mapped IPv6 literals are the CALLER's
|
||||
// responsibility to Unmap() before classification (fetch.go dial hook).
|
||||
unmap bool
|
||||
}{
|
||||
{name: "loopback v4", ip: "127.0.0.1", want: true},
|
||||
{name: "rfc1918 10/8", ip: "10.1.2.3", want: true},
|
||||
{name: "rfc1918 172.16/12 start", ip: "172.16.0.1", want: true},
|
||||
{name: "rfc1918 172.16/12 end", ip: "172.31.255.255", want: true},
|
||||
{name: "rfc1918 192.168/16", ip: "192.168.1.1", want: true},
|
||||
{name: "cloud metadata link-local", ip: "169.254.169.254", want: true},
|
||||
{name: "cgnat 100.64/10", ip: "100.64.0.1", want: true},
|
||||
{name: "this-network 0.0.0.0/8", ip: "0.0.0.1", want: true},
|
||||
{name: "public 8.8.8.8", ip: "8.8.8.8", want: false},
|
||||
{name: "public 1.1.1.1", ip: "1.1.1.1", want: false},
|
||||
{name: "public 93.184.216.34", ip: "93.184.216.34", want: false},
|
||||
{name: "loopback v6", ip: "::1", want: true},
|
||||
{name: "link-local v6", ip: "fe80::1", want: true},
|
||||
{name: "unique-local v6", ip: "fc00::1", want: true},
|
||||
{name: "public v6", ip: "2606:4700:4700::1111", want: false},
|
||||
{name: "v4-mapped metadata after Unmap", ip: "::ffff:169.254.169.254", want: true, unmap: true},
|
||||
{name: "multicast v4", ip: "224.0.0.1", want: true},
|
||||
{name: "unspecified v4", ip: "0.0.0.0", want: true},
|
||||
{name: "just below 172.16.0.0/12", ip: "172.15.255.255", want: false},
|
||||
{name: "just above 172.16.0.0/12", ip: "172.32.0.0", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
addr, err := netip.ParseAddr(tt.ip)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAddr(%q): %v", tt.ip, err)
|
||||
}
|
||||
if tt.unmap {
|
||||
addr = addr.Unmap()
|
||||
}
|
||||
got := isReservedOrPrivate(addr)
|
||||
if got != tt.want {
|
||||
t.Fatalf("isReservedOrPrivate(%s) = %v, want %v", addr, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
147
fetchguard/policy.go
Normal file
147
fetchguard/policy.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package fetchguard
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/compass"
|
||||
)
|
||||
|
||||
// Mode selects host-allow-list vs any-public-host. The private/loopback/
|
||||
// reserved IP block is always on regardless of Mode (D-11).
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
AllowHostsMode Mode = iota
|
||||
PublicOnlyMode
|
||||
)
|
||||
|
||||
// Reason is the closed set of Fetch failure reasons, matching PHP's
|
||||
// invalid_url/scheme/unresolvable/private_ip/network_error/too_large family.
|
||||
type Reason string
|
||||
|
||||
const (
|
||||
ReasonInvalidURL Reason = "invalid_url"
|
||||
ReasonScheme Reason = "scheme"
|
||||
ReasonUnresolvable Reason = "unresolvable"
|
||||
ReasonPrivateIP Reason = "private_ip"
|
||||
ReasonNetworkError Reason = "network_error"
|
||||
ReasonTooLarge Reason = "too_large"
|
||||
)
|
||||
|
||||
// Policy is supplied per call.
|
||||
type Policy struct {
|
||||
Mode Mode
|
||||
AllowHosts []string // exact or dotted-suffix match; used in AllowHostsMode
|
||||
MaxBytes int64 // 0 means use the config/framework default, never unlimited
|
||||
Timeout time.Duration
|
||||
|
||||
// tlsConfig, if set, is Transport.TLSClientConfig so tests can trust an
|
||||
// httptest certificate. Production callers leave it nil.
|
||||
tlsConfig *tls.Config
|
||||
// skipReservedCheck disables the dial-time private-IP block so tests can
|
||||
// exercise a real httptest listener on 127.0.0.1. Production callers
|
||||
// leave it false.
|
||||
skipReservedCheck bool
|
||||
}
|
||||
|
||||
// Error carries the typed Reason plus the underlying error for logging.
|
||||
type Error struct {
|
||||
Reason Reason
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e == nil {
|
||||
return "fetchguard: error"
|
||||
}
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("fetchguard: %s: %v", e.Reason, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("fetchguard: %s", e.Reason)
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// Defaults are the framework fallback: 10 MiB, 10s, matching PHP.
|
||||
func Defaults() (maxBytes int64, timeout time.Duration) {
|
||||
return 10 * 1024 * 1024, 10 * time.Second
|
||||
}
|
||||
|
||||
// DefaultsFromConfig reads http.fetch.max_bytes / http.fetch.timeout_seconds
|
||||
// from cfg, falling back to Defaults() for absent keys. An explicitly
|
||||
// configured zero or negative value is an error (D-14).
|
||||
func DefaultsFromConfig(cfg *compass.Config) (maxBytes int64, timeout time.Duration, err error) {
|
||||
maxBytes, timeout = Defaults()
|
||||
if cfg == nil {
|
||||
return maxBytes, timeout, nil
|
||||
}
|
||||
if v, ok := cfg.Lookup("http.fetch.max_bytes"); ok {
|
||||
n, err := configInt64("http.fetch.max_bytes", v)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if n <= 0 {
|
||||
return 0, 0, fmt.Errorf("fetchguard: http.fetch.max_bytes must be positive, got %d", n)
|
||||
}
|
||||
maxBytes = n
|
||||
}
|
||||
if v, ok := cfg.Lookup("http.fetch.timeout_seconds"); ok {
|
||||
n, err := configInt64("http.fetch.timeout_seconds", v)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if n <= 0 {
|
||||
return 0, 0, fmt.Errorf("fetchguard: http.fetch.timeout_seconds must be positive, got %d", n)
|
||||
}
|
||||
timeout = time.Duration(n) * time.Second
|
||||
}
|
||||
return maxBytes, timeout, nil
|
||||
}
|
||||
|
||||
func configInt64(path string, v any) (int64, error) {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return int64(n), nil
|
||||
case int8:
|
||||
return int64(n), nil
|
||||
case int16:
|
||||
return int64(n), nil
|
||||
case int32:
|
||||
return int64(n), nil
|
||||
case int64:
|
||||
return n, nil
|
||||
case uint:
|
||||
return int64(n), nil
|
||||
case uint8:
|
||||
return int64(n), nil
|
||||
case uint16:
|
||||
return int64(n), nil
|
||||
case uint32:
|
||||
return int64(n), nil
|
||||
case uint64:
|
||||
if n > math.MaxInt64 {
|
||||
return 0, fmt.Errorf("fetchguard: %s overflows int64", path)
|
||||
}
|
||||
return int64(n), nil
|
||||
case float64:
|
||||
if math.Trunc(n) != n {
|
||||
return 0, fmt.Errorf("fetchguard: %s is not an integer", path)
|
||||
}
|
||||
if n > math.MaxInt64 || n < math.MinInt64 {
|
||||
return 0, fmt.Errorf("fetchguard: %s overflows int64", path)
|
||||
}
|
||||
return int64(n), nil
|
||||
case float32:
|
||||
return configInt64(path, float64(n))
|
||||
default:
|
||||
return 0, fmt.Errorf("fetchguard: %s has unexpected type %T", path, v)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user