Skip to content

Commit f441108

Browse files
committed
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.
1 parent 9e10f6d commit f441108

6 files changed

Lines changed: 385 additions & 11 deletions

File tree

cmd/sts-handlers.go

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
"github.com/minio/minio/internal/auth"
3838
idldap "github.com/minio/minio/internal/config/identity/ldap"
3939
"github.com/minio/minio/internal/config/identity/openid"
40+
"github.com/minio/minio/internal/handlers"
4041
"github.com/minio/minio/internal/hash/sha256"
4142
xhttp "github.com/minio/minio/internal/http"
4243
"github.com/minio/minio/internal/logger"
@@ -441,17 +442,25 @@ func (l *stsLDAPLoginKeyLimiterSet) refillLocked(now time.Time, entry *stsLDAPLo
441442
}
442443
if l.refillEvery <= 0 {
443444
entry.lastRefill = now
444-
entry.tokens = float64(l.burst)
445+
entry.tokens = l.maxTokensLocked(entry)
445446
return
446447
}
447448

448449
entry.tokens += float64(now.Sub(lastRefill)) / float64(l.refillEvery)
449-
if maxTokens := float64(l.burst); entry.tokens > maxTokens {
450+
if maxTokens := l.maxTokensLocked(entry); entry.tokens > maxTokens {
450451
entry.tokens = maxTokens
451452
}
452453
entry.lastRefill = now
453454
}
454455

