Files
seaweedfs/weed/s3api/policy_engine/trusted_proxies_test.go
T
Chris LuandGitHub 02749c1192 s3api: configurable trusted-proxy allowlist for aws:SourceIp (#11302) (#11315)
* s3api: add TrustedProxies allowlist helper for aws:SourceIp extraction

Introduces a policy_engine.TrustedProxies type that parses a
comma-separated list of bare IPs and CIDRs (mirroring Guard.UpdateWhiteList)
and extracts the client IP for aws:SourceIp condition evaluation.

When the direct TCP peer is in the allowlist, X-Forwarded-For is walked
right-to-left skipping trusted hops (then X-Real-Ip); otherwise the direct
peer address is returned. This is the building block for restoring
configurable forwarded-header trust removed in b88156f (#11231), as
proposed in #11302.

* s3api: honor trusted-proxy allowlist in bucket/IAM policy engine

Make ExtractConditionValuesFromRequest a method on *PolicyEngine so it
can use the engine TrustedProxies when resolving aws:SourceIp. With no
allowlist configured the behavior is unchanged from b88156f: the direct
TCP peer is used and forwarded headers are ignored. When an allowlist is
configured via SetTrustedProxies, requests from a trusted peer honor
X-Forwarded-For (right-to-left) then X-Real-Ip.

Update the two call sites (auth_credentials.go, s3api_bucket_policy_engine.go)
and the engine tests to the method form, and add a regression test for the
trusted-proxy path.

* s3api: honor trusted-proxy allowlist in IAM role/session policies

Make extractRequestContext and extractSourceIP methods on
*S3IAMIntegration so they can use the integration TrustedProxies when
resolving aws:SourceIp. With no allowlist configured the behavior is
unchanged from b88156f: the direct TCP peer is used and forwarded
headers are ignored. When an allowlist is configured via
SetTrustedProxies, requests from a trusted peer honor X-Forwarded-For
(right-to-left) then X-Real-Ip.

Update the call site in isActionExplicitlyDeniedByIAM to type-assert
the integration and use the method, and add a regression test for the
trusted-proxy path.

* s3api: load [s3.trusted_proxies] from security.toml and wire to engines

Read s3.trusted_proxies.white_list (comma-separated IPs/CIDRs) from
security.toml and propagate the allowlist to the bucket policy engine,
the IAM policy engine (persisted across rebuilds via
IdentityAccessManagement.SetTrustedProxies), and the IAM integration.
Reloaded on SIGHUP alongside the JWT signing keys. Document the new
section in the scaffold security.toml.

Closes #11302.

* s3api: harden TrustedProxies parsing and X-Forwarded-For traversal

Canonicalize bare IP entries (via net.ParseIP + String) so non-canonical
IPv6 allowlist entries such as 2001:0db8::1 match peers rendered as
2001:db8::1, and log+skip unparseable bare entries instead of storing
them inertly.

When walking X-Forwarded-For right-to-left, stop at the first malformed
(non-empty, unparseable) entry instead of skipping it, and only fall
back to the leftmost valid IP when the chain was well-formed. This
prevents a malformed hop from masking a forged IP to its left.

Addresses review feedback on #11315.

* s3api: make TrustedProxies reload race-free via atomic.Pointer

Store the trusted-proxy allowlist behind sync/atomic.Pointer in
PolicyEngine and S3IAMIntegration so SIGHUP reloads (which swap the
allowlist) cannot race with concurrent request handlers reading it.
This mirrors the existing Guard guardState pattern. The
IdentityAccessManagement copy is already protected by iam.m.

Addresses review feedback on #11315.
2026-09-14 13:54:26 -07:00

129 lines
3.9 KiB
Go

package policy_engine
import (
"net/http"
"testing"
)
func newReq(remoteAddr string, xff, xRealIP string) *http.Request {
r := &http.Request{RemoteAddr: remoteAddr, Header: http.Header{}}
if xff != "" {
r.Header.Set("X-Forwarded-For", xff)
}
if xRealIP != "" {
r.Header.Set("X-Real-Ip", xRealIP)
}
return r
}
func TestTrustedProxies_IsTrusted(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.5", "192.168.0.0/16"})
if !tp.IsTrusted("10.0.0.5") {
t.Error("bare IP should be trusted")
}
if !tp.IsTrusted("192.168.1.100") {
t.Error("IP in CIDR should be trusted")
}
if tp.IsTrusted("8.8.8.8") {
t.Error("public IP should not be trusted")
}
if tp.IsTrusted("not-an-ip") {
t.Error("invalid IP should not be trusted")
}
}
func TestTrustedProxies_CanonicalizesBareIPv6(t *testing.T) {
tp := NewTrustedProxies([]string{"2001:0db8::1"})
if !tp.IsTrusted("2001:db8::1") {
t.Error("canonical IPv6 should match non-canonical allowlist entry")
}
}
func TestTrustedProxies_InvalidBareIPSkipped(t *testing.T) {
tp := NewTrustedProxies([]string{"not-an-ip", "10.0.0.5"})
if tp.IsTrusted("not-an-ip") {
t.Error("invalid entry should not be stored")
}
if !tp.IsTrusted("10.0.0.5") {
t.Error("valid entry after invalid one should still load")
}
}
func TestTrustedProxies_NilNotTrusted(t *testing.T) {
var tp *TrustedProxies
if tp.IsTrusted("127.0.0.1") {
t.Error("nil TrustedProxies should not trust any IP")
}
}
func TestTrustedProxies_ExtractSourceIP_DirectPeerWhenUntrusted(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
r := newReq("203.0.113.5:1234", "8.8.8.8", "1.1.1.1")
if got := tp.ExtractSourceIP(r); got != "203.0.113.5" {
t.Errorf("untrusted peer: want 203.0.113.5, got %s", got)
}
}
func TestTrustedProxies_ExtractSourceIP_XForwardedFor(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
r := newReq("10.0.0.1:1234", "8.8.8.8, 10.0.0.2", "")
if got := tp.ExtractSourceIP(r); got != "8.8.8.8" {
t.Errorf("trusted proxy: want 8.8.8.8, got %s", got)
}
}
func TestTrustedProxies_ExtractSourceIP_AllTrustedReturnsLeftmost(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
r := newReq("10.0.0.1:1234", "10.0.0.5, 10.0.0.6", "")
if got := tp.ExtractSourceIP(r); got != "10.0.0.5" {
t.Errorf("all-trusted chain: want leftmost 10.0.0.5, got %s", got)
}
}
func TestTrustedProxies_ExtractSourceIP_MalformedXFFFallsBackToPeer(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
r := newReq("10.0.0.1:1234", "8.8.8.8, garbage", "")
if got := tp.ExtractSourceIP(r); got != "10.0.0.1" {
t.Errorf("malformed XFF: want direct peer 10.0.0.1, got %s", got)
}
}
func TestTrustedProxies_ExtractSourceIP_XRealIP(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
r := newReq("10.0.0.1:1234", "", "8.8.8.8")
if got := tp.ExtractSourceIP(r); got != "8.8.8.8" {
t.Errorf("X-Real-Ip: want 8.8.8.8, got %s", got)
}
}
func TestTrustedProxies_ExtractSourceIP_XForwardedForPreferredOverXRealIP(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
r := newReq("10.0.0.1:1234", "8.8.8.8", "1.1.1.1")
if got := tp.ExtractSourceIP(r); got != "8.8.8.8" {
t.Errorf("XFF should win over X-Real-Ip: want 8.8.8.8, got %s", got)
}
}
func TestTrustedProxies_ExtractSourceIP_NoTrustedProxiesUsesPeer(t *testing.T) {
tp := NewTrustedProxies(nil)
r := newReq("10.0.0.1:1234", "8.8.8.8", "1.1.1.1")
if got := tp.ExtractSourceIP(r); got != "10.0.0.1" {
t.Errorf("empty allowlist: want 10.0.0.1, got %s", got)
}
}
func TestTrustedProxies_ExtractSourceIP_NilRequest(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
if got := tp.ExtractSourceIP(nil); got != "" {
t.Errorf("nil request: want empty, got %s", got)
}
}
func TestTrustedProxies_ExtractSourceIP_UnixSocket(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
r := newReq("@", "", "")
if got := tp.ExtractSourceIP(r); got != "@" {
t.Errorf("unix socket: want @, got %s", got)
}
}