mirror of
https://github.com/pgsty/minio.git
synced 2026-08-09 07:43:29 +03:00
feat: add a trusted-proxy boundary for the client source address
The address MinIO attributes a request to is read from X-Forwarded-For,
X-Real-IP or RFC 7239 Forwarded, and never from the connection unless all
three are absent. It becomes aws:SourceIp and the audit remotehost field,
so any client that can reach the API port chooses the value an IpAddress
condition is evaluated against and the address every logged action is
attributed to.
MINIO_API_TRUSTED_PROXIES now selects who may make that claim:
unset the historical behaviour, unchanged
none no forwarded header is believed; the TCP peer wins
<CIDRs> believed only from listed peers, chains read right-to-left
Reading right-to-left is what makes an appending proxy safe: each hop
appends the peer it actually saw, so an entry a client injected can only
sit to the left of one a proxy wrote. The stock nginx recipe
$proxy_add_x_forwarded_for appends, which leaves the client's entry
left-most - exactly where the untrusted path reads - so a deployment with
no direct route to the API port was forgeable too.
_MINIO_API_XFF_HEADER is deliberately untouched, in semantics and in read
timing. Widening it to mean "trust nothing" was implemented and reverted:
it is the only part of this change that could alter a deployed
configuration, and the new variable expresses the same guarantee at no
compatibility cost. Upstream's TestXFFDisabled is retained verbatim.
Notes on the allow-list mode, all covered by tests:
- it must name proxies, not the subnet they sit in; listed entries are
skipped while walking, so a range covering clients lets them forge
- a cluster must list its own nodes, because MinIO forwards between
them and a client can force a hop via the ListObjectsV2 token
- loopback is trusted as a peer, not as a chain entry, so FTP and SFTP
keep attributing their sessions
- the node-to-node forwarder drops X-Real-IP and Forwarded from a peer
not entitled to have set them
- the walk scans the header in place and stops after 100 hops, so a
long chain costs neither allocation nor unbounded work
No behaviour change for any deployment that does not set the new
variable: the untrusted path is a verbatim copy of the previous function
body, differentially verified against it over ~5.1M header combinations.
The LDAP STS allow-list now shares the list parser as pure code motion,
verified identical across every combination of 37 allow-list values and
21 peer addresses.
Co-authored-by: ChatGPT <noreply@openai.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
+102
-1
@@ -21,6 +21,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -317,6 +318,104 @@ func TestBucketPolicySourceIPCannotBeForged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The trust policy has to reach the decision, not merely the resolver. This
|
||||
// drives a forged X-Forwarded-For all the way through getConditionValues into a
|
||||
// real IpAddress evaluation under each mode. Everything else about the trust
|
||||
// modes is tested where the logic lives; this is the only test that would notice
|
||||
// if the resolver were correct but the policy engine were reading something else.
|
||||
func TestBucketPolicySourceIPForgeryAcrossTrustModes(t *testing.T) {
|
||||
_, cidr, err := net.ParseCIDR("10.0.0.0/8")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fn, err := condition.NewIPAddressFunc(condition.AWSSourceIP.ToKey(), cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bp := policy.BucketPolicy{
|
||||
Version: policy.DefaultVersion,
|
||||
Statements: []policy.BPStatement{{
|
||||
Effect: policy.Allow,
|
||||
Principal: policy.NewPrincipal("*"),
|
||||
Actions: policy.NewActionSet(policy.GetObjectAction),
|
||||
Resources: policy.NewResourceSet(policy.NewResource("bkt/*")),
|
||||
Conditions: condition.NewFunctions(fn),
|
||||
}},
|
||||
}
|
||||
|
||||
// An address inside the permitted range, asserted by a client that is not.
|
||||
const forgedClaim = "10.1.2.3"
|
||||
const outsider = "203.0.113.5:12345"
|
||||
const proxy = "192.0.2.7:9000"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
proxies string
|
||||
peer string
|
||||
allowed bool
|
||||
}{{
|
||||
// Documented, and the reason the allow-list exists.
|
||||
name: "default mode believes the claim",
|
||||
peer: outsider,
|
||||
allowed: true,
|
||||
}, {
|
||||
name: "trusting nobody ignores the claim",
|
||||
proxies: handlers.TrustNoProxies,
|
||||
peer: outsider,
|
||||
allowed: false,
|
||||
}, {
|
||||
name: "allow-list ignores an unlisted peer's claim",
|
||||
proxies: "192.0.2.7",
|
||||
peer: outsider,
|
||||
allowed: false,
|
||||
}, {
|
||||
// The allow-list must not break the deployment it exists to serve.
|
||||
name: "allow-list still honors its own proxy",
|
||||
proxies: "192.0.2.7",
|
||||
peer: proxy,
|
||||
allowed: true,
|
||||
}}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv(handlers.EnvTrustedProxies)
|
||||
if err := handlers.ConfigureSourceIPTrust(); err != nil {
|
||||
t.Fatalf("restoring the default policy: %v", err)
|
||||
}
|
||||
})
|
||||
if tt.proxies == "" {
|
||||
os.Unsetenv(handlers.EnvTrustedProxies)
|
||||
} else {
|
||||
t.Setenv(handlers.EnvTrustedProxies, tt.proxies)
|
||||
}
|
||||
if err := handlers.ConfigureSourceIPTrust(); err != nil {
|
||||
t.Fatalf("configuring %q: %v", tt.proxies, err)
|
||||
}
|
||||
|
||||
r, err := http.NewRequest(http.MethodGet, "http://minio.local/bkt/obj", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.RemoteAddr = tt.peer
|
||||
r.Header.Set("X-Forwarded-For", forgedClaim)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := bp.IsAllowed(policy.BucketPolicyArgs{
|
||||
Action: policy.GetObjectAction,
|
||||
BucketName: "bkt",
|
||||
ObjectName: "obj",
|
||||
ConditionValues: getConditionValues(r, "us-east-1", auth.Credentials{AccessKey: "lowpriv"}),
|
||||
})
|
||||
if got != tt.allowed {
|
||||
t.Errorf("IsAllowed = %v, want %v (peer %s claiming %s)", got, tt.allowed, tt.peer, forgedClaim)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// aws:SourceIp must be whatever the hardened resolver decided and nothing else.
|
||||
// The resolver is where the forwarded-header trust policy is enforced and where
|
||||
// its three modes are tested (internal/handlers/proxy_test.go); this pins the
|
||||
@@ -327,7 +426,9 @@ func TestBucketPolicySourceIPCannotBeForged(t *testing.T) {
|
||||
// each of the three forwarded headers still sets aws:SourceIp, and so an
|
||||
// IpAddress condition is only as good as the network path to the API port.
|
||||
// Enforcing such a condition against a client with direct access requires
|
||||
// MINIO_API_TRUSTED_PROXIES or _MINIO_API_XFF_HEADER=off.
|
||||
// MINIO_API_TRUSTED_PROXIES. Note that _MINIO_API_XFF_HEADER=off does not
|
||||
// achieve it: the loop below covers all three headers precisely because
|
||||
// suppressing one of them only moves the answer to the next.
|
||||
func TestGetConditionValuesSourceIPMatchesResolver(t *testing.T) {
|
||||
for _, header := range []map[string]string{
|
||||
nil,
|
||||
|
||||
@@ -54,6 +54,7 @@ import (
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/color"
|
||||
"github.com/minio/minio/internal/config"
|
||||
"github.com/minio/minio/internal/handlers"
|
||||
"github.com/minio/minio/internal/kms"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
"github.com/minio/pkg/v3/certs"
|
||||
@@ -711,6 +712,16 @@ func serverHandleEarlyEnvVars() {
|
||||
|
||||
func serverHandleEnvVars() {
|
||||
var err error
|
||||
|
||||
// Re-read the source-address trust policy now that loadEnvVarsFromFiles has
|
||||
// run: a policy taken at package initialisation would miss every deployment
|
||||
// configured through MINIO_CONFIG_ENV_FILE. Refuse to start on a malformed
|
||||
// allow-list rather than resolve aws:SourceIp and every audit client address
|
||||
// by a rule the operator did not write.
|
||||
if err := handlers.ConfigureSourceIPTrust(); err != nil {
|
||||
logger.Fatal(err, "Invalid %s value in environment variable", handlers.EnvTrustedProxies)
|
||||
}
|
||||
|
||||
if globalBrowserEnabled {
|
||||
if redirectURL := env.Get(config.EnvBrowserRedirectURL, ""); redirectURL != "" {
|
||||
u, err := xnet.ParseHTTPURL(redirectURL)
|
||||
|
||||
Reference in New Issue
Block a user