456+
func (l *stsLDAPLoginKeyLimiterSet) maxTokensLocked(entry *stsLDAPLoginKeyLimiter) float64 {
457+
maxTokens := l.burst - entry.inFlight
458+
if maxTokens < 0 {
459+
return 0
460+
}
461+
return float64(maxTokens)
462+
}
463+
455464
func (r *stsLDAPLoginKeyReservation) CommitAt(now time.Time) {
456465
r.finalize(now, false)
457466
}
@@ -479,7 +488,7 @@ func (r *stsLDAPLoginKeyReservation) finalize(now time.Time, refund bool) {
479488
}
480489
if refund {
481490
r.entry.tokens++
482-
if maxTokens := float64(r.set.burst); r.entry.tokens > maxTokens {
491+
if maxTokens := r.set.maxTokensLocked(r.entry); r.entry.tokens > maxTokens {
483492
r.entry.tokens = maxTokens
484493
}
485494
}
@@ -488,11 +497,42 @@ func (r *stsLDAPLoginKeyReservation) finalize(now time.Time, refund bool) {
488497
}
489498

490499
func getSTSLDAPLoginSourceIP(r *http.Request) string {
491-
sourceIP, _, err := net.SplitHostPort(r.RemoteAddr)
500+
peerIP := getSTSLDAPLoginCanonicalIP(r.RemoteAddr)
501+
sourceIP := peerIP
502+
if sourceIP == "" {
503+
sourceIP = getSTSLDAPLoginPeerAddr(r.RemoteAddr)
504+
}
505+
if peerIP != "" && globalIAMSys != nil && globalIAMSys.LDAPConfig.IsSTSTrustedProxy(peerIP) {
506+
if forwardedIP := getSTSLDAPTrustedProxySourceIP(r); forwardedIP != "" {
507+
return forwardedIP
508+
}
509+
}
510+
return sourceIP
511+
}
512+
513+
func getSTSLDAPTrustedProxySourceIP(r *http.Request) string {
514+
if realIP := getSTSLDAPLoginCanonicalIP(r.Header.Get("X-Real-IP")); realIP != "" {
515+
return realIP
516+
}
517+
return getSTSLDAPLoginCanonicalIP(handlers.GetSourceIPFromHeaders(r))
518+
}
519+
520+
func getSTSLDAPLoginPeerAddr(remoteAddr string) string {
521+
sourceIP, _, err := net.SplitHostPort(remoteAddr)
492522
if err == nil {
493523
return sourceIP
494524
}
495-
return r.RemoteAddr
525+
return remoteAddr
526+
}
527+
528+
func getSTSLDAPLoginCanonicalIP(addr string) string {
529+
addr = strings.TrimSpace(getSTSLDAPLoginPeerAddr(addr))
530+
addr = strings.TrimPrefix(addr, "[")
531+
addr = strings.TrimSuffix(addr, "]")
532+
if ip := net.ParseIP(addr); ip != nil {
533+
return ip.String()
534+
}
535+
return ""
496536
}
497537

498538
// reserveSTSLDAPLogin acquires immediate tokens from the per-source and

cmd/sts-handlers_test.go

Lines changed: 211 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,7 +1008,34 @@ func withGlobalSTSLDAPLoginRateLimiterForTest(limiter *stsLDAPLoginRateLimiter,
10081008
fn()
10091009
}
10101010

1011-
func (s *TestSuiteIAM) postLDAPSTS(c *check, username, password string) ldapSTSHTTPResult {
1011+
func withLDAPSTSTrustedProxiesForTest(t *testing.T, trustedProxies string, fn func()) {
1012+
t.Helper()
1013+
1014+
previousIAMSys := globalIAMSys
1015+
if globalIAMSys == nil {
1016+
globalIAMSys = &IAMSys{}
1017+
}
1018+
previous := globalIAMSys.LDAPConfig.Clone()
1019+
if err := globalIAMSys.LDAPConfig.SetSTSTrustedProxies(trustedProxies); err != nil {
1020+
t.Fatalf("unable to set LDAP STS trusted proxies for test: %v", err)
1021+
}
1022+
defer func() {
1023+
globalIAMSys.LDAPConfig = previous
1024+
globalIAMSys = previousIAMSys
1025+
}()
1026+
1027+
fn()
1028+
}
1029+
1030+
func singleHeader(key, value string) http.Header {
1031+
header := make(http.Header)
1032+
if key != "" {
1033+
header.Set(key, value)
1034+
}
1035+
return header
1036+
}
1037+
1038+
func (s *TestSuiteIAM) postLDAPSTSWithHeaders(c *check, username, password string, headers http.Header) ldapSTSHTTPResult {
10121039
c.Helper()
10131040

10141041
ctx, cancel := context.WithTimeout(context.Background(), testDefaultTimeout)
@@ -1025,6 +1052,11 @@ func (s *TestSuiteIAM) postLDAPSTS(c *check, username, password string) ldapSTSH
10251052
c.Fatalf("unexpected request creation error: %v", err)
10261053
}
10271054
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1055+
for key, values := range headers {
1056+
for _, value := range values {
1057+
req.Header.Add(key, value)
1058+
}
1059+
}
10281060

10291061
resp, err := s.TestSuiteCommon.client.Do(req)
10301062
if err != nil {
@@ -1044,6 +1076,11 @@ func (s *TestSuiteIAM) postLDAPSTS(c *check, username, password string) ldapSTSH
10441076
}
10451077
}
10461078

1079+
func (s *TestSuiteIAM) postLDAPSTS(c *check, username, password string) ldapSTSHTTPResult {
1080+
c.Helper()
1081+
return s.postLDAPSTSWithHeaders(c, username, password, nil)
1082+
}
1083+
10471084
func (s *TestSuiteIAM) postLDAPSTSForError(c *check, username, password string) ldapSTSErrorResult {
10481085
c.Helper()
10491086

@@ -1198,6 +1235,32 @@ func (s *TestSuiteIAM) TestLDAPSTSUpstreamFailure(c *check) {
11981235
)
11991236
}
12001237

1238+
func (s *TestSuiteIAM) TestLDAPSTSTrustedProxyRateLimit(c *check) {
1239+
withLDAPSTSTrustedProxiesForTest(c.T, "127.0.0.0/8,::1/128", func() {
1240+
withGlobalSTSLDAPLoginRateLimiterForTest(
1241+
newSTSLDAPLoginRateLimiter(time.Hour, 1, stsLDAPLoginEntryTTL),
1242+
func() {
1243+
// These usernames intentionally do not exist. The test asserts that
1244+
// LDAP user-not-found stays classified as an auth error, so failed
1245+
// attempts still commit the reservation and hit the source bucket.
1246+
first := s.postLDAPSTSWithHeaders(c, "missing-user-a", "nottherightpassword", singleHeader("X-Real-IP", "203.0.113.10"))
1247+
second := s.postLDAPSTSWithHeaders(c, "missing-user-b", "nottherightpassword", singleHeader("X-Real-IP", "198.51.100.23"))
1248+
third := s.postLDAPSTSWithHeaders(c, "missing-user-c", "nottherightpassword", singleHeader("X-Real-IP", "203.0.113.10"))
1249+
1250+
if first.StatusCode != http.StatusBadRequest {
1251+
c.Fatalf("expected first trusted-proxy LDAP STS auth failure to return %d, got %d body: %s", http.StatusBadRequest, first.StatusCode, first.Body)
1252+
}
1253+
if second.StatusCode != http.StatusBadRequest {
1254+
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)
1255+
}
1256+
if third.StatusCode != http.StatusTooManyRequests {
1257+
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)
1258+
}
1259+
},
1260+
)
1261+
})
1262+
}
1263+
12011264
func TestIAMWithLDAPSecurityServerSuite(t *testing.T) {
12021265
tests := []struct {
12031266
name string
@@ -1227,6 +1290,12 @@ func TestIAMWithLDAPSecurityServerSuite(t *testing.T) {
12271290
suite.TestLDAPSTSUpstreamFailure(c)
12281291
},
12291292
},
1293+
{
1294+
name: "TrustedProxyRateLimit",
1295+
run: func(suite *TestSuiteIAM, c *check, ldapServer string) {
1296+
suite.TestLDAPSTSTrustedProxyRateLimit(c)
1297+
},
1298+
},
12301299
}
12311300

