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) } }