refactor(10.2-01): nest framework packages under modules

- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
Jakub Zych
2026-09-28 02:21:02 +02:00
parent ac1f6d14f4
commit 5e50b166ef
277 changed files with 303 additions and 303 deletions

178
modules/fetchguard/fetch.go Normal file
View File

@@ -0,0 +1,178 @@
package fetchguard
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"syscall"
"time"
"git.golem15.com/golem15/summercms/modules/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)
}
if addr.Zone() != "" {
return errPrivateIP
}
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}
}

View File

@@ -0,0 +1,98 @@
package fetchguard
import (
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// Gap (f): PublicOnlyMode private-IP rejection is already asserted by
// TestFetchPrivateIPBlockedInBothModes/PublicOnlyMode. This file adds
// PublicOnlyMode accepting any host when the dial-time IP check is
// satisfied (loopback httptest with skipReservedCheck — a live public
// IP dial would require outbound network and is not asserted here).
func TestFetchPublicOnlyModeAcceptsAnyHostWhenPublic(t *testing.T) {
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,
MaxBytes: 1024,
Timeout: 2 * time.Second,
}), nil)
if err != nil {
t.Fatalf("PublicOnlyMode must accept the httptest host: %v", err)
}
if string(res.Body) != "ok" {
t.Fatalf("body = %q", res.Body)
}
}
func TestFetchPublicOnlyModePrivateIPRejected(t *testing.T) {
// Explicit restatement of the private-IP case under PublicOnlyMode so
// this coverage file names both halves of gap (f).
srv := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Error("handler must not run for a private dial")
}))
t.Cleanup(srv.Close)
_, 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 TestHostAllowedExactAndDottedSuffix(t *testing.T) {
allowed := []string{"discogs.com", ""}
if !hostAllowed("discogs.com", allowed) {
t.Fatal("exact match")
}
if !hostAllowed("api.discogs.com", allowed) {
t.Fatal("dotted-suffix match")
}
if hostAllowed("evil-discogs.com", allowed) {
t.Fatal("raw suffix must not match")
}
if hostAllowed("example.test", allowed) {
t.Fatal("unrelated host")
}
}
func TestMapTransportErrorReasons(t *testing.T) {
if got := mapTransportError(errPrivateIP); got.Reason != ReasonPrivateIP {
t.Fatalf("private_ip = %s", got.Reason)
}
dns := &net.DNSError{Err: "no such host", Name: "nope.test", IsNotFound: true}
if got := mapTransportError(dns); got.Reason != ReasonUnresolvable {
t.Fatalf("dns = %s", got.Reason)
}
if got := mapTransportError(errors.New("connection reset")); got.Reason != ReasonNetworkError {
t.Fatalf("other = %s", got.Reason)
}
}
func TestErrorStringWithoutInner(t *testing.T) {
e := &Error{Reason: ReasonScheme}
if e.Error() != "fetchguard: scheme" {
t.Fatalf("Error() = %q", e.Error())
}
var nilE *Error
if nilE.Error() != "fetchguard: error" {
t.Fatalf("nil Error() = %q", nilE.Error())
}
if nilE.Unwrap() != nil {
t.Fatal("nil Unwrap")
}
}

View File

