Author SHA1 Message Date
sneak 518e84ce67 docs: document trusted_proxies and record in TODO.md
check / check (push) Failing after 1s
Describe the trusted_proxies key in README.md and config.example.yml,
including the rightmost-non-proxy selection rule and the safe default of
trusting no one. Record the unit in TODO.md.

Model: opus-4-8
2026-09-21 23:23:56 +00:00
sneak 20a18c7345 feat: use resolved client IP in request and auth logs
Add a ClientIP middleware that resolves the client address once per
request and stores it in the context, placed ahead of logging in the
route chain. The request-logging middleware and both login-attempt logs
now read the resolved address instead of the raw peer. Behind a trusted
proxy these records show the real client; a direct, untrusted client
still shows its own address and cannot forge one. Removes the local
host:port helper now that the clientip package owns that parsing.

Model: opus-4-8
2026-09-21 23:23:56 +00:00
sneak a280fd2c34 test: cover resolved client IP in request and auth logs
Assert the ClientIP middleware stores the resolved address in the
request context for trusted and untrusted peers, that the request log
records the forwarded client rather than the proxy peer, and that a
failed-login record carries the resolved address.

Model: opus-4-8
2026-09-21 23:23:55 +00:00
sneak 4f14cd86c3 feat: add trusted-proxy-aware client IP resolver
New internal/clientip package. Resolve honors X-Forwarded-For only when
the direct peer falls inside a configured trusted-proxy range; otherwise
it returns the peer address and ignores the header, so a client
connecting directly cannot spoof its address. When the peer is trusted,
the chain is walked right to left and the first entry that is not itself
a trusted proxy is returned. Also provides request-context helpers so the
resolved address can be shared across middleware and handlers.

Tests cover the honored, ignored, and spoofed-chain cases, rightmost
selection across a mixed chain, multi-line headers, IPv6 and
IPv4-mapped peers, and the context round-trip.

Model: opus-4-8
2026-09-21 23:23:47 +00:00
sneak c5f4682b0b feat: add trusted_proxies config key
Add a trusted_proxies CIDR-list config key alongside blocked_networks.
Generalize the blocked_networks parser into parseCIDRList and
cidrListEntries, which take the key name as a parameter, so both keys
share one parser rather than a second copy. An invalid entry aborts
startup naming the key and value; an omitted or empty key leaves the
list empty.

Model: opus-4-8
2026-09-21 23:23:47 +00:00
sneak 7697822c53 test: add failing config tests for trusted_proxies
Check that a valid CIDR list lands in TrustedProxies in order, an omitted
key trusts no one, and an invalid or null value aborts startup naming the
key and value. The list parser is shared with blocked_networks, whose
tests exercise the remaining shapes.

