Skip to content

Commit e2f0925

Browse files
committed
feat(login): key the rate limiter on the real client behind trusted proxies
1 parent 79cba95 commit e2f0925

4 files changed

Lines changed: 249 additions & 4 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
- ADMIN_USERNAME=admin
4848
- ADMIN_PASSWORD=<any password>
4949
- PORT=8798
50+
- TRUSTED_PROXIES=172.16.0.0/12 # the docker network traefik reaches OnLogs over, see below
5051
# - ONLOGS_PATH_PREFIX=/onlogs if want to use with path prefix
5152

5253
labels:
@@ -93,6 +94,25 @@ Once done, just go to <your host> and login as "admin" with <any password>.
9394
| MAX_LOGS_SIZE | Maximum allowed total logs size before cleanup triggers. Accepts human-readable formats like 5GB, 500MB, 1.5GB etc. When exceeded, 10% of logs (by count) will be removed proportionally across containers starting from oldest. Validated at startup: an unparseable value stops OnLogs rather than silently disabling retention | 10GB | -
9495
| DISABLE_AUTH | Option to completely disable built in authentication in the application. When this option is set to `true` the app will behave like if the Administrator is logged in. The option to manage users will be removed. | false | -
9596
| METRICS_TOKEN | Bearer token for the Prometheus endpoint at `/api/v1/metrics`. While it is unset the endpoint returns `401` and exposes nothing, so metrics are off by default. See [Metrics](#metrics) | | only for `/api/v1/metrics`
97+
| TRUSTED_PROXIES | Peers allowed to name the client through `X-Forwarded-For` / `X-Real-IP`, as comma separated IPs and CIDR ranges. See [Behind a reverse proxy](#behind-a-reverse-proxy) | | only if behind nginx/traefik
98+
99+
## Behind a reverse proxy
100+
101+
Failed logins are rate limited per client address. Behind nginx or traefik every request
102+
arrives from the proxy, so without `TRUSTED_PROXIES` all your users count as one client and
103+
a single password sprayer locks out the rest. Set it to the addresses your proxy connects
104+
from:
105+
106+
```
107+
TRUSTED_PROXIES=172.16.0.0/12 # docker networks land here, so any containerised proxy does too
108+
TRUSTED_PROXIES=127.0.0.1 # a proxy on the host
109+
```
110+
111+
Keep the range as tight as your proxy allows — anything reaching OnLogs from a listed address
112+
can call itself any client.
113+
114+
Make sure the proxy actually sends the headers — traefik does by default, nginx needs
115+
`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`.
96116

97117
## Metrics
98118

application/backend/app/routes/loginlimit.go

Lines changed: 116 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ package routes
33
import (
44
"crypto/sha256"
55
"encoding/hex"
6+
"fmt"
67
"net"
78
"net/http"
9+
"net/netip"
10+
"strings"
811
"sync"
912
"time"
1013
)
@@ -110,12 +113,121 @@ func (l *loginAttempts) prune(now time.Time) {
110113
}
111114
}
112115

113-
// RemoteAddr only: X-Forwarded-For is caller-controlled and would let an
114-
// attacker rotate past the limit.
116+
// Empty means no peer may speak for anyone else.
117+
var trustedProxies []netip.Prefix
118+
119+
// SetTrustedProxies takes a comma separated list of IPs and CIDR ranges.
120+
func SetTrustedProxies(spec string) error {
121+
parsed, err := parseTrustedProxies(spec)
122+
if err != nil {
123+
return err
124+
}
125+
trustedProxies = parsed
126+
return nil
127+
}
128+
129+
func parseTrustedProxies(spec string) ([]netip.Prefix, error) {
130+
var parsed []netip.Prefix
131+
for _, item := range strings.Split(spec, ",") {
132+
item = strings.TrimSpace(item)
133+
switch {
134+
case item == "":
135+
case strings.Contains(item, "/"):
136+
prefix, err := netip.ParsePrefix(item)
137+
if err != nil {
138+
return nil, fmt.Errorf("%q is not a CIDR range: %w", item, err)
139+
}
140+
parsed = append(parsed, prefix.Masked())
141+
default:
142+
addr, err := netip.ParseAddr(item)
143+
if err != nil {
144+
return nil, fmt.Errorf("%q is not an IP address or CIDR range: %w", item, err)
145+
}
146+
addr = addr.Unmap()
147+
parsed = append(parsed, netip.PrefixFrom(addr, addr.BitLen()))
148+
}
149+
}
150+
return parsed, nil
151+
}
152+
153+
func isTrustedProxy(addr netip.Addr) bool {
154+
for _, prefix := range trustedProxies {
155+
if prefix.Contains(addr) {
156+
return true
157+
}
158+
}
159+
return false
160+
}
161+
162+
// Zones and the IPv4-in-IPv6 form are dropped so one client cannot hold two
163+
// buckets.
164+
func parseIP(value string) (netip.Addr, bool) {
165+
value = strings.TrimSpace(value)
166+
if addr, err := netip.ParseAddr(value); err == nil {
167+
return addr.Unmap().WithZone(""), true
168+
}
169+
if addrPort, err := netip.ParseAddrPort(value); err == nil {
170+
return addrPort.Addr().Unmap().WithZone(""), true
171+
}
172+
return netip.Addr{}, false
173+
}
174+
175+
// The peer address, unless it is a trusted proxy: then the address it
176+
// forwarded. The headers are caller-controlled, so taking them from anyone
177+
// would let an attacker rotate past the limit or wear someone else's address.
115178
func clientAddr(req *http.Request) string {
116179
host, _, err := net.SplitHostPort(req.RemoteAddr)
117180
if err != nil {
118-
return req.RemoteAddr
181+
host = req.RemoteAddr
182+
}
183+
184+
peer, ok := parseIP(host)
185+
if !ok {
186+
return host
187+
}
188+
if !isTrustedProxy(peer) {
189+
warnUntrustedForward(req)
190+
return peer.String()
191+
}
192+
if forwarded, ok := forwardedClient(req); ok {
193+
return forwarded
194+
}
195+
return peer.String()
196+
}
197+
198+
// Right to left: every hop appends the address it saw, so the rightmost entry
199+
// no trusted proxy could have written is the last one still worth believing.
200+
func forwardedClient(req *http.Request) (string, bool) {
201+
entries := strings.Split(strings.Join(req.Header.Values("X-Forwarded-For"), ","), ",")
202+
origin := ""
203+
for i := len(entries) - 1; i >= 0; i-- {
204+
addr, ok := parseIP(entries[i])
205+
if !ok {
206+
break
207+
}
208+
if !isTrustedProxy(addr) {
209+
return addr.String(), true
210+
}
211+
origin = addr.String()
212+
}
213+
214+
if addr, ok := parseIP(req.Header.Get("X-Real-IP")); ok {
215+
return addr.String(), true
216+
}
217+
// Every hop was trusted, so the chain names its own origin.
218+
return origin, origin != ""
219+
}
220+
221+
var forwardWarning sync.Once
222+
223+
// Either a proxy nobody told us about, whose users then share one bucket, or
224+
// someone trying on another address for size.
225+
func warnUntrustedForward(req *http.Request) {
226+
if req.Header.Get("X-Forwarded-For") == "" && req.Header.Get("X-Real-IP") == "" {
227+
return
119228
}
120-
return host
229+
forwardWarning.Do(func() {
230+
fmt.Printf("WARNING: a login from %s carried a forwarded client address, but that peer is not in TRUSTED_PROXIES;"+
231+
" rate limiting keys on the peer, so everyone behind it shares one limit.\n", req.RemoteAddr)
232+
})
121233
}

application/backend/app/routes/loginlimit_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,83 @@ func TestLoginLimiterStillThrottlesRepeatedFailures(t *testing.T) {
8585
_ = time.Second
8686
}
8787

88+
func withTrustedProxies(t *testing.T, spec string) {
89+
t.Helper()
90+
if err := SetTrustedProxies(spec); err != nil {
91+
t.Fatalf("SetTrustedProxies(%q): %v", spec, err)
92+
}
93+
t.Cleanup(func() { trustedProxies = nil })
94+
}
95+
96+
func forwardedRequest(peer string, headers map[string]string) *http.Request {
97+
req, _ := http.NewRequest("POST", "/api/v1/login", nil)
98+
req.RemoteAddr = peer
99+
for name, value := range headers {
100+
req.Header.Set(name, value)
101+
}
102+
return req
103+
}
104+
105+
func TestForwardedAddressesAreIgnoredFromUntrustedPeers(t *testing.T) {
106+
req := forwardedRequest("203.0.113.9:40000", map[string]string{
107+
"X-Forwarded-For": "198.51.100.7",
108+
"X-Real-IP": "198.51.100.7",
109+
})
110+
111+
if got := clientAddr(req); got != "203.0.113.9" {
112+
t.Fatalf("a caller wore another address with no proxy configured: %q", got)
113+
}
114+
115+
// Trusting some other proxy is not trusting this caller.
116+
withTrustedProxies(t, "192.0.2.1,10.0.0.0/8")
117+
if got := clientAddr(req); got != "203.0.113.9" {
118+
t.Fatalf("a caller outside TRUSTED_PROXIES wore another address: %q", got)
119+
}
120+
}
121+
122+
func TestClientAddrResolvesThroughTrustedProxies(t *testing.T) {
123+
withTrustedProxies(t, "10.0.0.0/8,172.16.0.0/12")
124+
125+
cases := []struct {
126+
name string
127+
peer string
128+
headers map[string]string
129+
want string
130+
}{
131+
{"single hop", "10.0.0.1:40000", map[string]string{"X-Forwarded-For": "198.51.100.7"}, "198.51.100.7"},
132+
{"chained proxies", "10.0.0.1:40000", map[string]string{"X-Forwarded-For": "198.51.100.7, 10.0.0.5"}, "198.51.100.7"},
133+
{"client prepended a fake hop", "10.0.0.1:40000", map[string]string{"X-Forwarded-For": "1.1.1.1, 198.51.100.7"}, "198.51.100.7"},
134+
{"x-real-ip only", "10.0.0.1:40000", map[string]string{"X-Real-IP": "198.51.100.7"}, "198.51.100.7"},
135+
{"entry carries a port", "10.0.0.1:40000", map[string]string{"X-Forwarded-For": "198.51.100.7:1234"}, "198.51.100.7"},
136+
{"ipv4 in ipv6 form", "[::ffff:10.0.0.1]:40000", map[string]string{"X-Forwarded-For": "::ffff:198.51.100.7"}, "198.51.100.7"},
137+
{"unparseable chain", "10.0.0.1:40000", map[string]string{"X-Forwarded-For": "not-an-address"}, "10.0.0.1"},
138+
{"proxy forwarded nothing", "10.0.0.1:40000", nil, "10.0.0.1"},
139+
{"internal end to end", "10.0.0.1:40000", map[string]string{"X-Forwarded-For": "10.9.9.9, 10.0.0.5"}, "10.9.9.9"},
140+
}
141+
142+
for _, tc := range cases {
143+
t.Run(tc.name, func(t *testing.T) {
144+
if got := clientAddr(forwardedRequest(tc.peer, tc.headers)); got != tc.want {
145+
t.Fatalf("got %q, want %q", got, tc.want)
146+
}
147+
})
148+
}
149+
}
150+
151+
func TestTrustedProxySpecIsValidated(t *testing.T) {
152+
if _, err := parseTrustedProxies(" 10.0.0.0/8 , 192.0.2.1,::1 "); err != nil {
153+
t.Fatalf("a valid spec was rejected: %v", err)
154+
}
155+
if parsed, err := parseTrustedProxies(""); err != nil || parsed != nil {
156+
t.Fatalf("an empty spec gave (%v, %v)", parsed, err)
157+
}
158+
for _, spec := range []string{"10.0.0.0/99", "not-an-address", "10.0.0.1-10.0.0.5", "10.0.0.0/8,junk"} {
159+
if _, err := parseTrustedProxies(spec); err == nil {
160+
t.Errorf("%q was accepted", spec)
161+
}
162+
}
163+
}
164+
88165
// The lockout lived in which key the handler chose, so it has to be exercised
89166
// through the handler.
90167
func TestLoginLockoutCannotBeInflictedOnAnotherUser(t *testing.T) {
@@ -116,3 +193,33 @@ func TestLoginLockoutCannotBeInflictedOnAnotherUser(t *testing.T) {
116193
t.Fatalf("an attacker guessing at the account locked its real owner out: status %d", code)
117194
}
118195
}
196+
197+
func TestOneAttackerBehindAProxyDoesNotThrottleEveryoneElse(t *testing.T) {
198+
userdb.CreateUser("proxieduser", "the-real-password")
199+
t.Cleanup(func() { userdb.DeleteUser("proxieduser") })
200+
withTrustedProxies(t, "10.0.0.0/8,172.16.0.0/12")
201+
202+
loginLimiter.mu.Lock()
203+
loginLimiter.entries = map[string]*loginAttempt{}
204+
loginLimiter.mu.Unlock()
205+
206+
attempt := func(client, password string) int {
207+
body, _ := json.Marshal(map[string]string{"Login": "proxieduser", "Password": password})
208+
req, _ := http.NewRequest("POST", "/api/v1/login", bytes.NewBuffer(body))
209+
req.RemoteAddr = "172.18.0.2:40000"
210+
req.Header.Set("X-Forwarded-For", client)
211+
rr := httptest.NewRecorder()
212+
http.HandlerFunc(testCtrl.Login).ServeHTTP(rr, req)
213+
return rr.Result().StatusCode
214+
}
215+
216+
for i := 0; i < 20; i++ {
217+
attempt("203.0.113.9", "guess-"+strconv.Itoa(i))
218+
}
219+
if code := attempt("203.0.113.9", "the-real-password"); code != http.StatusTooManyRequests {
220+
t.Errorf("the attacker was not throttled through the proxy: status %d", code)
221+
}
222+
if code := attempt("198.51.100.7", "the-real-password"); code != http.StatusOK {
223+
t.Fatalf("one attacker behind the proxy locked out everyone else: status %d", code)
224+
}
225+
}

application/backend/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ func init_config() {
8484
os.Exit(1)
8585
}
8686

87+
if err := routes.SetTrustedProxies(os.Getenv("TRUSTED_PROXIES")); err != nil {
88+
fmt.Printf("FATAL: TRUSTED_PROXIES=%q is invalid (%v); refusing to start with a rate limiter that cannot tell clients apart.\n",
89+
os.Getenv("TRUSTED_PROXIES"), err)
90+
os.Exit(1)
91+
}
92+
8793
fmt.Println("INFO: OnLogs configs done!")
8894
}
8995

0 commit comments

Comments
 (0)