Compare commits
2
Commits
8dc46af6b7
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10eab440e7 | ||
|
|
3cfcda0730 |
@@ -122,6 +122,22 @@ Configured via YAML file (`--config`). Key settings:
|
|||||||
|
|
||||||
- `access_control_allow_origin` — CORS origin
|
- `access_control_allow_origin` — CORS origin
|
||||||
- `allowlist_hosts` — list of allowed upstream hosts
|
- `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.
|
||||||
|
An omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`,
|
||||||
|
`172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a
|
||||||
|
proxy on a private network; an explicitly empty list (`[]`) trusts no
|
||||||
|
one, and an explicit list replaces the default. An invalid CIDR aborts
|
||||||
|
startup. Set this to your proxy's address range if it is not already
|
||||||
|
covered by the defaults
|
||||||
- `upstream_fetch_timeout` — timeout for origin requests
|
- `upstream_fetch_timeout` — timeout for origin requests
|
||||||
- `upstream_max_response_size` — max origin response size
|
- `upstream_max_response_size` — max origin response size
|
||||||
- `downstream_timeout` — client response timeout
|
- `downstream_timeout` — client response timeout
|
||||||
|
|||||||
@@ -25,10 +25,33 @@ The disk cache is now size-bounded with LRU eviction
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
P1: implement blocked networks configuration to extend SSRF protection
|
P1: rate limit global concurrent upstream fetches to prevent resource
|
||||||
|
exhaustion
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 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; an omitted key defaults to the RFC 1918
|
||||||
|
private ranges, an explicitly empty list trusts no one, and an explicit
|
||||||
|
list replaces the default); 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
|
- 2026-09-21 http.Server hardening (closes #92): added
|
||||||
`HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and
|
`HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and
|
||||||
`HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the
|
`HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the
|
||||||
@@ -130,8 +153,6 @@ P1: implement blocked networks configuration to extend SSRF protection
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- P1: rate limit global concurrent upstream fetches to prevent
|
|
||||||
resource exhaustion
|
|
||||||
- P1: strip EXIF and other metadata from processed images (privacy)
|
- P1: strip EXIF and other metadata from processed images (privacy)
|
||||||
- P2: security
|
- P2: security
|
||||||
- referer blacklist
|
- referer blacklist
|
||||||
|
|||||||
@@ -22,6 +22,30 @@ allowlist_hosts:
|
|||||||
- github.com
|
- github.com
|
||||||
- user-images.githubusercontent.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. When
|
||||||
|
# omitted, this defaults to the RFC 1918 private ranges (10.0.0.0/8,
|
||||||
|
# 172.16.0.0/12, 192.168.0.0/16), since pixa is deployed behind a proxy on
|
||||||
|
# a private network. An explicitly empty list ([]) trusts no one; an
|
||||||
|
# explicit list replaces the default. An invalid CIDR aborts startup.
|
||||||
|
# Uncomment to override the defaults with your proxy's address range.
|
||||||
|
# trusted_proxies:
|
||||||
|
# - 10.0.0.0/8
|
||||||
|
# - 2001:db8::/32
|
||||||
|
|
||||||
# Allow HTTP upstream (only for testing, always use HTTPS in production)
|
# Allow HTTP upstream (only for testing, always use HTTPS in production)
|
||||||
allow_http: false
|
allow_http: false
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
+143
-3
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -42,6 +43,8 @@ const (
|
|||||||
keyAllowHTTP = "allow_http"
|
keyAllowHTTP = "allow_http"
|
||||||
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
||||||
keyCacheMaxBytes = "cache_max_bytes"
|
keyCacheMaxBytes = "cache_max_bytes"
|
||||||
|
keyBlockedNetworks = "blocked_networks"
|
||||||
|
keyTrustedProxies = "trusted_proxies"
|
||||||
)
|
)
|
||||||
|
|
||||||
// placeholderSigningKey is the dummy signing_key shipped in
|
// placeholderSigningKey is the dummy signing_key shipped in
|
||||||
@@ -60,6 +63,7 @@ var (
|
|||||||
errNotAnInteger = errors.New("not an integer")
|
errNotAnInteger = errors.New("not an integer")
|
||||||
errNotABoolean = errors.New("not a boolean")
|
errNotABoolean = errors.New("not a boolean")
|
||||||
errNotAStringList = errors.New("not a list of strings")
|
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")
|
errNotAMetricsMap = errors.New("not a map of metrics settings")
|
||||||
errEmptyListEntry = errors.New("list contains an empty entry")
|
errEmptyListEntry = errors.New("list contains an empty entry")
|
||||||
errEmptyEntry = errors.New("contains an empty entry")
|
errEmptyEntry = errors.New("contains an empty entry")
|
||||||
@@ -109,6 +113,22 @@ type Config struct {
|
|||||||
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
||||||
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
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. An omitted key
|
||||||
|
// defaults to the RFC 1918 private ranges (see defaultTrustedProxies),
|
||||||
|
// since pixa is deployed behind a proxy on a private network; an
|
||||||
|
// explicitly empty list trusts nothing and always uses the peer
|
||||||
|
// address, and an explicit list replaces the default.
|
||||||
|
TrustedProxies []netip.Prefix
|
||||||
|
|
||||||
// CacheMaxBytes is the disk cache size limit in bytes. Zero
|
// CacheMaxBytes is the disk cache size limit in bytes. Zero
|
||||||
// disables the disk cache entirely. When cache_max_bytes is
|
// disables the disk cache entirely. When cache_max_bytes is
|
||||||
// omitted from the configuration, this holds the computed default
|
// omitted from the configuration, this holds the computed default
|
||||||
@@ -177,6 +197,24 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCIDRList returns a nil slice only when the key is absent; an
|
||||||
|
// explicitly empty list ([]) comes back non-nil and empty. An omitted
|
||||||
|
// key takes the RFC 1918 default, while an explicit empty list is left
|
||||||
|
// as trust-nothing.
|
||||||
|
if trustedProxies == nil {
|
||||||
|
trustedProxies = defaultTrustedProxies()
|
||||||
|
}
|
||||||
|
|
||||||
loader := &strictLoader{sc: sc}
|
loader := &strictLoader{sc: sc}
|
||||||
|
|
||||||
c := &Config{
|
c := &Config{
|
||||||
@@ -192,7 +230,9 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
AllowHTTP: loader.boolVal(keyAllowHTTP, false),
|
AllowHTTP: loader.boolVal(keyAllowHTTP, false),
|
||||||
UpstreamConnectionsPerHost: loader.intVal(
|
UpstreamConnectionsPerHost: loader.intVal(
|
||||||
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
||||||
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
||||||
|
BlockedNetworks: blockedNetworks,
|
||||||
|
TrustedProxies: trustedProxies,
|
||||||
}
|
}
|
||||||
|
|
||||||
// The computed default for cache_max_bytes needs a validated
|
// The computed default for cache_max_bytes needs a validated
|
||||||
@@ -224,7 +264,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
return nil, loader.err
|
return nil, loader.err
|
||||||
}
|
}
|
||||||
|
|
||||||
err := c.validate()
|
err = c.validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -308,7 +348,8 @@ func isKnownConfigKey(key string) bool {
|
|||||||
switch key {
|
switch key {
|
||||||
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
|
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
|
||||||
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
|
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
|
||||||
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, "env":
|
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, keyBlockedNetworks,
|
||||||
|
keyTrustedProxies, "env":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,3 +843,102 @@ func getStringSlice(sc *smartconfig.Config) []string {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultTrustedProxies returns the trusted_proxies default: the three RFC
|
||||||
|
// 1918 private ranges. pixa is always deployed behind a TLS-terminating
|
||||||
|
// reverse proxy, which in practice sits on a private network, so its
|
||||||
|
// forwarding headers are believed unless the operator says otherwise.
|
||||||
|
// Loopback is deliberately excluded: it is not an RFC 1918 range, and no
|
||||||
|
// deployment reaches pixa over it. A fresh slice is returned on each call so
|
||||||
|
// callers may hold it without aliasing shared state.
|
||||||
|
func defaultTrustedProxies() []netip.Prefix {
|
||||||
|
return []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("172.16.0.0/12"),
|
||||||
|
netip.MustParsePrefix("192.168.0.0/16"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,85 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTrustedProxiesConfig checks the trusted_proxies key wiring: an
|
||||||
|
// explicit CIDR list lands in TrustedProxies in order and replaces the
|
||||||
|
// default, an omitted key falls back to the RFC 1918 private ranges, and an
|
||||||
|
// explicitly empty list 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("explicit list replaces the default 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 defaults to the RFC 1918 ranges", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minimal config should load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]string, len(c.TrustedProxies))
|
||||||
|
for i, p := range c.TrustedProxies {
|
||||||
|
got[i] = p.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||||
|
if joined := strings.Join(got, ","); joined != want {
|
||||||
|
t.Errorf("TrustedProxies = %v, want the RFC 1918 ranges %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("explicitly empty list trusts no one", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+"trusted_proxies: []\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty trusted_proxies 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},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/clientip"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
"sneak.berlin/go/pixa/internal/templates"
|
"sneak.berlin/go/pixa/internal/templates"
|
||||||
@@ -47,7 +48,8 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Constant-time comparison to prevent timing attacks
|
// Constant-time comparison to prevent timing attacks
|
||||||
if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 {
|
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")
|
s.renderLogin(w, r, "Invalid signing key")
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -62,7 +64,8 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// Redirect to generator page
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,6 +111,8 @@ func (s *Handlers) initImageService() error {
|
|||||||
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fetcherCfg.BlockedNetworks = s.config.BlockedNetworks
|
||||||
|
|
||||||
// Create the service
|
// Create the service
|
||||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptrace"
|
"net/http/httptrace"
|
||||||
|
"net/netip"
|
||||||
neturl "net/url"
|
neturl "net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -46,6 +47,20 @@ const (
|
|||||||
localhostIPv6 = "::1"
|
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.
|
// Fetcher errors.
|
||||||
var (
|
var (
|
||||||
ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
|
ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
|
||||||
@@ -107,6 +122,9 @@ type Config struct {
|
|||||||
AllowHTTP bool
|
AllowHTTP bool
|
||||||
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
||||||
MaxConnectionsPerHost int
|
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.
|
// DefaultConfig returns a Config with sensible defaults.
|
||||||
@@ -142,9 +160,13 @@ func New(config *Config) *HTTPFetcher {
|
|||||||
config = DefaultConfig()
|
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{
|
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,
|
TLSHandshakeTimeout: DefaultTLSTimeout,
|
||||||
MaxIdleConns: DefaultMaxIdleConns,
|
MaxIdleConns: DefaultMaxIdleConns,
|
||||||
IdleConnTimeout: DefaultIdleConnTimeout,
|
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) {
|
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)
|
host, port, err := net.SplitHostPort(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -468,8 +532,10 @@ func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check all resolved IPs
|
// Check all resolved IPs
|
||||||
if slices.ContainsFunc(ips, isPrivateIP) {
|
for _, ip := range ips {
|
||||||
return nil, ErrSSRFBlocked
|
if isBlockedIP(ip, blocked) {
|
||||||
|
return nil, ErrSSRFBlocked
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect using the first valid IP
|
// Connect using the first valid IP
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,6 +13,7 @@ import (
|
|||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
"github.com/slok/go-http-metrics/middleware/std"
|
"github.com/slok/go-http-metrics/middleware/std"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/pixa/internal/clientip"
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
)
|
)
|
||||||
@@ -58,31 +58,34 @@ type Params struct {
|
|||||||
|
|
||||||
// Middleware provides HTTP middleware functions.
|
// Middleware provides HTTP middleware functions.
|
||||||
type Middleware struct {
|
type Middleware struct {
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
config *config.Config
|
config *config.Config
|
||||||
|
clientIP *clientip.Resolver
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Middleware instance.
|
// New creates a new Middleware instance.
|
||||||
func New(_ fx.Lifecycle, params Params) (*Middleware, error) {
|
func New(_ fx.Lifecycle, params Params) (*Middleware, error) {
|
||||||
s := &Middleware{
|
s := &Middleware{
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
|
clientIP: clientip.NewResolver(params.Config.TrustedProxies),
|
||||||
}
|
}
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ipFromHostPort(hp string) string {
|
// ClientIP returns a middleware that resolves the real client IP,
|
||||||
h, _, err := net.SplitHostPort(hp)
|
// honoring X-Forwarded-For only from trusted proxies, and stores it in
|
||||||
if err != nil {
|
// the request context for the logging middleware and handlers to read.
|
||||||
return ""
|
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 {
|
type loggingResponseWriter struct {
|
||||||
@@ -127,7 +130,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
|||||||
"request_id", reqID,
|
"request_id", reqID,
|
||||||
"referer", r.Referer(),
|
"referer", r.Referer(),
|
||||||
"proto", r.Proto,
|
"proto", r.Proto,
|
||||||
"remoteIP", ipFromHostPort(r.RemoteAddr),
|
"remoteIP", clientip.FromContext(ctx),
|
||||||
"status", lrw.statusCode,
|
"status", lrw.statusCode,
|
||||||
"response_bytes", lrw.bytesWritten,
|
"response_bytes", lrw.bytesWritten,
|
||||||
"latency_ms", latency.Milliseconds(),
|
"latency_ms", latency.Milliseconds(),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func (s *Server) SetupRoutes() {
|
|||||||
|
|
||||||
s.router.Use(middleware.Recoverer)
|
s.router.Use(middleware.Recoverer)
|
||||||
s.router.Use(middleware.RequestID)
|
s.router.Use(middleware.RequestID)
|
||||||
|
s.router.Use(s.mw.ClientIP())
|
||||||
s.router.Use(s.mw.SecurityHeaders())
|
s.router.Use(s.mw.SecurityHeaders())
|
||||||
s.router.Use(s.mw.Logging())
|
s.router.Use(s.mw.Logging())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user