fix: tighten LDAP STS rate-limit accounting

Prevent LDAP STS reservation cancel paths from over-crediting rate-limit buckets by capping refill and refund capacity against in-flight reservations.

Add an explicit trusted-proxy allowlist for LDAP STS source bucketing, prefer clean X-Real-IP values on trusted peers, and extend tests/docs for the new behavior.
This commit is contained in:
Feng Ruohang
2026-04-16 23:22:13 +08:00
parent 9e10f6d9a0
commit f44110890b
6 changed files with 385 additions and 11 deletions
+45 -5
View File
@@ -37,6 +37,7 @@ import (
"github.com/minio/minio/internal/auth" "github.com/minio/minio/internal/auth"
idldap "github.com/minio/minio/internal/config/identity/ldap" idldap "github.com/minio/minio/internal/config/identity/ldap"
"github.com/minio/minio/internal/config/identity/openid" "github.com/minio/minio/internal/config/identity/openid"
"github.com/minio/minio/internal/handlers"
"github.com/minio/minio/internal/hash/sha256" "github.com/minio/minio/internal/hash/sha256"
xhttp "github.com/minio/minio/internal/http" xhttp "github.com/minio/minio/internal/http"
"github.com/minio/minio/internal/logger" "github.com/minio/minio/internal/logger"
@@ -441,17 +442,25 @@ func (l *stsLDAPLoginKeyLimiterSet) refillLocked(now time.Time, entry *stsLDAPLo
} }
if l.refillEvery <= 0 { if l.refillEvery <= 0 {
entry.lastRefill = now entry.lastRefill = now
entry.tokens = float64(l.burst) entry.tokens = l.maxTokensLocked(entry)
return return
} }
entry.tokens += float64(now.Sub(lastRefill)) / float64(l.refillEvery) entry.tokens += float64(now.Sub(lastRefill)) / float64(l.refillEvery)
if maxTokens := float64(l.burst); entry.tokens > maxTokens { if maxTokens := l.maxTokensLocked(entry); entry.tokens > maxTokens {
entry.tokens = maxTokens entry.tokens = maxTokens
} }
entry.lastRefill = now entry.lastRefill = now
} }
func (l *stsLDAPLoginKeyLimiterSet) maxTokensLocked(entry *stsLDAPLoginKeyLimiter) float64 {
maxTokens := l.burst - entry.inFlight
if maxTokens < 0 {
return 0
}
return float64(maxTokens)
}
func (r *stsLDAPLoginKeyReservation) CommitAt(now time.Time) { func (r *stsLDAPLoginKeyReservation) CommitAt(now time.Time) {
r.finalize(now, false) r.finalize(now, false)
} }
@@ -479,7 +488,7 @@ func (r *stsLDAPLoginKeyReservation) finalize(now time.Time, refund bool) {
} }
if refund { if refund {
r.entry.tokens++ r.entry.tokens++
if maxTokens := float64(r.set.burst); r.entry.tokens > maxTokens { if maxTokens := r.set.maxTokensLocked(r.entry); r.entry.tokens > maxTokens {
r.entry.tokens = maxTokens r.entry.tokens = maxTokens
} }
} }
@@ -488,11 +497,42 @@ func (r *stsLDAPLoginKeyReservation) finalize(now time.Time, refund bool) {
} }
func getSTSLDAPLoginSourceIP(r *http.Request) string { func getSTSLDAPLoginSourceIP(r *http.Request) string {
sourceIP, _, err := net.SplitHostPort(r.RemoteAddr) peerIP := getSTSLDAPLoginCanonicalIP(r.RemoteAddr)
sourceIP := peerIP
if sourceIP == "" {
sourceIP = getSTSLDAPLoginPeerAddr(r.RemoteAddr)
}
if peerIP != "" && globalIAMSys != nil && globalIAMSys.LDAPConfig.IsSTSTrustedProxy(peerIP) {
if forwardedIP := getSTSLDAPTrustedProxySourceIP(r); forwardedIP != "" {
return forwardedIP
}
}
return sourceIP
}
func getSTSLDAPTrustedProxySourceIP(r *http.Request) string {
if realIP := getSTSLDAPLoginCanonicalIP(r.Header.Get("X-Real-IP")); realIP != "" {
return realIP
}
return getSTSLDAPLoginCanonicalIP(handlers.GetSourceIPFromHeaders(r))
}
func getSTSLDAPLoginPeerAddr(remoteAddr string) string {
sourceIP, _, err := net.SplitHostPort(remoteAddr)
if err == nil { if err == nil {
return sourceIP return sourceIP
} }
return r.RemoteAddr return remoteAddr
}
func getSTSLDAPLoginCanonicalIP(addr string) string {
addr = strings.TrimSpace(getSTSLDAPLoginPeerAddr(addr))
addr = strings.TrimPrefix(addr, "[")
addr = strings.TrimSuffix(addr, "]")
if ip := net.ParseIP(addr); ip != nil {
return ip.String()
}
return ""
} }
// reserveSTSLDAPLogin acquires immediate tokens from the per-source and // reserveSTSLDAPLogin acquires immediate tokens from the per-source and
+211 -6
View File
@@ -1008,7 +1008,34 @@ func withGlobalSTSLDAPLoginRateLimiterForTest(limiter *stsLDAPLoginRateLimiter,
fn() fn()
} }
func (s *TestSuiteIAM) postLDAPSTS(c *check, username, password string) ldapSTSHTTPResult { func withLDAPSTSTrustedProxiesForTest(t *testing.T, trustedProxies string, fn func()) {
t.Helper()
previousIAMSys := globalIAMSys
if globalIAMSys == nil {
globalIAMSys = &IAMSys{}
}
previous := globalIAMSys.LDAPConfig.Clone()
if err := globalIAMSys.LDAPConfig.SetSTSTrustedProxies(trustedProxies); err != nil {
t.Fatalf("unable to set LDAP STS trusted proxies for test: %v", err)
}
defer func() {
globalIAMSys.LDAPConfig = previous
globalIAMSys = previousIAMSys
}()
fn()
}
func singleHeader(key, value string) http.Header {
header := make(http.Header)
if key != "" {
header.Set(key, value)
}
return header
}
func (s *TestSuiteIAM) postLDAPSTSWithHeaders(c *check, username, password string, headers http.Header) ldapSTSHTTPResult {
c.Helper() c.Helper()
ctx, cancel := context.WithTimeout(context.Background(), testDefaultTimeout) ctx, cancel := context.WithTimeout(context.Background(), testDefaultTimeout)
@@ -1025,6 +1052,11 @@ func (s *TestSuiteIAM) postLDAPSTS(c *check, username, password string) ldapSTSH
c.Fatalf("unexpected request creation error: %v", err) c.Fatalf("unexpected request creation error: %v", err)
} }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for key, values := range headers {
for _, value := range values {
req.Header.Add(key, value)
}
}
resp, err := s.TestSuiteCommon.client.Do(req) resp, err := s.TestSuiteCommon.client.Do(req)
if err != nil { if err != nil {
@@ -1044,6 +1076,11 @@ func (s *TestSuiteIAM) postLDAPSTS(c *check, username, password string) ldapSTSH
} }
} }
func (s *TestSuiteIAM) postLDAPSTS(c *check, username, password string) ldapSTSHTTPResult {
c.Helper()
return s.postLDAPSTSWithHeaders(c, username, password, nil)
}
func (s *TestSuiteIAM) postLDAPSTSForError(c *check, username, password string) ldapSTSErrorResult { func (s *TestSuiteIAM) postLDAPSTSForError(c *check, username, password string) ldapSTSErrorResult {
c.Helper() c.Helper()
@@ -1198,6 +1235,32 @@ func (s *TestSuiteIAM) TestLDAPSTSUpstreamFailure(c *check) {
) )
} }
func (s *TestSuiteIAM) TestLDAPSTSTrustedProxyRateLimit(c *check) {
withLDAPSTSTrustedProxiesForTest(c.T, "127.0.0.0/8,::1/128", func() {
withGlobalSTSLDAPLoginRateLimiterForTest(
newSTSLDAPLoginRateLimiter(time.Hour, 1, stsLDAPLoginEntryTTL),
func() {
// These usernames intentionally do not exist. The test asserts that
// LDAP user-not-found stays classified as an auth error, so failed
// attempts still commit the reservation and hit the source bucket.
first := s.postLDAPSTSWithHeaders(c, "missing-user-a", "nottherightpassword", singleHeader("X-Real-IP", "203.0.113.10"))
second := s.postLDAPSTSWithHeaders(c, "missing-user-b", "nottherightpassword", singleHeader("X-Real-IP", "198.51.100.23"))
third := s.postLDAPSTSWithHeaders(c, "missing-user-c", "nottherightpassword", singleHeader("X-Real-IP", "203.0.113.10"))
if first.StatusCode != http.StatusBadRequest {
c.Fatalf("expected first trusted-proxy LDAP STS auth failure to return %d, got %d body: %s", http.StatusBadRequest, first.StatusCode, first.Body)
}
if second.StatusCode != http.StatusBadRequest {
c.Fatalf("expected a different forwarded client IP behind the same trusted proxy to avoid source throttling, got %d body: %s", second.StatusCode, second.Body)
}
if third.StatusCode != http.StatusTooManyRequests {
c.Fatalf("expected the same forwarded client IP behind the trusted proxy to be throttled with %d, got %d body: %s", http.StatusTooManyRequests, third.StatusCode, third.Body)
}
},
)
})
}
func TestIAMWithLDAPSecurityServerSuite(t *testing.T) { func TestIAMWithLDAPSecurityServerSuite(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -1227,6 +1290,12 @@ func TestIAMWithLDAPSecurityServerSuite(t *testing.T) {
suite.TestLDAPSTSUpstreamFailure(c) suite.TestLDAPSTSUpstreamFailure(c)
}, },
}, },
{
name: "TrustedProxyRateLimit",
run: func(suite *TestSuiteIAM, c *check, ldapServer string) {
suite.TestLDAPSTSTrustedProxyRateLimit(c)
},
},
} }
for i, testCase := range iamTestSuites { for i, testCase := range iamTestSuites {
@@ -1397,6 +1466,35 @@ func TestSTSLDAPLoginRateLimiterReserveCancel(t *testing.T) {
reservation.Cancel() reservation.Cancel()
} }
func TestSTSLDAPLoginKeyLimiterCancelDoesNotOverCreditAfterRefill(t *testing.T) {
set := newSTSLDAPLoginKeyLimiterSet(10*time.Millisecond, 2, time.Minute)
start := time.Unix(0, 0)
first := set.Reserve(start, "192.0.2.10")
if first == nil {
t.Fatal("expected first reservation to succeed")
}
second := set.Reserve(start.Add(5*time.Millisecond), "192.0.2.10")
if second == nil {
t.Fatal("expected second reservation to succeed while one token remains available")
}
first.CancelAt(start.Add(10 * time.Millisecond))
third := set.Reserve(start.Add(10*time.Millisecond), "192.0.2.10")
if third == nil {
t.Fatal("expected canceled reservation to restore exactly one slot")
}
defer third.CancelAt(start.Add(10 * time.Millisecond))
if extra := set.Reserve(start.Add(10*time.Millisecond), "192.0.2.10"); extra != nil {
extra.CancelAt(start.Add(10 * time.Millisecond))
t.Fatal("expected only one slot to be restored after cancel; got over-credit from refill")
}
second.CancelAt(start.Add(10 * time.Millisecond))
}
func TestSTSLDAPLoginRateLimiterReserveRollbackOnCompositeFailure(t *testing.T) { func TestSTSLDAPLoginRateLimiterReserveRollbackOnCompositeFailure(t *testing.T) {
limiter := newSTSLDAPLoginRateLimiter(time.Hour, 1, time.Minute) limiter := newSTSLDAPLoginRateLimiter(time.Hour, 1, time.Minute)
@@ -1531,7 +1629,7 @@ func TestGetSTSLDAPLoginSourceIPIgnoresSpoofedForwardingHeaders(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
req := &http.Request{ req := &http.Request{
Header: http.Header{tt.headerKey: []string{tt.headerValue}}, Header: singleHeader(tt.headerKey, tt.headerValue),
RemoteAddr: "192.0.2.10:9000", RemoteAddr: "192.0.2.10:9000",
} }
@@ -1542,6 +1640,73 @@ func TestGetSTSLDAPLoginSourceIPIgnoresSpoofedForwardingHeaders(t *testing.T) {
} }
} }
func TestGetSTSLDAPLoginSourceIPUsesForwardedHeadersForTrustedProxy(t *testing.T) {
tests := []struct {
name string
headerKey string
headerValue string
want string
}{
{
name: "x-forwarded-for",
headerKey: "X-Forwarded-For",
headerValue: "203.0.113.10, 198.51.100.24",
want: "203.0.113.10",
},
{
name: "x-real-ip",
headerKey: "X-Real-IP",
headerValue: "203.0.113.10",
want: "203.0.113.10",
},
{
name: "forwarded",
headerKey: "Forwarded",
headerValue: `for=203.0.113.10;proto=https`,
want: "203.0.113.10",
},
}
withLDAPSTSTrustedProxiesForTest(t, "192.0.2.0/24", func() {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &http.Request{
Header: singleHeader(tt.headerKey, tt.headerValue),
RemoteAddr: "192.0.2.10:9000",
}
if got := getSTSLDAPLoginSourceIP(req); got != tt.want {
t.Fatalf("expected trusted proxy header %s to resolve %q, got %q", tt.headerKey, tt.want, got)
}
})
}
})
}
func TestGetSTSLDAPLoginSourceIPTrustedProxyPrefersXRealIPOverXForwardedFor(t *testing.T) {
withLDAPSTSTrustedProxiesForTest(t, "192.0.2.0/24", func() {
req := &http.Request{
Header: make(http.Header),
RemoteAddr: "192.0.2.10:9000",
}
req.Header.Set("X-Forwarded-For", "198.51.100.99, 203.0.113.10")
req.Header.Set("X-Real-IP", "203.0.113.10")
if got := getSTSLDAPLoginSourceIP(req); got != "203.0.113.10" {
t.Fatalf("expected trusted proxy path to prefer X-Real-IP over appended X-Forwarded-For, got %q", got)
}
})
}
func TestGetSTSLDAPLoginSourceIPTrustedProxyFallsBackToPeerWithoutForwardingHeaders(t *testing.T) {
withLDAPSTSTrustedProxiesForTest(t, "192.0.2.0/24", func() {
req := &http.Request{RemoteAddr: "192.0.2.10:9000"}
if got := getSTSLDAPLoginSourceIP(req); got != "192.0.2.10" {
t.Fatalf("expected trusted proxy path without forwarding headers to fall back to peer address, got %q", got)
}
})
}
func TestReserveSTSLDAPLoginUsesPeerAddressBuckets(t *testing.T) { func TestReserveSTSLDAPLoginUsesPeerAddressBuckets(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -1574,12 +1739,12 @@ func TestReserveSTSLDAPLoginUsesPeerAddressBuckets(t *testing.T) {
limiter := newSTSLDAPLoginRateLimiter(time.Hour, 2, time.Minute) limiter := newSTSLDAPLoginRateLimiter(time.Hour, 2, time.Minute)
withGlobalSTSLDAPLoginRateLimiterForTest(limiter, func() { withGlobalSTSLDAPLoginRateLimiterForTest(limiter, func() {
req1 := &http.Request{ req1 := &http.Request{
Header: http.Header{tt.headerKey: []string{tt.firstValue}}, Header: singleHeader(tt.headerKey, tt.firstValue),
RemoteAddr: "192.0.2.10:9000", RemoteAddr: "192.0.2.10:9000",
Form: url.Values{stsLDAPUsername: []string{"alice"}}, Form: url.Values{stsLDAPUsername: []string{"alice"}},
} }
req2 := &http.Request{ req2 := &http.Request{
Header: http.Header{tt.headerKey: []string{tt.secondValue}}, Header: singleHeader(tt.headerKey, tt.secondValue),
RemoteAddr: "192.0.2.10:9001", RemoteAddr: "192.0.2.10:9001",
Form: url.Values{stsLDAPUsername: []string{"bob"}}, Form: url.Values{stsLDAPUsername: []string{"bob"}},
} }
@@ -1609,12 +1774,12 @@ func TestReserveSTSLDAPLoginUsesPeerAddressBuckets(t *testing.T) {
limiter := newSTSLDAPLoginRateLimiter(time.Hour, 1, time.Minute) limiter := newSTSLDAPLoginRateLimiter(time.Hour, 1, time.Minute)
withGlobalSTSLDAPLoginRateLimiterForTest(limiter, func() { withGlobalSTSLDAPLoginRateLimiterForTest(limiter, func() {
req1 := &http.Request{ req1 := &http.Request{
Header: http.Header{tt.headerKey: []string{tt.firstValue}}, Header: singleHeader(tt.headerKey, tt.firstValue),
RemoteAddr: "192.0.2.10:9000", RemoteAddr: "192.0.2.10:9000",
Form: url.Values{stsLDAPUsername: []string{"alice"}}, Form: url.Values{stsLDAPUsername: []string{"alice"}},
} }
req2 := &http.Request{ req2 := &http.Request{
Header: http.Header{tt.headerKey: []string{tt.firstValue}}, Header: singleHeader(tt.headerKey, tt.firstValue),
RemoteAddr: "192.0.2.11:9000", RemoteAddr: "192.0.2.11:9000",
Form: url.Values{stsLDAPUsername: []string{"bob"}}, Form: url.Values{stsLDAPUsername: []string{"bob"}},
} }
@@ -1645,6 +1810,46 @@ func TestReserveSTSLDAPLoginUsesPeerAddressBuckets(t *testing.T) {
} }
} }
func TestReserveSTSLDAPLoginUsesForwardedBucketsForTrustedProxy(t *testing.T) {
limiter := newSTSLDAPLoginRateLimiter(time.Hour, 1, time.Minute)
withGlobalSTSLDAPLoginRateLimiterForTest(limiter, func() {
withLDAPSTSTrustedProxiesForTest(t, "192.0.2.0/24", func() {
req1 := &http.Request{
Header: singleHeader("X-Forwarded-For", "203.0.113.10"),
RemoteAddr: "192.0.2.10:9000",
Form: url.Values{stsLDAPUsername: []string{"alice"}},
}
req2 := &http.Request{
Header: singleHeader("X-Forwarded-For", "198.51.100.23"),
RemoteAddr: "192.0.2.10:9001",
Form: url.Values{stsLDAPUsername: []string{"bob"}},
}
reservation1 := reserveSTSLDAPLogin(req1)
if reservation1 == nil {
t.Fatal("expected first reservation through trusted proxy to succeed")
}
defer reservation1.Cancel()
reservation2 := reserveSTSLDAPLogin(req2)
if reservation2 == nil {
t.Fatal("expected forwarded client IPs behind the same trusted proxy to use distinct source buckets")
}
defer reservation2.Cancel()
if got := len(limiter.source.entries); got != 2 {
t.Fatalf("expected distinct forwarded client IPs to use two source buckets, got %d", got)
}
if _, ok := limiter.source.entries["203.0.113.10"]; !ok {
t.Fatalf("expected source bucket for forwarded client IP %q, got keys %v", "203.0.113.10", limiter.source.entries)
}
if _, ok := limiter.source.entries["198.51.100.23"]; !ok {
t.Fatalf("expected source bucket for forwarded client IP %q, got keys %v", "198.51.100.23", limiter.source.entries)
}
})
})
}
func TestLDAPBindErrorToSTS(t *testing.T) { func TestLDAPBindErrorToSTS(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+15
View File
@@ -46,6 +46,7 @@ MINIO_IDENTITY_LDAP_GROUP_SEARCH_BASE_DN (list) ";" separated list of gr
MINIO_IDENTITY_LDAP_TLS_SKIP_VERIFY (on|off) trust server TLS without verification (default: 'off') MINIO_IDENTITY_LDAP_TLS_SKIP_VERIFY (on|off) trust server TLS without verification (default: 'off')
MINIO_IDENTITY_LDAP_SERVER_INSECURE (on|off) allow plain text connection to AD/LDAP server (default: 'off') MINIO_IDENTITY_LDAP_SERVER_INSECURE (on|off) allow plain text connection to AD/LDAP server (default: 'off')
MINIO_IDENTITY_LDAP_SERVER_STARTTLS (on|off) use StartTLS connection to AD/LDAP server (default: 'off') MINIO_IDENTITY_LDAP_SERVER_STARTTLS (on|off) use StartTLS connection to AD/LDAP server (default: 'off')
MINIO_IDENTITY_LDAP_STS_TRUSTED_PROXIES (list) "," separated list of trusted proxy IPs or CIDRs whose forwarded client IP headers may be used for LDAP STS rate limiting
MINIO_IDENTITY_LDAP_COMMENT (sentence) optionally add a comment to this setting MINIO_IDENTITY_LDAP_COMMENT (sentence) optionally add a comment to this setting
``` ```
@@ -61,6 +62,20 @@ MINIO_IDENTITY_LDAP_SERVER_INSECURE (on|off) allow plain text connect
MINIO_IDENTITY_LDAP_SERVER_STARTTLS (on|off) use StartTLS connection to AD/LDAP server, defaults to "off" MINIO_IDENTITY_LDAP_SERVER_STARTTLS (on|off) use StartTLS connection to AD/LDAP server, defaults to "off"
``` ```
### LDAP STS rate limiting behind trusted proxies
LDAP STS login throttling is keyed by the socket peer address by default. This is the safe default because MinIO does **not** trust `X-Forwarded-For`, `X-Real-IP`, or `Forwarded` headers for this security-sensitive rate-limit key unless you opt in explicitly.
If MinIO is deployed behind a trusted reverse proxy, load balancer, or API gateway and you want LDAP STS throttling to bucket by the forwarded client IP instead of the proxy peer address, configure:
```
MINIO_IDENTITY_LDAP_STS_TRUSTED_PROXIES (list) "," separated list of trusted proxy IPs or CIDRs
```
Only requests whose peer address matches this allowlist may supply forwarded client IP headers for LDAP STS rate limiting. Requests from all other peers continue to use the peer address directly.
For trusted-proxy deployments, prefer setting a clean single-value `X-Real-IP` header. If you rely on `X-Forwarded-For`, make sure the proxy strips or overwrites any inbound forwarding headers instead of appending to a client-supplied value. In nginx, prefer `$remote_addr` for the trusted client IP header; `proxy_add_x_forwarded_for` appends and is not suitable unless you first clear inbound forwarding headers.
The server address variable is _required_. TLS is assumed to be on by default. The port in the server address is optional and defaults to 636 if not provided. The server address variable is _required_. TLS is assumed to be on by default. The port in the server address is optional and defaults to 636 if not provided.
**MinIO sends LDAP credentials to the LDAP server for validation. So we _strongly recommend_ to use MinIO with AD/LDAP server over TLS or StartTLS _only_. Using plain-text connection between MinIO and LDAP server means _credentials can be compromised_ by anyone listening to network traffic.** **MinIO sends LDAP credentials to the LDAP server for validation. So we _strongly recommend_ to use MinIO with AD/LDAP server over TLS or StartTLS _only_. Using plain-text connection between MinIO and LDAP server means _credentials can be compromised_ by anyone listening to network traffic.**
+77
View File
@@ -22,7 +22,9 @@ import (
"crypto/x509" "crypto/x509"
"errors" "errors"
"net" "net"
"net/netip"
"sort" "sort"
"strings"
"time" "time"
"github.com/minio/madmin-go/v3" "github.com/minio/madmin-go/v3"
@@ -43,6 +45,7 @@ type Config struct {
LDAP ldap.Config LDAP ldap.Config
stsExpiryDuration time.Duration // contains converted value stsExpiryDuration time.Duration // contains converted value
stsTrustedProxies []netip.Prefix
} }
// Enabled returns if LDAP is enabled. // Enabled returns if LDAP is enabled.
@@ -58,6 +61,7 @@ func (l *Config) Clone() Config {
cfg := Config{ cfg := Config{
LDAP: l.LDAP.Clone(), LDAP: l.LDAP.Clone(),
stsExpiryDuration: l.stsExpiryDuration, stsExpiryDuration: l.stsExpiryDuration,
stsTrustedProxies: append([]netip.Prefix(nil), l.stsTrustedProxies...),
} }
return cfg return cfg
} }
@@ -76,6 +80,7 @@ const (
TLSSkipVerify = "tls_skip_verify" TLSSkipVerify = "tls_skip_verify"
ServerInsecure = "server_insecure" ServerInsecure = "server_insecure"
ServerStartTLS = "server_starttls" ServerStartTLS = "server_starttls"
STSTrustedProxies = "sts_trusted_proxies"
EnvServerAddr = "MINIO_IDENTITY_LDAP_SERVER_ADDR" EnvServerAddr = "MINIO_IDENTITY_LDAP_SERVER_ADDR"
EnvSRVRecordName = "MINIO_IDENTITY_LDAP_SRV_RECORD_NAME" EnvSRVRecordName = "MINIO_IDENTITY_LDAP_SRV_RECORD_NAME"
@@ -90,6 +95,7 @@ const (
EnvGroupSearchBaseDN = "MINIO_IDENTITY_LDAP_GROUP_SEARCH_BASE_DN" EnvGroupSearchBaseDN = "MINIO_IDENTITY_LDAP_GROUP_SEARCH_BASE_DN"
EnvLookupBindDN = "MINIO_IDENTITY_LDAP_LOOKUP_BIND_DN" EnvLookupBindDN = "MINIO_IDENTITY_LDAP_LOOKUP_BIND_DN"
EnvLookupBindPassword = "MINIO_IDENTITY_LDAP_LOOKUP_BIND_PASSWORD" EnvLookupBindPassword = "MINIO_IDENTITY_LDAP_LOOKUP_BIND_PASSWORD"
EnvSTSTrustedProxies = "MINIO_IDENTITY_LDAP_STS_TRUSTED_PROXIES"
) )
var removedKeys = []string{ var removedKeys = []string{
@@ -155,9 +161,77 @@ var (
Key: LookupBindPassword, Key: LookupBindPassword,
Value: "", Value: "",
}, },
config.KV{
Key: STSTrustedProxies,
Value: "",
},
} }
) )
func parseSTSTrustedProxies(value string) ([]netip.Prefix, error) {
fields := strings.FieldsFunc(value, func(r rune) bool {
switch r {
case ',', ';', ' ', '\n', '\r', '\t':
return true
default:
return false
}
})
if len(fields) == 0 {
return nil, nil
}
prefixes := make([]netip.Prefix, 0, len(fields))
for _, field := range fields {
if prefix, err := netip.ParsePrefix(field); err == nil {
prefixes = append(prefixes, prefix.Masked())
continue
}
addr, err := netip.ParseAddr(field)
if err != nil {
return nil, config.Errorf("invalid LDAP STS trusted proxy %q", field)
}
bits := 32
if addr.Is6() {
bits = 128
}
prefixes = append(prefixes, netip.PrefixFrom(addr, bits))
}
return prefixes, nil
}
// SetSTSTrustedProxies parses and stores the LDAP STS trusted proxy allowlist.
func (l *Config) SetSTSTrustedProxies(value string) error {
prefixes, err := parseSTSTrustedProxies(value)
if err != nil {
return err
}
l.stsTrustedProxies = prefixes
return nil
}
// IsSTSTrustedProxy reports whether the peer IP is allowed to supply forwarded headers
// for LDAP STS source bucketing.
func (l *Config) IsSTSTrustedProxy(peerIP string) bool {
if l == nil || len(l.stsTrustedProxies) == 0 {
return false
}
addr, err := netip.ParseAddr(peerIP)
if err != nil {
return false
}
for _, prefix := range l.stsTrustedProxies {
if prefix.Contains(addr) {
return true
}
}
return false
}
// Enabled returns if LDAP config is enabled. // Enabled returns if LDAP config is enabled.
func Enabled(kvs config.KVS) bool { func Enabled(kvs config.KVS) bool {
return kvs.Get(ServerAddr) != "" return kvs.Get(ServerAddr) != ""
@@ -255,6 +329,9 @@ func Lookup(s config.Config, rootCAs *x509.CertPool) (l Config, err error) {
// Group search params configuration // Group search params configuration
l.LDAP.GroupSearchFilter = getCfgVal(GroupSearchFilter) l.LDAP.GroupSearchFilter = getCfgVal(GroupSearchFilter)
l.LDAP.GroupSearchBaseDistName = getCfgVal(GroupSearchBaseDN) l.LDAP.GroupSearchBaseDistName = getCfgVal(GroupSearchBaseDN)
if err = l.SetSTSTrustedProxies(getCfgVal(STSTrustedProxies)); err != nil {
return l, err
}
// If enable flag was not explicitly set, we treat it as implicitly set at // If enable flag was not explicitly set, we treat it as implicitly set at
// this point as necessary configuration is available. // this point as necessary configuration is available.
+6
View File
@@ -102,6 +102,12 @@ var (
Optional: true, Optional: true,
Type: "on|off", Type: "on|off",
}, },
config.HelpKV{
Key: STSTrustedProxies,
Description: `"," separated list of trusted proxy IPs or CIDRs whose forwarded client IP headers may be used for LDAP STS rate limiting` + defaultHelpPostfix(STSTrustedProxies),
Optional: true,
Type: "list",
},
config.HelpKV{ config.HelpKV{
Key: config.Comment, Key: config.Comment,
Description: config.DefaultComment, Description: config.DefaultComment,
@@ -43,3 +43,34 @@ func TestIsUserDNNotFoundError(t *testing.T) {
t.Fatal("expected infrastructure lookup error to not be detected as user-not-found") t.Fatal("expected infrastructure lookup error to not be detected as user-not-found")
} }
} }
func TestSetSTSTrustedProxies(t *testing.T) {
var cfg Config
if err := cfg.SetSTSTrustedProxies("192.0.2.10,198.51.100.0/24;2001:db8::/126"); err != nil {
t.Fatalf("expected trusted proxies to parse, got %v", err)
}
tests := []struct {
peerIP string
want bool
}{
{peerIP: "192.0.2.10", want: true},
{peerIP: "198.51.100.23", want: true},
{peerIP: "198.51.101.23", want: false},
{peerIP: "2001:db8::2", want: true},
{peerIP: "2001:db8::8", want: false},
}
for _, tt := range tests {
if got := cfg.IsSTSTrustedProxy(tt.peerIP); got != tt.want {
t.Fatalf("peer %q: expected %v, got %v", tt.peerIP, tt.want, got)
}
}
}
func TestSetSTSTrustedProxiesRejectsInvalidEntries(t *testing.T) {
var cfg Config
if err := cfg.SetSTSTrustedProxies("192.0.2.0/24,not-an-ip"); err == nil {
t.Fatal("expected invalid trusted proxy list to fail")
}
}