@@ -0,0 +1,351 @@
package fetchguard
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/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 TestDialControlRejectsUnsafeIPv6Transitions(t *testing.T) {
tests := []struct {
name string
ip string
}{
{name: "nat64 well-known loopback", ip: "64:ff9b::7f00:1"},
{name: "nat64 well-known rfc1918", ip: "64:ff9b::a00:1"},
{name: "nat64 well-known metadata", ip: "64:ff9b::a9fe:a9fe"},
{name: "nat64 local-use loopback", ip: "64:ff9b:1:7f00:0:100::"},
{name: "nat64 local-use rfc1918", ip: "64:ff9b:1:a00:0:100::"},
{name: "nat64 local-use metadata", ip: "64:ff9b:1:a9fe:a9:fe00::"},
{name: "6to4 loopback", ip: "2002:7f00:1::"},
{name: "6to4 rfc1918", ip: "2002:a00:1::"},
{name: "6to4 metadata", ip: "2002:a9fe:a9fe::"},
}
control := dialControl(Policy{Mode: PublicOnlyMode})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := control("tcp6", "["+tt.ip+"]:443", nil)
if !errors.Is(err, errPrivateIP) {
t.Fatalf("dialControl(%s) error = %v, want errPrivateIP", tt.ip, err)
}
if got := mapTransportError(err).Reason; got != ReasonPrivateIP {
t.Fatalf("mapTransportError(%s) reason = %q, want %q", tt.ip, got, 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 past maxBytes+1, and under a loaded parallel
// test run the handler keeps flushing 64-byte chunks until the client's
// close propagates (observed ~80 KiB). The client must not have pulled an
// unbounded body first (the handler would hit 8 MiB), so 1 MiB is the
// meaningful ceiling.
if got := written.Load(); got > 1<<20 {
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
}
func TestDialControlRejectsZonedAndSpecialUse(t *testing.T) {
probes := []string{"[fe80::1%eth0]:443", "198.18.0.1:443", "192.0.0.1:443", "240.0.0.1:443"}
ctl := dialControl(Policy{Mode: PublicOnlyMode})
for _, a := range probes {
t.Run(a, func(t *testing.T) {
err := ctl("tcp", a, nil)
if !errors.Is(err, errPrivateIP) {
t.Fatalf("dialControl(%s) = %v, want errPrivateIP", a, err)
}
if got := mapTransportError(err).Reason; got != ReasonPrivateIP {
t.Fatalf("reason = %q, want %q", got, ReasonPrivateIP)
}
})
}
if err := ctl("tcp", "8.8.8.8:443", nil); err != nil {
t.Fatalf("public address rejected: %v", err)
}
if err := ctl("tcp", "[2606:4700:4700::1111]:443", nil); err != nil {
t.Fatalf("public v6 rejected: %v", err)
}
if err := ctl("tcp", "no-port", nil); err == nil {
t.Fatal("address without port accepted")
}
if err := ctl("tcp", "example.com:443", nil); err == nil || errors.Is(err, errPrivateIP) {
t.Fatalf("hostname dial target: err = %v, want unparseable error", err)
}
if err := dialControl(Policy{skipReservedCheck: true})("tcp", "127.0.0.1:1", nil); err != nil {
t.Fatalf("skipReservedCheck: %v", err)
}
}
func TestFetchPublicOnlyMapsSpecialUseToPrivateIP(t *testing.T) {
for _, u := range []string{"https://[fe80::1%25eth0]/", "https://198.18.0.1/", "https://192.0.0.1/", "https://240.0.0.1/"} {
t.Run(u, func(t *testing.T) {
_, err := Fetch(t.Context(), u, Policy{Mode: PublicOnlyMode, Timeout: 2 * time.Second, MaxBytes: 1024}, nil)
if got := reasonFrom(t, err); got != ReasonPrivateIP {
t.Fatalf("reason = %q, want %s (err %v)", got, ReasonPrivateIP, err)
}
})
}
}

82
modules/fetchguard/ip.go Normal file
View File

@@ -0,0 +1,82 @@
package fetchguard
import "net/netip"
// privateV4 is the IANA IPv4 special-purpose non-public set. It is a strict
// superset of ManualCoverUrlFetcher.php's lists (06-VERIFICATION gap 3).
var privateV4 = mustPrefixes(
"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",
)
// privateV6 is the IANA IPv6 special-purpose non-public set. 2002::/16 and
// 64:ff9b::/96 are handled by embeddedTransitionIPv4 instead.
var privateV6 = mustPrefixes(
"::/96", "100::/64", "2001::/23", "2001:db8::/32", "3fff::/20",
"5f00::/16", "fc00::/7", "fe80::/10", "fec0::/10", "ff00::/8",
)
func mustPrefixes(cidrs ...string) []netip.Prefix {
out := make([]netip.Prefix, len(cidrs))
for i, c := range cidrs {
out[i] = netip.MustParsePrefix(c)
}
return out
}
var (
nat64WellKnownPrefix = netip.MustParsePrefix("64:ff9b::/96")
nat64LocalUsePrefix = netip.MustParsePrefix("64:ff9b:1::/48")
sixToFourPrefix = netip.MustParsePrefix("2002::/16")
)
// isReservedOrPrivate classifies addr against the PHP private/loopback/
// reserved/CGNAT table, including IPv4 embedded in supported IPv6 transition
// formats.
func isReservedOrPrivate(addr netip.Addr) bool {
if !addr.IsValid() {
return true
}
addr = addr.WithZone("").Unmap()
if addr.IsMulticast() || addr.IsUnspecified() {
return true
}
if embedded, ok := embeddedTransitionIPv4(addr); ok {
return isReservedOrPrivate(embedded)
}
table := privateV4
if !addr.Is4() {
table = privateV6
}
for _, prefix := range table {
if prefix.Contains(addr) {
return true
}
}
return false
}
// embeddedTransitionIPv4 extracts IPv4 from the transition formats supported
// by fetchguard. A recognized but malformed RFC 6052 /48 address returns an
// invalid address with ok=true so the classifier fails closed.
func embeddedTransitionIPv4(addr netip.Addr) (netip.Addr, bool) {
if !addr.Is6() {
return netip.Addr{}, false
}
b := addr.As16()
switch {
case nat64WellKnownPrefix.Contains(addr):
return netip.AddrFrom4([4]byte{b[12], b[13], b[14], b[15]}), true
case nat64LocalUsePrefix.Contains(addr):
if b[8] != 0 {
return netip.Addr{}, true
}
return netip.AddrFrom4([4]byte{b[6], b[7], b[9], b[10]}), true
case sixToFourPrefix.Contains(addr):
return netip.AddrFrom4([4]byte{b[2], b[3], b[4], b[5]}), true
default:
return netip.Addr{}, false
}
}

View File

@@ -0,0 +1,178 @@
package fetchguard
import (
"net/netip"
"testing"
)
func TestIsReservedOrPrivate(t *testing.T) {
tests := []struct {
name string
ip string
want 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", ip: "::ffff:169.254.169.254", want: 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)
}
got := isReservedOrPrivate(addr)
if got != tt.want {
t.Fatalf("isReservedOrPrivate(%s) = %v, want %v", addr, got, tt.want)
}
})
}
}
func TestIsReservedOrPrivateIPv6Transitions(t *testing.T) {
tests := []struct {
name string
ip string
want bool
}{
{name: "nat64 well-known loopback", ip: "64:ff9b::7f00:1", want: true},
{name: "nat64 well-known rfc1918", ip: "64:ff9b::a00:1", want: true},
{name: "nat64 well-known metadata", ip: "64:ff9b::a9fe:a9fe", want: true},
{name: "nat64 well-known public", ip: "64:ff9b::808:808", want: false},
{name: "nat64 local-use loopback", ip: "64:ff9b:1:7f00:0:100::", want: true},
{name: "nat64 local-use rfc1918", ip: "64:ff9b:1:a00:0:100::", want: true},
{name: "nat64 local-use metadata", ip: "64:ff9b:1:a9fe:a9:fe00::", want: true},
{name: "nat64 local-use public", ip: "64:ff9b:1:808:8:800::", want: false},
{name: "nat64 local-use non-zero u octet", ip: "64:ff9b:1:7f00:100:100::", want: true},
{name: "6to4 loopback", ip: "2002:7f00:1::", want: true},
{name: "6to4 rfc1918", ip: "2002:a00:1::", want: true},
{name: "6to4 metadata", ip: "2002:a9fe:a9fe::", want: true},
{name: "6to4 public", ip: "2002:808:808::", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
addr := netip.MustParseAddr(tt.ip)
if got := isReservedOrPrivate(addr); got != tt.want {
t.Fatalf("isReservedOrPrivate(%s) = %v, want %v", addr, got, tt.want)
}
})
}
}
func TestIsReservedOrPrivateSpecialUseSmoke(t *testing.T) {
for _, s := range []string{"198.18.0.1", "192.0.0.1", "240.0.0.1", "255.255.255.255", "2001:db8::1", "fec0::1", "fe80::1%eth0"} {
if !isReservedOrPrivate(netip.MustParseAddr(s)) {
t.Errorf("%s should be non-public", s)
}
}
for _, s := range []string{"8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"} {
if isReservedOrPrivate(netip.MustParseAddr(s)) {
t.Errorf("%s should be public", s)
}
}
}
func prefixBounds(p netip.Prefix) (first, last netip.Addr) {
first = p.Masked().Addr()
b := first.AsSlice()
for bit := p.Bits(); bit < len(b)*8; bit++ {
b[bit/8] |= 1 << (7 - bit%8)
}
last, _ = netip.AddrFromSlice(b)
return first, last
}
func TestIsReservedOrPrivateIANABoundaries(t *testing.T) {
var all []netip.Prefix
all = append(all, privateV4...)
all = append(all, privateV6...)
inAny := func(a netip.Addr) bool {
for _, p := range all {
if p.Contains(a) {
return true
}
}
return false
}
for _, p := range all {
t.Run(p.String(), func(t *testing.T) {
first, last := prefixBounds(p)
interior := first.Next()
for _, a := range []netip.Addr{first, last, interior} {
if !p.Contains(a) {
t.Fatalf("test bug: %s not in %s", a, p)
}
if !isReservedOrPrivate(a) {
t.Errorf("%s (in %s) classified public", a, p)
}
}
for _, a := range []netip.Addr{first.Prev(), last.Next()} {
if !a.IsValid() || inAny(a) || a.IsMulticast() || a.IsUnspecified() {
continue
}
if _, ok := embeddedTransitionIPv4(a); ok {
continue
}
if isReservedOrPrivate(a) {
t.Errorf("neighbour %s of %s classified non-public", a, p)
}
}
})
}
explicit := []struct {
ip string
want bool
}{
{"198.17.255.255", false}, {"198.18.0.0", true}, {"198.19.255.255", true}, {"198.20.0.0", false},
{"191.255.255.255", false}, {"192.0.0.0", true}, {"192.0.1.0", false},
{"239.255.255.255", true}, {"240.0.0.0", true}, {"255.255.255.255", true},
{"100.63.255.255", false}, {"100.64.0.0", true}, {"100.127.255.255", true}, {"100.128.0.0", false},
{"172.15.255.255", false}, {"172.32.0.0", false},
{"2001:200::1", false}, {"2001:db8::", true}, {"2001:db9::", false},
{"3fff:fff:ffff:ffff:ffff:ffff:ffff:ffff", true}, {"3fff:1000::", false},
{"8.8.8.8", false}, {"1.1.1.1", false}, {"2606:4700:4700::1111", false},
{"::ffff:127.0.0.1", true}, {"::ffff:10.0.0.1", true}, {"::ffff:198.18.0.1", true}, {"::ffff:8.8.8.8", false},
}
for _, tc := range explicit {
if got := isReservedOrPrivate(netip.MustParseAddr(tc.ip)); got != tc.want {
t.Errorf("isReservedOrPrivate(%s) = %v, want %v", tc.ip, got, tc.want)
}
}
if !isReservedOrPrivate(netip.Addr{}) {
t.Error("invalid addr must fail closed")
}
}
func TestIsReservedOrPrivateIgnoresZone(t *testing.T) {
for _, s := range []string{"fe80::1%eth0", "fe80::1%1", "fe80::1", "fc00::1%eth0"} {
if !isReservedOrPrivate(netip.MustParseAddr(s)) {
t.Errorf("%s should be non-public", s)
}
}
for _, s := range []string{"2606:4700:4700::1111%eth0", "2606:4700:4700::1111%2"} {
a := netip.MustParseAddr(s)
if isReservedOrPrivate(a) != isReservedOrPrivate(a.WithZone("")) || isReservedOrPrivate(a) {
t.Errorf("%s should classify as its unzoned public form", s)
}
}
}

View File

@@ -0,0 +1,147 @@
package fetchguard
import (
"crypto/tls"
"fmt"
"math"
"time"
"git.golem15.com/golem15/summercms/modules/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)
}
}