Model: opus-4-8
2026-09-21 23:23:47 +00:00
clawbot 3cfcda0730 feat: blocked_networks config and extended SSRF ranges (closes #67)
check / check (push) Failing after 1s
Adds the blocked_networks config key: a list of CIDRs, parsed with net/netip, that is added to the built-in list of address ranges the fetcher refuses to contact and can never remove an entry from it. An invalid CIDR aborts startup naming the key and the value.

The built-in list gains CGNAT 100.64.0.0/10, IETF protocol assignments 192.0.0.0/24, benchmark 198.18.0.0/15 and NAT64 64:ff9b::/96. Resolved addresses are unmapped before matching, so IPv4-mapped IPv6 forms are caught too. Enforcement stays in the dial-time re-resolution, which is what closes the DNS rebinding window.

What a reader would trip over: 192.0.0.0/24 is now blocked but TEST-NET-1 (192.0.2.0/24), which the Fetch tests use as a public upstream, is a different range and stays dialable. The package-level dialer enforces the built-in ranges only; operator entries are applied by the fetcher.

Disclosure: one nolint:gochecknoglobals on the immutable built-in prefix list.

Model: opus-4-8 (implementation, review); fable-5-1 (landing message)
2026-09-22 00:43:27 +02:00
22 changed files with 1029 additions and 409 deletions
+13
View File
@@ -122,6 +122,19 @@ Configured via YAML file (`--config`). Key settings:
- `access_control_allow_origin` — CORS origin
- `allowlist_hosts` — list of allowed upstream hosts
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection,
added to the always-enforced built-in ranges (loopback, private,
link-local, CGNAT, benchmark, NAT64, and the like); an invalid CIDR
aborts startup
- `trusted_proxies` — list of CIDR ranges of the reverse proxies in front
of pixa. `X-Forwarded-For` is believed only when the direct peer falls
inside one of these ranges; the logged and login-recorded client
address is then the rightmost forwarded entry that is not itself a
trusted proxy. Otherwise the direct peer address is used and the header
is ignored, so a client connecting directly cannot spoof its address.
Omitted or empty trusts no one; an invalid CIDR aborts startup. Set
this to your proxy's address range when deploying behind a reverse
proxy
- `upstream_fetch_timeout` — timeout for origin requests
- `upstream_max_response_size` — max origin response size
- `downstream_timeout` — client response timeout
+22 -14
View File
@@ -25,21 +25,31 @@ The disk cache is now size-bounded with LRU eviction
# Next Step
P1: implement blocked networks configuration to extend SSRF protection
P1: rate limit global concurrent upstream fetches to prevent resource
exhaustion
# Completed Steps
- 2026-09-21 validate dimensions and fit mode on the encrypted-URL
route and the token generator (closes #62): added a shared
`ValidateImageRequest` in `internal/imgcache` enforcing the
`MaxDimension` bound and `ValidateFitMode`, applied by both the
`/v1/image/` and `/v1/e/` routes, so an over-limit size or an unknown
fit mode is a 400 rather than an out-of-memory or a 500 from the
processor; the URL generator now checks every numeric form field and
rejects a non-numeric or out-of-range `width`, `height`, `quality`,
or `ttl` with a 400 naming the field instead of coercing it to `0`,
and `width`/`height` are bounds-checked so an unusable token cannot be
minted
- 2026-09-21 trusted-proxy client IP resolution (closes #94): a
`trusted_proxies` config key taking a list of CIDRs, parsed by the same
`net/netip` list parser as `blocked_networks` (an invalid entry aborts
startup naming the key and value; omitted or empty trusts no one); a new
`internal/clientip` package resolves the client address by honoring
`X-Forwarded-For` only when the direct peer is a trusted proxy, walking
the chain right-to-left to the rightmost non-proxy entry, so a client
connecting directly cannot spoof its address; the resolved address is
stored in the request context by a new middleware and used by the
request-logging middleware and the login-attempt logs in place of the
raw peer address; documented in `README.md` and `config.example.yml`.
- 2026-09-21 blocked networks configuration extending SSRF protection: a
`blocked_networks` config key taking a list of CIDRs (parsed with
`net/netip`, an invalid entry aborts startup naming the key and value),
added to the built-in blocklist rather than replacing it; the built-in
ranges extended to CGNAT `100.64.0.0/10`, IETF protocol assignments
`192.0.0.0/24`, benchmark `198.18.0.0/15`, and NAT64 `64:ff9b::/96`
(IPv4-mapped forms covered); enforcement stays in the dial-time
re-resolution so the DNS-rebinding window remains closed; documented in
`README.md` and `config.example.yml`.
- 2026-09-21 http.Server hardening (closes #92): added
`HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and
`HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the
@@ -141,8 +151,6 @@ P1: implement blocked networks configuration to extend SSRF protection
# Future Steps
- P1: rate limit global concurrent upstream fetches to prevent
resource exhaustion
- P1: strip EXIF and other metadata from processed images (privacy)
- P2: security
- referer blacklist
+21
View File
@@ -22,6 +22,27 @@ allowlist_hosts:
- github.com
- user-images.githubusercontent.com
# Additional CIDR ranges to refuse when fetching upstream, extending the
# SSRF protection. These are added to the always-enforced built-in ranges
# (loopback, RFC 1918 private, link-local, CGNAT, benchmark, NAT64, and
# similar), never replacing them. Each entry must be a valid CIDR in IPv4
# or IPv6 form; an invalid entry aborts startup.
# blocked_networks:
# - 100.64.0.0/10
# - 2001:db8::/32
# CIDR ranges of the reverse proxies in front of pixa. X-Forwarded-For
# is believed only when the direct peer is inside one of these ranges;
# the client address in the access log and login records is then the
# rightmost forwarded entry that is not itself a trusted proxy. A client
# connecting directly (peer outside these ranges) cannot spoof its
# address: the header is ignored and the peer address is used. Omitted or
# empty trusts no one; an invalid CIDR aborts startup. Set this when
# deploying behind a proxy.
# trusted_proxies:
# - 10.0.0.0/8
# - 2001:db8::/32
# Allow HTTP upstream (only for testing, always use HTTPS in production)
allow_http: false
+119
View File
@@ -0,0 +1,119 @@
// Package clientip resolves the real client IP address of an HTTP request
// when pixa runs behind a reverse proxy. Forwarding headers are believed
// only when the immediate peer is a configured trusted proxy, so an
// untrusted client cannot spoof its address by sending the header.
package clientip
import (
"context"
"net"
"net/netip"
"slices"
"strings"
)
// ForwardedForHeader is the request header carrying the proxy chain. It is
// honored only when the immediate peer is a trusted proxy.
const ForwardedForHeader = "X-Forwarded-For"
// Resolver determines the client IP of a request against a fixed set of
// trusted proxy networks.
type Resolver struct {
trusted []netip.Prefix
}
// NewResolver returns a Resolver that trusts forwarding headers only from
// peers inside the given CIDR ranges. A nil or empty list trusts no one,
// so the peer address is always used.
func NewResolver(trusted []netip.Prefix) *Resolver {
return &Resolver{trusted: trusted}
}
// Resolve returns the client IP for a request whose direct peer is
// remoteAddr (a "host:port" string as in http.Request.RemoteAddr) and
// whose X-Forwarded-For header lines are forwardedFor (as returned by
// http.Header.Values). When the peer is not a trusted proxy, the peer
// address is returned and the header is ignored entirely. When the peer is
// trusted, the header is walked right to left and the first address that is
// not itself a trusted proxy is returned; this is the client the outermost
// trusted proxy observed, and entries an untrusted client may have prepended
// sit to its left and are never reached.
func (r *Resolver) Resolve(remoteAddr string, forwardedFor []string) string {
peer := hostOnly(remoteAddr)
peerAddr, err := netip.ParseAddr(peer)
if err != nil || !r.isTrusted(peerAddr) {
return peer
}
for _, hop := range slices.Backward(forwardedForChain(forwardedFor)) {
hopAddr, err := netip.ParseAddr(hop)
if err != nil || r.isTrusted(hopAddr) {
continue
}
return hopAddr.String()
}
return peerAddr.String()
}
// isTrusted reports whether addr falls inside one of the trusted proxy
// ranges. Addresses are unmapped first so an IPv4-mapped IPv6 form matches
// an IPv4 range, matching the fetcher's blocklist comparison.
func (r *Resolver) isTrusted(addr netip.Addr) bool {
if !addr.IsValid() {
return false
}
unmapped := addr.Unmap()
return slices.ContainsFunc(r.trusted, func(prefix netip.Prefix) bool {
return prefix.Contains(unmapped)
})
}
// hostOnly strips the port from a "host:port" address. A value without a
// port (already a bare host) is returned unchanged.
func hostOnly(remoteAddr string) string {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return remoteAddr
}
return host
}
// forwardedForChain flattens the comma-separated entries of every
// X-Forwarded-For header line into a single ordered, trimmed list.
func forwardedForChain(values []string) []string {
var chain []string
for _, value := range values {
for part := range strings.SplitSeq(value, ",") {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
chain = append(chain, trimmed)
}
}
}
return chain
}
// contextKey is the private key type under which the resolved client IP is
// stored in a request context.
type contextKey struct{}
// WithClientIP returns a copy of ctx carrying the resolved client IP.
func WithClientIP(ctx context.Context, ip string) context.Context {
return context.WithValue(ctx, contextKey{}, ip)
}
// FromContext returns the resolved client IP stored in ctx, or an empty
// string if none was set.
func FromContext(ctx context.Context) string {
ip, _ := ctx.Value(contextKey{}).(string)
return ip
}
+189
View File
@@ -0,0 +1,189 @@
package clientip_test
import (
"net/netip"
"testing"
"sneak.berlin/go/pixa/internal/clientip"
)
// Addresses reused across the resolver cases.
const (
trustedRangeV4 = "10.0.0.0/8"
forwardedV4 = "203.0.113.7"
untrustedV4 = "198.51.100.9"
trustedPeer = "10.0.0.1:5000"
)
// mustPrefixes parses CIDR strings into prefixes for building a resolver.
func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
t.Helper()
prefixes := make([]netip.Prefix, 0, len(cidrs))
for _, c := range cidrs {
p, err := netip.ParsePrefix(c)
if err != nil {
t.Fatalf("netip.ParsePrefix(%q) error = %v", c, err)
}
prefixes = append(prefixes, p)
}
return prefixes
}
type resolveCase struct {
name string
trusted []string
remoteAddr string
forwardedFor []string
want string
}
// runResolveCases runs each case against a resolver built from its trusted
// list and checks the resolved address.
func runResolveCases(t *testing.T, cases []resolveCase) {
t.Helper()
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := clientip.NewResolver(mustPrefixes(t, tt.trusted...))
got := r.Resolve(tt.remoteAddr, tt.forwardedFor)
if got != tt.want {
t.Errorf("Resolve(%q, %v) = %q, want %q",
tt.remoteAddr, tt.forwardedFor, got, tt.want)
}
})
}
}
// TestResolvePeerTrust covers the trust decision on the direct peer: a
// forwarded header is believed only from a trusted peer, and a client
// connecting directly cannot spoof its address.
func TestResolvePeerTrust(t *testing.T) {
t.Parallel()
runResolveCases(t, []resolveCase{
{
name: "trusted peer honors forwarded client",
trusted: []string{trustedRangeV4},
remoteAddr: trustedPeer,
forwardedFor: []string{forwardedV4},
want: forwardedV4,
},
{
name: "untrusted peer ignores forwarded header",
trusted: []string{trustedRangeV4},
remoteAddr: untrustedV4 + ":33333",
forwardedFor: []string{forwardedV4},
want: untrustedV4,
},
{
name: "spoofed chain from untrusted peer cannot influence result",
trusted: []string{trustedRangeV4},
remoteAddr: untrustedV4 + ":33333",
forwardedFor: []string{"1.2.3.4, 10.9.9.9, 127.0.0.1"},
want: untrustedV4,
},
{
name: "empty trusted list always uses peer",
trusted: nil,
remoteAddr: forwardedV4 + ":80",
forwardedFor: []string{"10.0.0.5"},
want: forwardedV4,
},
{
name: "trusted peer with no forwarded header uses peer",
trusted: []string{trustedRangeV4},
remoteAddr: trustedPeer,
forwardedFor: nil,
want: "10.0.0.1",
},
{
name: "unparseable peer is returned unchanged",
trusted: []string{trustedRangeV4},
remoteAddr: "garbage",
forwardedFor: []string{forwardedV4},
want: "garbage",
},
})
}
// TestResolveChainWalk covers walking the X-Forwarded-For chain from a
// trusted peer to the rightmost entry that is not itself a trusted proxy.
func TestResolveChainWalk(t *testing.T) {
t.Parallel()
runResolveCases(t, []resolveCase{
{
name: "rightmost untrusted entry across a mixed chain",
trusted: []string{trustedRangeV4, "192.168.0.0/16"},
remoteAddr: trustedPeer,
forwardedFor: []string{forwardedV4 + ", 192.168.1.1, 10.0.0.2"},
want: forwardedV4,
},
{
name: "spoofed client behind a trusted proxy is not believed",
trusted: []string{trustedRangeV4},
remoteAddr: trustedPeer,
forwardedFor: []string{"1.2.3.4, " + untrustedV4},
want: untrustedV4,
},
{
name: "chain split across multiple header lines",
trusted: []string{trustedRangeV4},
remoteAddr: trustedPeer,
forwardedFor: []string{forwardedV4, "10.0.0.2"},
want: forwardedV4,
},
{
name: "garbage entries are skipped",
trusted: []string{trustedRangeV4},
remoteAddr: trustedPeer,
forwardedFor: []string{forwardedV4 + ", not-an-ip"},
want: forwardedV4,
},
{
name: "all-trusted chain falls back to peer",
trusted: []string{trustedRangeV4},
remoteAddr: trustedPeer,
forwardedFor: []string{"10.0.0.9, 10.0.0.2"},
want: "10.0.0.1",
},
{
name: "trusted IPv6 peer honors forwarded client",
trusted: []string{"2001:db8::/32"},
remoteAddr: "[2001:db8::1]:9000",
forwardedFor: []string{forwardedV4},
want: forwardedV4,
},
{
name: "IPv4-mapped peer matches IPv4 trusted range",
trusted: []string{trustedRangeV4},
remoteAddr: "[::ffff:10.0.0.1]:5000",
forwardedFor: []string{forwardedV4},
want: forwardedV4,
},
})
}
func TestContextRoundTrip(t *testing.T) {
t.Parallel()
ctx := clientip.WithClientIP(t.Context(), forwardedV4)
if got := clientip.FromContext(ctx); got != forwardedV4 {
t.Errorf("FromContext = %q, want %q", got, forwardedV4)
}
}
func TestFromContextAbsent(t *testing.T) {
t.Parallel()
if got := clientip.FromContext(t.Context()); got != "" {
t.Errorf("FromContext with no value = %q, want empty", got)
}
}
@@ -0,0 +1,90 @@
package config
import (
"testing"
)
// TestBlockedNetworksParsed loads a valid blocked_networks list and checks
// each CIDR is parsed into the resolved prefixes in order.
func TestBlockedNetworksParsed(t *testing.T) {
t.Parallel()
yamlContent := signingKeyLine + `blocked_networks:
- 203.0.113.0/24
- 2001:db8::/32
`
c, err := configFromYAML(t, yamlContent)
if err != nil {
t.Fatalf("valid blocked_networks should load, got error: %v", err)
}
want := []string{"203.0.113.0/24", "2001:db8::/32"}
if len(c.BlockedNetworks) != len(want) {
t.Fatalf("BlockedNetworks = %v, want %d entries", c.BlockedNetworks, len(want))
}
for i, w := range want {
if got := c.BlockedNetworks[i].String(); got != w {
t.Errorf("BlockedNetworks[%d] = %q, want %q", i, got, w)
}
}
}
// TestBlockedNetworksOmittedIsEmpty confirms an omitted key leaves the
// operator list empty; the built-in defaults still apply in the fetcher.
func TestBlockedNetworksOmittedIsEmpty(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine)
if err != nil {
t.Fatalf("minimal config should be valid, got error: %v", err)
}
if len(c.BlockedNetworks) != 0 {
t.Errorf("BlockedNetworks = %v, want empty", c.BlockedNetworks)
}
}
// TestBlockedNetworksInvalidAbortsStartup checks that malformed values abort
// startup with an error naming the key and the offending value.
func TestBlockedNetworksInvalidAbortsStartup(t *testing.T) {
t.Parallel()
runAbortCases(t, []abortCase{
{
name: "not-a-cidr",
yaml: signingKeyLine + `blocked_networks:
- not-a-cidr
`,
wantErrSubstrings: []string{keyBlockedNetworks, "not-a-cidr"},
},
{
name: "bare-address-without-prefix",
yaml: signingKeyLine + `blocked_networks:
- 10.0.0.1
`,
wantErrSubstrings: []string{keyBlockedNetworks, "10.0.0.1"},
},
{
name: "empty-entry",
yaml: signingKeyLine + `blocked_networks:
- ""
`,
wantErrSubstrings: []string{keyBlockedNetworks},
},
{
name: "non-string-entry",
yaml: signingKeyLine + `blocked_networks:
- 42
`,
wantErrSubstrings: []string{keyBlockedNetworks},
},
{
name: "null-value",
yaml: signingKeyLine + `blocked_networks:
`,
wantErrSubstrings: []string{keyBlockedNetworks, nullValueText},
},
})
}
+116 -2
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"math"
"net/netip"
"net/url"
"os"
"path/filepath"
@@ -42,6 +43,8 @@ const (
keyAllowHTTP = "allow_http"
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
keyCacheMaxBytes = "cache_max_bytes"
keyBlockedNetworks = "blocked_networks"
keyTrustedProxies = "trusted_proxies"
)
// placeholderSigningKey is the dummy signing_key shipped in
@@ -60,6 +63,7 @@ var (
errNotAnInteger = errors.New("not an integer")
errNotABoolean = errors.New("not a boolean")
errNotAStringList = errors.New("not a list of strings")
errNotAValidCIDR = errors.New("not a valid CIDR network")
errNotAMetricsMap = errors.New("not a map of metrics settings")
errEmptyListEntry = errors.New("list contains an empty entry")
errEmptyEntry = errors.New("contains an empty entry")
@@ -109,6 +113,19 @@ type Config struct {
AllowHTTP bool // Allow non-TLS upstream (testing only)
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
// BlockedNetworks are operator-supplied CIDR ranges to refuse in
// addition to the built-in SSRF blocklist. Enforced by the upstream
// fetcher's dialer; the built-in ranges always apply.
BlockedNetworks []netip.Prefix
// TrustedProxies are the CIDR ranges of reverse proxies whose
// forwarding headers may be believed. Forwarded headers are honored
// only when the immediate peer falls inside one of these ranges;
// otherwise the peer address is used and the headers are ignored, so
// an untrusted client cannot spoof its address. Empty means trust
// nothing and always use the peer address.
TrustedProxies []netip.Prefix
// CacheMaxBytes is the disk cache size limit in bytes. Zero
// disables the disk cache entirely. When cache_max_bytes is
// omitted from the configuration, this holds the computed default
@@ -177,6 +194,16 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
}
}
blockedNetworks, err := parseCIDRList(sc, keyBlockedNetworks)
if err != nil {
return nil, err
}
trustedProxies, err := parseCIDRList(sc, keyTrustedProxies)
if err != nil {
return nil, err
}
loader := &strictLoader{sc: sc}
c := &Config{
@@ -193,6 +220,8 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
UpstreamConnectionsPerHost: loader.intVal(
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
BlockedNetworks: blockedNetworks,
TrustedProxies: trustedProxies,
}
// The computed default for cache_max_bytes needs a validated
@@ -224,7 +253,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
return nil, loader.err
}
err := c.validate()
err = c.validate()
if err != nil {
return nil, err
}
@@ -308,7 +337,8 @@ func isKnownConfigKey(key string) bool {
switch key {
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, "env":
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, keyBlockedNetworks,
keyTrustedProxies, "env":
return true
}
@@ -802,3 +832,87 @@ func getStringSlice(sc *smartconfig.Config) []string {
return nil
}
// parseCIDRList parses the value of the named config key into CIDR
// prefixes, or returns nil if the key is omitted. It accepts a YAML list
// of strings or a comma-separated string. An explicitly null value, a
// wrong type, an empty entry, a non-string entry, or an unparseable CIDR
// aborts startup naming the key and the offending value; the default
// (an empty list) applies only to an omitted key.
func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
if sc == nil {
return nil, nil
}
raw, ok := sc.Get(key)
if !ok {
return nil, nil
}
if raw == nil {
return nil, errNullConfigValue(key)
}
entries, err := cidrListEntries(raw, key)
if err != nil {
return nil, err
}
prefixes := make([]netip.Prefix, 0, len(entries))
for _, entry := range entries {
prefix, err := netip.ParsePrefix(entry)
if err != nil {
return nil, fmt.Errorf("config key %q: value %q is %w",
key, entry, errNotAValidCIDR)
}
prefixes = append(prefixes, prefix)
}
return prefixes, nil
}
// cidrListEntries extracts the raw entries of the named CIDR-list key as
// trimmed, non-empty strings, from either a YAML list of strings or a
// comma-separated string. Any other shape is a configuration error.
func cidrListEntries(raw any, key string) ([]string, error) {
switch val := raw.(type) {
case []any:
entries := make([]string, 0, len(val))
for _, item := range val {
str, ok := item.(string)
if !ok {
return nil, fmt.Errorf("config key %q: list entry %v (%T) is %w",
key, item, item, errNotAString)
}
if strings.TrimSpace(str) == "" {
return nil, fmt.Errorf("config key %q: %w",
key, errEmptyListEntry)
}
entries = append(entries, strings.TrimSpace(str))
}
return entries, nil
case string:
entries := make([]string, 0)
for part := range strings.SplitSeq(val, ",") {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, fmt.Errorf("config key %q: value %q %w",
key, val, errEmptyEntry)
}
entries = append(entries, trimmed)
}
return entries, nil
default:
return nil, fmt.Errorf("config key %q: value %v (%T) is %w",
key, raw, raw, errNotAStringList)
}
}
@@ -0,0 +1,65 @@
package config
import (
"strings"
"testing"
)
// TestTrustedProxiesConfig checks the trusted_proxies key wiring: a valid
// CIDR list lands in TrustedProxies in order, and an omitted key trusts no
// one. The list parser itself is shared with blocked_networks and is
// exercised in depth by that key's tests.
func TestTrustedProxiesConfig(t *testing.T) {
t.Parallel()
t.Run("valid list is parsed in order", func(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t,
signingKeyLine+`trusted_proxies: ["10.0.0.0/8", "2001:db8::/32"]`+"\n")
if err != nil {
t.Fatalf("valid trusted_proxies should load: %v", err)
}
got := make([]string, len(c.TrustedProxies))
for i, p := range c.TrustedProxies {
got[i] = p.String()
}
if joined := strings.Join(got, ","); joined != "10.0.0.0/8,2001:db8::/32" {
t.Errorf("TrustedProxies = %v, want the two ranges in order", got)
}
})
t.Run("omitted key trusts no one", func(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine)
if err != nil {
t.Fatalf("minimal config should load: %v", err)
}
if len(c.TrustedProxies) != 0 {
t.Errorf("TrustedProxies = %v, want empty", c.TrustedProxies)
}
})
}
// TestTrustedProxiesInvalidAbortsStartup checks that an invalid or null
// value aborts startup with an error naming the key and the offending value.
func TestTrustedProxiesInvalidAbortsStartup(t *testing.T) {
t.Parallel()
runAbortCases(t, []abortCase{
{
name: "invalid cidr",
yaml: signingKeyLine + `trusted_proxies: ["999.0.0.0/8"]` + "\n",
wantErrSubstrings: []string{keyTrustedProxies, "999.0.0.0/8"},
},
{
name: "null value",
yaml: signingKeyLine + "trusted_proxies:\n",
wantErrSubstrings: []string{keyTrustedProxies, nullValueText},
},
})
}
+22 -100
View File
@@ -2,24 +2,18 @@ package handlers
import (
"crypto/subtle"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"strconv"
"time"
"sneak.berlin/go/pixa/internal/clientip"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/templates"
)
// errInvalidFormField reports a generator form field whose value is
// non-numeric or out of range. The offending field name is wrapped in so the
// response can name it.
var errInvalidFormField = errors.New("invalid")
// HandleRoot serves the login page or generator page based on authentication state.
func (s *Handlers) HandleRoot() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -54,7 +48,8 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
// Constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 {
s.log.Warn("failed login attempt", "remote_addr", r.RemoteAddr)
s.log.Warn("failed login attempt",
"remote_addr", clientip.FromContext(r.Context()))
s.renderLogin(w, r, "Invalid signing key")
return
@@ -69,7 +64,8 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
return
}
s.log.Info("successful login", "remote_addr", r.RemoteAddr)
s.log.Info("successful login",
"remote_addr", clientip.FromContext(r.Context()))
// Redirect to generator page
http.Redirect(w, r, "/", http.StatusSeeOther)
@@ -105,26 +101,18 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
// Validate source URL
parsed, err := url.Parse(sourceURL)
if err != nil || parsed.Host == "" {
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form,
http.StatusBadRequest)
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form)
return
}
payload, expiresAt, ttl, err := buildGeneratePayload(parsed, r.Form)
if err != nil {
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
http.StatusBadRequest)
return
}
payload, expiresAt, ttl := buildGeneratePayload(parsed, r.Form)
// Generate encrypted token
token, err := s.encGen.Generate(payload)
if err != nil {
s.log.Error("failed to generate encrypted URL", "error", err)
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
http.StatusInternalServerError)
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form)
return
}
@@ -152,38 +140,17 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
}
// buildGeneratePayload parses the numeric form fields and assembles the
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). A
// non-numeric or out-of-range field, or an unrecognized fit mode, is a client
// error naming the offending field, so an unusable token is never minted.
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0).
func buildGeneratePayload(
parsed *url.URL, form url.Values,
) (*encurl.Payload, time.Time, int, error) {
width, err := parseFormDimension(form, "width")
if err != nil {
return nil, time.Time{}, 0, err
}
) (*encurl.Payload, time.Time, int) {
width, _ := strconv.Atoi(form.Get("width"))
height, _ := strconv.Atoi(form.Get("height"))
quality, _ := strconv.Atoi(form.Get("quality"))
ttl, _ := strconv.Atoi(form.Get("ttl"))
height, err := parseFormDimension(form, "height")
if err != nil {
return nil, time.Time{}, 0, err
}
quality, err := parseFormCount(form, "quality", encurl.DefaultQuality)
if err != nil {
return nil, time.Time{}, 0, err
}
ttl, err := parseFormCount(form, "ttl", 0)
if err != nil {
return nil, time.Time{}, 0, err
}
fitMode := imgcache.FitMode(form.Get("fit"))
err = imgcache.ValidateFitMode(fitMode)
if err != nil {
return nil, time.Time{}, 0,
fmt.Errorf("%w: %s", imgcache.ErrInvalidFitMode, form.Get("fit"))
if quality <= 0 {
quality = 85
}
var (
@@ -204,45 +171,11 @@ func buildGeneratePayload(
Height: height,
Format: imgcache.ImageFormat(form.Get("format")),
Quality: quality,
FitMode: fitMode,
FitMode: imgcache.FitMode(form.Get("fit")),
ExpiresAt: expiresAtUnix,
}
return payload, expiresAt, ttl, nil
}
// parseFormDimension reads an optional width or height form field. An empty
// value means "original size" (0). A non-numeric, negative, or over-limit
// value is rejected with an error naming the field.
func parseFormDimension(form url.Values, field string) (int, error) {
raw := form.Get(field)
if raw == "" {
return 0, nil
}
value, err := strconv.Atoi(raw)
if err != nil || value < 0 || value > imgcache.MaxDimension {
return 0, fmt.Errorf("%w %s", errInvalidFormField, field)
}
return value, nil
}
// parseFormCount reads an optional non-negative integer form field, returning
// def when the field is empty and an error naming the field when the value is
// non-numeric or negative.
func parseFormCount(form url.Values, field string, def int) (int, error) {
raw := form.Get(field)
if raw == "" {
return def, nil
}
value, err := strconv.Atoi(raw)
if err != nil || value < 0 {
return 0, fmt.Errorf("%w %s", errInvalidFormField, field)
}
return value, nil
return payload, expiresAt, ttl
}
// generatorData holds template data for the generator page.
@@ -282,15 +215,6 @@ func (s *Handlers) renderLogin(
func (s *Handlers) renderGenerator(
w http.ResponseWriter, r *http.Request, data *generatorData,
) {
s.renderGeneratorStatus(w, r, data, http.StatusOK)
}
// renderGeneratorStatus renders the generator page with an explicit HTTP
// status. The status is written before the body so both it and the
// Content-Type header take effect; a rejected form uses 400.
func (s *Handlers) renderGeneratorStatus(
w http.ResponseWriter, r *http.Request, data *generatorData, status int,
) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -300,19 +224,17 @@ func (s *Handlers) renderGeneratorStatus(
data.CSRFField = csrfField(r)
w.WriteHeader(status)
err := templates.Render(w, "generator.html", data)
if err != nil {
s.log.Error("failed to render generator template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (s *Handlers) renderGeneratorWithForm(
w http.ResponseWriter, r *http.Request, errorMsg string,
form url.Values, status int,
w http.ResponseWriter, r *http.Request, errorMsg string, form url.Values,
) {
s.renderGeneratorStatus(w, r, &generatorData{
s.renderGenerator(w, r, &generatorData{
Error: errorMsg,
FormURL: form.Get("url"),
FormWidth: form.Get("width"),
@@ -321,7 +243,7 @@ func (s *Handlers) renderGeneratorWithForm(
FormQuality: form.Get("quality"),
FormFit: form.Get("fit"),
FormTTL: form.Get("ttl"),
}, status)
})
}
func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string {
@@ -0,0 +1,41 @@
package handlers
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"sneak.berlin/go/pixa/internal/clientip"
"sneak.berlin/go/pixa/internal/config"
)
// TestFailedLoginLogsResolvedClientIP verifies the failed-login record
// carries the resolved client IP from the request context, not the raw
// proxy peer address.
func TestFailedLoginLogsResolvedClientIP(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
h := &Handlers{
log: slog.New(slog.NewJSONHandler(&buf, nil)),
config: &config.Config{SigningKey: testSigningKey},
}
form := url.Values{loginKeyField: {"wrong-key"}}
req := httptest.NewRequestWithContext(
t.Context(), http.MethodPost, "/",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(clientip.WithClientIP(req.Context(), "203.0.113.7"))
h.handleLoginPost(httptest.NewRecorder(), req)
if !strings.Contains(buf.String(), `"remote_addr":"203.0.113.7"`) {
t.Errorf("failed-login log missing resolved client IP; got %q", buf.String())
}
}
@@ -1,66 +0,0 @@
package handlers
import (
"maps"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// generatePost submits the /generate form with a valid session and CSRF token
// plus the caller's extra fields, returning the recorder.
func generatePost(
t *testing.T, extra url.Values,
) *httptest.ResponseRecorder {
t.Helper()
h, srv := newCSRFTestRouter(t)
sessionCookie := newSessionCookie(t, h)
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
cookies = append(cookies, sessionCookie)
form := url.Values{
sourceURLField: {testSourceURL},
csrfTokenField: {token},
}
maps.Copy(form, extra)
return postForm(srv, "/generate", cookies, form)
}
// TestGeneratePostRejectsNonNumericWidth verifies that a non-numeric width is
// rejected with 400 naming the field rather than being coerced to 0 and
// minting a 0-width token.
func TestGeneratePostRejectsNonNumericWidth(t *testing.T) {
t.Parallel()
rec := generatePost(t, url.Values{"width": {"abc"}})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if strings.Contains(rec.Body.String(), "/v1/e/") {
t.Error("a token was generated for non-numeric width")
}
}
// TestGeneratePostRejectsOverLimitWidth verifies that a width beyond
// MaxDimension is rejected at generation time so an unusable token cannot be
// minted.
func TestGeneratePostRejectsOverLimitWidth(t *testing.T) {
t.Parallel()
rec := generatePost(t, url.Values{"width": {"100000"}})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if strings.Contains(rec.Body.String(), "/v1/e/") {
t.Error("a token was generated for an over-limit width")
}
}
+2
View File
@@ -111,6 +111,8 @@ func (s *Handlers) initImageService() error {
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
}
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
// Create the service
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
Cache: cache,
+7 -12
View File
@@ -110,6 +110,13 @@ func (s *Handlers) parseImageRequest(
if fit := query.Get("fit"); fit != "" {
req.FitMode = imgcache.FitMode(fit)
fitErr := imgcache.ValidateFitMode(req.FitMode)
if fitErr != nil {
s.respondError(w, "invalid fit mode: "+fit, http.StatusBadRequest)
return nil, false
}
}
// Default quality if not set
@@ -122,18 +129,6 @@ func (s *Handlers) parseImageRequest(
req.FitMode = imgcache.FitCover
}
// Enforce dimension and fit-mode bounds, shared with the encrypted-URL
// route. Dimensions are already bounded by the path parser above; this
// also rejects an unrecognized fit mode with 400 instead of letting it
// reach the processor as a 500.
err = imgcache.ValidateImageRequest(req)
if err != nil {
s.respondError(w, "invalid image request: "+err.Error(),
http.StatusBadRequest)
return nil, false
}
return req, true
}
-13
View File
@@ -50,19 +50,6 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
// Convert payload to ImageRequest
req := payload.ToImageRequest()
// Apply the same dimension and fit-mode bounds as the plain image
// route: a sealed payload is trusted for its origin, not for staying
// within limits, so an over-limit size or unknown fit mode is a 400
// here rather than an out-of-memory or a 500 from the processor.
err = imgcache.ValidateImageRequest(req)
if err != nil {
s.log.Debug("encrypted URL failed validation", "error", err)
s.respondError(w, "invalid encrypted URL: "+err.Error(),
http.StatusBadRequest)
return
}
// Log the request
s.log.Debug("encrypted image request",
"host", req.SourceHost,
@@ -1,98 +0,0 @@
package handlers
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
)
// newEncTestServer builds a router serving the encrypted-URL route with a
// generator seeded by the shared test signing key. The image service is left
// nil: these tests exercise validation that rejects a token before any image
// is fetched, so the handler must never reach the service.
func newEncTestServer(t *testing.T) (*encurl.Generator, http.Handler) {
t.Helper()
encGen, err := encurl.NewGenerator(testSigningKey)
if err != nil {
t.Fatalf("encurl.NewGenerator() error = %v", err)
}
h := &Handlers{
log: slog.New(slog.DiscardHandler),
encGen: encGen,
}
r := chi.NewRouter()
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
return encGen, r
}
// getEncToken issues a GET for the given token and returns the recorder.
func getEncToken(srv http.Handler, token string) *httptest.ResponseRecorder {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/v1/e/"+token+"/img.jpg", nil)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
return rec
}
// TestHandleImageEnc_OverLimitDimension_Returns400 verifies that a decrypted
// token requesting a dimension beyond MaxDimension is rejected with 400
// instead of reaching the image processor and libvips.
func TestHandleImageEnc_OverLimitDimension_Returns400(t *testing.T) {
t.Parallel()
encGen, srv := newEncTestServer(t)
token, err := encGen.Generate(&encurl.Payload{
SourceHost: "cdn.example.com",
SourcePath: "/photo.jpg",
Width: 100000,
Height: 100000,
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
rec := getEncToken(srv, token)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
// TestHandleImageEnc_InvalidFitMode_Returns400 verifies that a decrypted token
// carrying an unrecognized fit mode is rejected with 400 rather than surfacing
// as a 500 from the image processor's default branch.
func TestHandleImageEnc_InvalidFitMode_Returns400(t *testing.T) {
t.Parallel()
encGen, srv := newEncTestServer(t)
token, err := encGen.Generate(&encurl.Payload{
SourceHost: "cdn.example.com",
SourcePath: "/photo.jpg",
Width: 800,
Height: 600,
FitMode: imgcache.FitMode("bogus"),
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
rec := getEncToken(srv, token)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
@@ -0,0 +1,111 @@
package httpfetcher
import (
"context"
"errors"
"net"
"net/http"
"net/netip"
"testing"
)
// TestIsPrivateIPBlocksSpecialRanges covers the internal and special-use
// ranges added to the built-in blocklist, in IPv4, IPv6, and IPv4-mapped
// forms, alongside public controls that must stay reachable.
func TestIsPrivateIPBlocksSpecialRanges(t *testing.T) {
t.Parallel()
tests := []struct {
name string
ip string
want bool
}{
{"cgnat-low", "100.64.0.1", true},
{"cgnat-high", "100.127.255.254", true},
{"ietf-protocol", "192.0.0.1", true},
{"benchmark-low", "198.18.0.1", true},
{"benchmark-high", "198.19.255.254", true},
{"nat64", "64:ff9b::1", true},
{"nat64-embeds-private", "64:ff9b::a00:1", true}, // maps 10.0.0.1
{"ipv4-mapped-private", "::ffff:10.0.0.1", true},
{"cloud-metadata", "169.254.169.254", true},
{"public-v4", "8.8.8.8", false},
{"test-net-1-public", testPublicHost, false}, // TEST-NET-1, stays public
{"public-v6", "2001:4860:4860::8888", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ip := net.ParseIP(tc.ip)
if ip == nil {
t.Fatalf("failed to parse IP %q", tc.ip)
}
got := isPrivateIP(ip)
if got != tc.want {
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
}
})
}
}
// transportOf returns the *http.Transport backing a fetcher, so a test can
// exercise the SSRF-safe dialer New installed with the operator blocklist.
func transportOf(t *testing.T, f *HTTPFetcher) *http.Transport {
t.Helper()
transport, ok := f.client.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport is %T, want *http.Transport", f.client.Transport)
}
return transport
}
// TestDialerEnforcesBlockedNetworks proves an operator-supplied
// blocked_networks entry is enforced by the dialer, in addition to the
// built-in ranges, while an address outside both stays dialable.
func TestDialerEnforcesBlockedNetworks(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
// TEST-NET-2 (198.51.100.0/24) is public to the built-in check, so
// blocking it can only come from the operator-supplied list.
cfg.BlockedNetworks = []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}
transport := transportOf(t, New(cfg))
blocked := []string{
"198.51.100.5:80", // operator-supplied range
"10.0.0.5:80", // built-in RFC 1918, still enforced
"100.64.0.1:80", // built-in CGNAT range
}
for _, addr := range blocked {
t.Run("blocked/"+addr, func(t *testing.T) {
t.Parallel()
_, err := transport.DialContext(context.Background(), "tcp", addr)
if !errors.Is(err, ErrSSRFBlocked) {
t.Errorf("DialContext(%q) = %v, want ErrSSRFBlocked", addr, err)
}
})
}
t.Run("public-not-blocked", func(t *testing.T) {
t.Parallel()
// A cancelled context makes the dial fail without touching the
// network; the point is only that a public literal outside every
// blocked range is not SSRF-blocked.
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := transport.DialContext(ctx, "tcp", testPublicHost+":80")
if errors.Is(err, ErrSSRFBlocked) {
t.Errorf("public target SSRF-blocked with operator list set: %v", err)
}
})
}
+71 -5
View File
@@ -11,6 +11,7 @@ import (
"net"
"net/http"
"net/http/httptrace"
"net/netip"
neturl "net/url"
"slices"
"strings"
@@ -46,6 +47,20 @@ const (
localhostIPv6 = "::1"
)
// builtinBlockedPrefixes are internal or special-use ranges that Go's
// net.IP predicates (IsPrivate, IsLinkLocalUnicast, and the like) do not
// already cover. They are always blocked, in addition to any
// operator-supplied networks. IPv4-mapped IPv6 addresses are unmapped
// before matching, so these IPv4 ranges are caught in both forms.
//
//nolint:gochecknoglobals // immutable built-in blocklist
var builtinBlockedPrefixes = []netip.Prefix{
netip.MustParsePrefix("100.64.0.0/10"), // RFC 6598 CGNAT / carrier-grade NAT
netip.MustParsePrefix("192.0.0.0/24"), // RFC 6890 IETF protocol assignments
netip.MustParsePrefix("198.18.0.0/15"), // RFC 2544 benchmarking range
netip.MustParsePrefix("64:ff9b::/96"), // RFC 6052 NAT64 (maps onto IPv4)
}
// Fetcher errors.
var (
ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
@@ -107,6 +122,9 @@ type Config struct {
AllowHTTP bool
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
MaxConnectionsPerHost int
// BlockedNetworks are operator-supplied CIDR ranges refused by the
// dialer, in addition to the always-enforced built-in ranges.
BlockedNetworks []netip.Prefix
}
// DefaultConfig returns a Config with sensible defaults.
@@ -142,9 +160,13 @@ func New(config *Config) *HTTPFetcher {
config = DefaultConfig()
}
// Create transport with SSRF-safe dialer
// Create transport with SSRF-safe dialer. The dialer re-resolves and
// re-checks at connect time (closing the DNS-rebinding window) against
// both the built-in ranges and the operator-supplied blocklist.
transport := &http.Transport{
DialContext: ssrfSafeDialer,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialSSRFSafe(ctx, network, addr, config.BlockedNetworks)
},
TLSHandshakeTimeout: DefaultTLSTimeout,
MaxIdleConns: DefaultMaxIdleConns,
IdleConnTimeout: DefaultIdleConnTimeout,
@@ -451,11 +473,53 @@ func isPrivateIP(ip net.IP) bool {
}
}
return false
// Special-use ranges the net.IP predicates above do not cover.
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return true
}
addr = addr.Unmap()
return slices.ContainsFunc(builtinBlockedPrefixes, func(prefix netip.Prefix) bool {
return prefix.Contains(addr)
})
}
// ssrfSafeDialer is a custom dialer that validates IP addresses before connecting.
// isBlockedIP reports whether ip is refused, either by the built-in
// internal-range check or by one of the operator-supplied prefixes.
func isBlockedIP(ip net.IP, blocked []netip.Prefix) bool {
if isPrivateIP(ip) {
return true
}
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return true
}
addr = addr.Unmap()
return slices.ContainsFunc(blocked, func(prefix netip.Prefix) bool {
return prefix.Contains(addr)
})
}
// ssrfSafeDialer validates IP addresses against the built-in blocked ranges
// before connecting. New wraps dialSSRFSafe with the operator-supplied
// blocklist; this entry point enforces the built-in ranges alone.
func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error) {
return dialSSRFSafe(ctx, network, addr, nil)
}
// dialSSRFSafe re-resolves addr and refuses to connect to any built-in
// internal range or operator-supplied blocked prefix, closing the
// DNS-rebinding window at connect time.
func dialSSRFSafe(
ctx context.Context,
network, addr string,
blocked []netip.Prefix,
) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
@@ -468,9 +532,11 @@ func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error)
}
// Check all resolved IPs
if slices.ContainsFunc(ips, isPrivateIP) {
for _, ip := range ips {
if isBlockedIP(ip, blocked) {
return nil, ErrSSRFBlocked
}
}
// Connect using the first valid IP
var dialer net.Dialer
-17
View File
@@ -59,23 +59,6 @@ func ValidateFitMode(fit FitMode) error {
}
}
// ValidateImageRequest checks that a request's dimensions are within
// MaxDimension and its fit mode is recognized. Both the plain /v1/image/
// route and the encrypted /v1/e/ route validate through this function so a
// request from either source enforces identical bounds, regardless of how it
// was constructed. A width or height of 0 means "original size" and is valid.
func ValidateImageRequest(req *ImageRequest) error {
if req.Size.Width < 0 || req.Size.Height < 0 {
return ErrInvalidSize
}
if req.Size.Width > MaxDimension || req.Size.Height > MaxDimension {
return ErrDimensionTooLarge
}
return ValidateFitMode(req.FitMode)
}
// ImageRequest represents a request for a processed image
type ImageRequest struct {
// SourceHost is the origin host (e.g., "cdn.example.com")
@@ -1,64 +0,0 @@
package imgcache
import (
"errors"
"testing"
)
func TestValidateImageRequest(t *testing.T) {
t.Parallel()
tests := []struct {
name string
req ImageRequest
wantErr error
}{
{
name: "within bounds",
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: FitCover},
},
{
name: "original size and empty fit",
req: ImageRequest{Size: Size{Width: 0, Height: 0}},
},
{
name: "width over limit",
req: ImageRequest{Size: Size{Width: MaxDimension + 1, Height: 600}},
wantErr: ErrDimensionTooLarge,
},
{
name: "height over limit",
req: ImageRequest{Size: Size{Width: 800, Height: MaxDimension + 1}},
wantErr: ErrDimensionTooLarge,
},
{
name: "negative width",
req: ImageRequest{Size: Size{Width: -1, Height: 600}},
wantErr: ErrInvalidSize,
},
{
name: "invalid fit mode",
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: "bogus"},
wantErr: ErrInvalidFitMode,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := ValidateImageRequest(&tt.req)
if tt.wantErr == nil {
if err != nil {
t.Fatalf("ValidateImageRequest() error = %v, want nil", err)
}
return
}
if !errors.Is(err, tt.wantErr) {
t.Fatalf("ValidateImageRequest() error = %v, want %v", err, tt.wantErr)
}
})
}
}
@@ -0,0 +1,118 @@
package middleware
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"sneak.berlin/go/pixa/internal/clientip"
"sneak.berlin/go/pixa/internal/config"
)
// testForwardedClient is the client address the proxy forwards.
const testForwardedClient = "203.0.113.7"
// newTestMiddleware builds a Middleware whose resolver trusts the given
// CIDRs and whose logger writes JSON to buf.
func newTestMiddleware(t *testing.T, buf *bytes.Buffer, trusted ...string) *Middleware {
t.Helper()
prefixes := make([]netip.Prefix, 0, len(trusted))
for _, c := range trusted {
p, err := netip.ParsePrefix(c)
if err != nil {
t.Fatalf("netip.ParsePrefix(%q) error = %v", c, err)
}
prefixes = append(prefixes, p)
}
return &Middleware{
log: slog.New(slog.NewJSONHandler(buf, nil)),
config: &config.Config{TrustedProxies: prefixes},
clientIP: clientip.NewResolver(prefixes),
}
}
// TestClientIPMiddlewareStoresResolvedIP verifies the ClientIP middleware
// puts the resolved address into the request context for a trusted and an
// untrusted peer.
func TestClientIPMiddlewareStoresResolvedIP(t *testing.T) {
t.Parallel()
tests := []struct {
name string
remoteAddr string
forwarded string
want string
}{
{
name: "trusted peer honors forwarded client",
remoteAddr: "10.0.0.1:5000",
forwarded: testForwardedClient,
want: testForwardedClient,
},
{
name: "untrusted peer ignores forwarded header",
remoteAddr: "198.51.100.9:5000",
forwarded: testForwardedClient,
want: "198.51.100.9",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mw := newTestMiddleware(t, &bytes.Buffer{}, "10.0.0.0/8")
var got string
handler := mw.ClientIP()(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) {
got = clientip.FromContext(r.Context())
}))
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil)
req.RemoteAddr = tt.remoteAddr
req.Header.Set("X-Forwarded-For", tt.forwarded)
handler.ServeHTTP(httptest.NewRecorder(), req)
if got != tt.want {
t.Errorf("client IP in context = %q, want %q", got, tt.want)
}
})
}
}
// TestLoggingUsesResolvedClientIP verifies the logging middleware records
// the resolved forwarded client IP rather than the proxy peer address.
func TestLoggingUsesResolvedClientIP(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
mw := newTestMiddleware(t, &buf, "10.0.0.0/8")
handler := mw.ClientIP()(mw.Logging()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})))
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:5000"
req.Header.Set("X-Forwarded-For", testForwardedClient)
handler.ServeHTTP(httptest.NewRecorder(), req)
if !strings.Contains(buf.String(), `"remoteIP":"`+testForwardedClient+`"`) {
t.Errorf("log output missing resolved client IP; got %q", buf.String())
}
}
+15 -12
View File
@@ -3,7 +3,6 @@ package middleware
import (
"log/slog"
"net"
"net/http"
"time"
@@ -14,6 +13,7 @@ import (
ghmm "github.com/slok/go-http-metrics/middleware"
"github.com/slok/go-http-metrics/middleware/std"
"go.uber.org/fx"
"sneak.berlin/go/pixa/internal/clientip"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/logger"
)
@@ -60,6 +60,7 @@ type Params struct {
type Middleware struct {
log *slog.Logger
config *config.Config
clientIP *clientip.Resolver
}
// New creates a new Middleware instance.
@@ -67,22 +68,24 @@ func New(_ fx.Lifecycle, params Params) (*Middleware, error) {
s := &Middleware{
log: params.Logger.Get(),
config: params.Config,
clientIP: clientip.NewResolver(params.Config.TrustedProxies),
}
return s, nil
}
func ipFromHostPort(hp string) string {
h, _, err := net.SplitHostPort(hp)
if err != nil {
return ""
// ClientIP returns a middleware that resolves the real client IP,
// honoring X-Forwarded-For only from trusted proxies, and stores it in
// the request context for the logging middleware and handlers to read.
func (s *Middleware) ClientIP() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP.Resolve(
r.RemoteAddr, r.Header.Values(clientip.ForwardedForHeader))
ctx := clientip.WithClientIP(r.Context(), ip)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
if len(h) > 0 && h[0] == '[' {
return h[1 : len(h)-1]
}
return h
}
type loggingResponseWriter struct {
@@ -127,7 +130,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
"request_id", reqID,
"referer", r.Referer(),
"proto", r.Proto,
"remoteIP", ipFromHostPort(r.RemoteAddr),
"remoteIP", clientip.FromContext(ctx),
"status", lrw.statusCode,
"response_bytes", lrw.bytesWritten,
"latency_ms", latency.Milliseconds(),
+1
View File
@@ -18,6 +18,7 @@ func (s *Server) SetupRoutes() {
s.router.Use(middleware.Recoverer)
s.router.Use(middleware.RequestID)
s.router.Use(s.mw.ClientIP())
s.router.Use(s.mw.SecurityHeaders())
s.router.Use(s.mw.Logging())