12321301
for i, testCase := range iamTestSuites {
@@ -1397,6 +1466,35 @@ func TestSTSLDAPLoginRateLimiterReserveCancel(t *testing.T) {
13971466
reservation.Cancel()
13981467
}
13991468

1469+
func TestSTSLDAPLoginKeyLimiterCancelDoesNotOverCreditAfterRefill(t *testing.T) {
1470+
set := newSTSLDAPLoginKeyLimiterSet(10*time.Millisecond, 2, time.Minute)
1471+
start := time.Unix(0, 0)
1472+
1473+
first := set.Reserve(start, "192.0.2.10")
1474+
if first == nil {
1475+
t.Fatal("expected first reservation to succeed")
1476+
}
1477+
second := set.Reserve(start.Add(5*time.Millisecond), "192.0.2.10")
1478+
if second == nil {
1479+
t.Fatal("expected second reservation to succeed while one token remains available")
1480+
}
1481+
1482+
first.CancelAt(start.Add(10 * time.Millisecond))
1483+
1484+
third := set.Reserve(start.Add(10*time.Millisecond), "192.0.2.10")
1485+
if third == nil {
1486+
t.Fatal("expected canceled reservation to restore exactly one slot")
1487+
}
1488+
defer third.CancelAt(start.Add(10 * time.Millisecond))
1489+
1490+
if extra := set.Reserve(start.Add(10*time.Millisecond), "192.0.2.10"); extra != nil {
1491+
extra.CancelAt(start.Add(10 * time.Millisecond))
1492+
t.Fatal("expected only one slot to be restored after cancel; got over-credit from refill")
1493+
}
1494+
1495+
second.CancelAt(start.Add(10 * time.Millisecond))
1496+
}
1497+
14001498
func TestSTSLDAPLoginRateLimiterReserveRollbackOnCompositeFailure(t *testing.T) {
14011499
limiter := newSTSLDAPLoginRateLimiter(time.Hour, 1, time.Minute)
14021500

@@ -1531,7 +1629,7 @@ func TestGetSTSLDAPLoginSourceIPIgnoresSpoofedForwardingHeaders(t *testing.T) {
15311629
for _, tt := range tests {
15321630
t.Run(tt.name, func(t *testing.T) {
15331631
req := &http.Request{
1534-
Header: http.Header{tt.headerKey: []string{tt.headerValue}},
1632+
Header: singleHeader(tt.headerKey, tt.headerValue),
15351633
RemoteAddr: "192.0.2.10:9000",
15361634
}
15371635

@@ -1542,6 +1640,73 @@ func TestGetSTSLDAPLoginSourceIPIgnoresSpoofedForwardingHeaders(t *testing.T) {
15421640
}
15431641
}
15441642

1643+
func TestGetSTSLDAPLoginSourceIPUsesForwardedHeadersForTrustedProxy(t *testing.T) {
1644+
tests := []struct {
1645+
name string
1646+
headerKey string
1647+
headerValue string
1648+
want string
1649+
}{
1650+
{
1651+
name: "x-forwarded-for",
1652+
headerKey: "X-Forwarded-For",
1653+
headerValue: "203.0.113.10, 198.51.100.24",
1654+
want: "203.0.113.10",
1655+
},
1656+
{
1657+
name: "x-real-ip",
1658+
headerKey: "X-Real-IP",
1659+
headerValue: "203.0.113.10",
1660+
want: "203.0.113.10",
1661+
},
1662+
{
1663+
name: "forwarded",
1664+
headerKey: "Forwarded",
1665+
headerValue: `for=203.0.113.10;proto=https`,
1666+
want: "203.0.113.10",
1667+
},
1668+
}
1669+
1670+
withLDAPSTSTrustedProxiesForTest(t, "192.0.2.0/24", func() {
1671+
for _, tt := range tests {
1672+
t.Run(tt.name, func(t *testing.T) {
1673+
req := &http.Request{
1674+
Header: singleHeader(tt.headerKey, tt.headerValue),
1675+
RemoteAddr: "192.0.2.10:9000",
1676+
}
1677+
1678+
if got := getSTSLDAPLoginSourceIP(req); got != tt.want {
1679+
t.Fatalf("expected trusted proxy header %s to resolve %q, got %q", tt.headerKey, tt.want, got)
1680+
}
1681+
})
1682+
}
1683+
})
1684+
}
1685+
1686+
func TestGetSTSLDAPLoginSourceIPTrustedProxyPrefersXRealIPOverXForwardedFor(t *testing.T) {
1687+
withLDAPSTSTrustedProxiesForTest(t, "192.0.2.0/24", func() {
1688+
req := &http.Request{
1689+
Header: make(http.Header),
1690+
RemoteAddr: "192.0.2.10:9000",
1691+
}
1692+
req.Header.Set("X-Forwarded-For", "198.51.100.99, 203.0.113.10")
1693+
req.Header.Set("X-Real-IP", "203.0.113.10")
1694+
1695+
if got := getSTSLDAPLoginSourceIP(req); got != "203.0.113.10" {
1696+
t.Fatalf("expected trusted proxy path to prefer X-Real-IP over appended X-Forwarded-For, got %q", got)
1697+
}
1698+
})
1699+
}
1700+
1701+
func TestGetSTSLDAPLoginSourceIPTrustedProxyFallsBackToPeerWithoutForwardingHeaders(t *testing.T) {
1702+
withLDAPSTSTrustedProxiesForTest(t, "192.0.2.0/24", func() {
1703+
req := &http.Request{RemoteAddr: "192.0.2.10:9000"}
1704+
if got := getSTSLDAPLoginSourceIP(req); got != "192.0.2.10" {
1705+
t.Fatalf("expected trusted proxy path without forwarding headers to fall back to peer address, got %q", got)
1706+
}
1707+
})
1708+
}
1709+
15451710
func TestReserveSTSLDAPLoginUsesPeerAddressBuckets(t *testing.T) {
15461711
tests := []struct {
15471712
name string
@@ -1574,12 +1739,12 @@ func TestReserveSTSLDAPLoginUsesPeerAddressBuckets(t *testing.T) {
15741739
limiter := newSTSLDAPLoginRateLimiter(time.Hour, 2, time.Minute)
15751740
withGlobalSTSLDAPLoginRateLimiterForTest(limiter, func() {
15761741
req1 := &http.Request{
1577-
Header: http.Header{tt.headerKey: []string{tt.firstValue}},
1742+
Header: singleHeader(tt.headerKey, tt.firstValue),
15781743
RemoteAddr: "192.0.2.10:9000",
15791744
Form: url.Values{stsLDAPUsername: []string{"alice"}},
15801745
}
15811746
req2 := &http.Request{
1582-
Header: http.Header{tt.headerKey: []string{tt.secondValue}},
1747+
Header: singleHeader(tt.headerKey, tt.secondValue),
15831748
RemoteAddr: "192.0.2.10:9001",
15841749
Form: url.Values{stsLDAPUsername: []string{"bob"}},
15851750
}
@@ -1609,12 +1774,12 @@ func TestReserveSTSLDAPLoginUsesPeerAddressBuckets(t *testing.T) {
16091774
limiter := newSTSLDAPLoginRateLimiter(time.Hour, 1, time.Minute)
16101775
withGlobalSTSLDAPLoginRateLimiterForTest(limiter, func() {
16111776
req1 := &http.Request{
1612-
Header: http.Header{tt.headerKey: []string{tt.firstValue}},
1777+
Header: singleHeader(tt.headerKey, tt.firstValue),
16131778
RemoteAddr: "192.0.2.10:9000",
16141779
Form: url.Values{stsLDAPUsername: []string{"alice"}},
16151780
}
16161781
req2 := &http.Request{
1617-
Header: http.Header{tt.headerKey: []string{tt.firstValue}},
1782+
Header: singleHeader(tt.headerKey, tt.firstValue),
16181783
RemoteAddr: "192.0.2.11:9000",
16191784
Form: url.Values{stsLDAPUsername: []string{"bob"}},
16201785
}
@@ -1645,6 +1810,46 @@ func TestReserveSTSLDAPLoginUsesPeerAddressBuckets(t *testing.T) {
16451810
}
16461811
}
16471812

1813+
func TestReserveSTSLDAPLoginUsesForwardedBucketsForTrustedProxy(t *testing.T) {
1814+
limiter := newSTSLDAPLoginRateLimiter(time.Hour, 1, time.Minute)
1815+
withGlobalSTSLDAPLoginRateLimiterForTest(limiter, func() {
1816+
withLDAPSTSTrustedProxiesForTest(t, "192.0.2.0/24", func() {
1817+
req1 := &http.Request{
1818+
Header: singleHeader("X-Forwarded-For", "203.0.113.10"),
1819+
RemoteAddr: "192.0.2.10:9000",
1820+
Form: url.Values{stsLDAPUsername: []string{"alice"}},
1821+
}
1822+
req2 := &http.Request{
1823+
Header: singleHeader("X-Forwarded-For", "198.51.100.23"),
1824+
RemoteAddr: "192.0.2.10:9001",
1825+
Form: url.Values{stsLDAPUsername: []string{"bob"}},
1826+
}
1827+
1828+
reservation1 := reserveSTSLDAPLogin(req1)
1829+
if reservation1 == nil {
1830+
t.Fatal("expected first reservation through trusted proxy to succeed")
1831+
}
1832+
defer reservation1.Cancel()
1833+
1834+
reservation2 := reserveSTSLDAPLogin(req2)
1835+
if reservation2 == nil {
1836+
t.Fatal("expected forwarded client IPs behind the same trusted proxy to use distinct source buckets")
1837+
}
1838+
defer reservation2.Cancel()
1839+
1840+
if got := len(limiter.source.entries); got != 2 {
1841+
t.Fatalf("expected distinct forwarded client IPs to use two source buckets, got %d", got)
1842+
}
1843+
if _, ok := limiter.source.entries["203.0.113.10"]; !ok {
1844+
t.Fatalf("expected source bucket for forwarded client IP %q, got keys %v", "203.0.113.10", limiter.source.entries)
1845+
}
1846+
if _, ok := limiter.source.entries["198.51.100.23"]; !ok {
1847+
t.Fatalf("expected source bucket for forwarded client IP %q, got keys %v", "198.51.100.23", limiter.source.entries)
1848+
}
1849+
})
1850+
})
1851+
}
1852+
16481853
func TestLDAPBindErrorToSTS(t *testing.T) {
16491854
tests := []struct {
16501855
name string

0 commit comments

Comments
 (0)