fix(security): make the client IP trustworthy and scope credential lockout to it - #13
Conversation
…ckout to it Two coupled defects. The second could not be fixed safely before the first. Gin was never told which proxies to trust. Nothing in the repository calls SetTrustedProxies or sets TrustedPlatform, so the engine ran on Gin's own default of 0.0.0.0/0 and ::/0 - every peer is a trusted proxy. validateHeader then walks X-Forwarded-For right to left and stops at the first address that is *not* a trusted proxy; with everything trusted it never stops, and returns the leftmost value, which is the one the caller supplied. Any client could therefore choose the address recorded in t_login_credential_log.client_ip and t_user.last_login_ip by sending one header. X-Real-IP is in Gin's default RemoteIPHeaders too, so it was a second route to the same result. - ServerConfig gains trustedProxies and trustedPlatform. Left unset, trustedProxies falls back to the loopback, RFC1918, IPv6 unique-local and link-local ranges rather than to Gin's trust-everything default. That covers a sidecar tunnel, a compose network and a local nginx, and a client on the public internet cannot present one of those addresses as its direct peer. - trustedPlatform maps "cloudflare", "fly.io" and "google-app-engine" onto Gin's own constants and passes any other value through as a literal header name. It defaults to empty on purpose: an edge that appends rather than overwrites would turn the header back into a client-controlled value. - NewServer applies both before any middleware is registered, and fails startup on an unparseable CIDR rather than quietly dropping the trust boundary. - Documented in config/config.example.yaml, and .env.example sets TRUSTED_PLATFORM=cloudflare because this deployment sits behind a Cloudflare Tunnel. With an address that can be believed, credential lockout stops being a denial of service. isCredentialLocked keyed on the username alone, so anybody who knew a username could lock the real account out for the whole window, from anywhere, as often as they liked - and the window was renewable indefinitely. - The per-account window is now keyed on principal *and* client address, so one source grinding on one account is still stopped. - A second window counts failures from one address across all principals, which is what credential stuffing looks like. auth.maxFailedAttemptsPerIP configures it and defaults to four times maxFailedAttempts; disabling maxFailedAttempts disables both, matching the existing convention. - An address the server could not determine is normalised to a single "unknown" bucket and excluded from the per-address window, so a missing IP cannot pool every such caller into one lockout. - createLoginCredentialLog normalises on write so the read and write sides cannot drift apart. Tests. TestGinDefaultTrustsEveryProxy pins the vulnerable default, so the fix stays load-bearing and a future Gin that changes the default says so instead of silently making the configuration decorative. Three more cover an untrusted peer's forged header being ignored, a trusted proxy's appended address beating client-prepended junk, and the platform header taking precedence over X-Forwarded-For. Two lockout tests cover the denial of service being closed and the per-address window catching a username spray that no single account trips. Four existing lockout tests seeded credential logs without a client address, which is exactly the assumption this removes, so they now seed one.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_42da26f6-ab6e-49c4-8ced-a6d978eb2c1d) |
|
Warning Gemini encountered an error creating the review. You can try again by commenting |
Two coupled defects. The second could not be fixed safely before the first, which is why this is one pull request rather than two.
1. The client IP was forgeable with a single header
Nothing in the repository calls
SetTrustedProxiesor setsTrustedPlatform- grep forSetTrustedProxies|ForwardedByClientIP|TrustedPlatform|RemoteIPHeaders|X-Forwarded-For|CF-Connecting-IPacross the whole tree returns zero hits. So Gin ran on its own default, and the chain is:gin@v1.12.0 gin.go:39-47defaultTrustedCIDRs = [0.0.0.0/0, ::/0]- every peer is a trusted proxygin.go:482-501validateHeaderwalksX-Forwarded-Forright to left and stops at the first address that is not a trusted proxycontext.go:975-1024ClientIP()findsTrustedPlatformempty, the peer trusted,ForwardedByClientIPtrue, and callsvalidateHeaderWith everything trusted, the walk never stops and returns the leftmost value - the one the caller supplied.
X-Real-IPis in Gin's defaultRemoteIPHeaderstoo, so it was a second route to the same result.Consequence today:
t_login_credential_log.client_ip(written at four points in the login flow) andt_user.last_login_ip(three writers) record an attacker-chosen value, so the forensic value of both is nil. Consequence for anything built on top: an IP-keyed control is bypassable by editing a header.Fix.
ServerConfiggainstrustedProxiesandtrustedPlatform, applied inNewServerbefore any middleware is registered.trustedProxiesfalls back to loopback / RFC1918 / IPv6 unique-local / link-local rather than to Gin's trust-everything default. That covers a sidecar tunnel, a compose network and a local nginx, and a client on the public internet cannot present one of those addresses as its direct peer. Direct exposure still resolves to the real peer, because a public peer is not in the list.trustedPlatformmapscloudflare,fly.ioandgoogle-app-engineonto Gin's own constants and passes anything else through as a literal header name. It defaults to empty on purpose: an edge that appends rather than overwrites would turn the header back into a client-controlled value..env.examplesets it tocloudflarebecause this deployment sits behind a Cloudflare Tunnel, whereCF-Connecting-IPis overwritten and therefore not forgeable.2. Credential lockout was a denial of service
isCredentialLockedkeyed on the username alone. Anyone who knew a username -adminis not a secret - could lock the real account out for the fullcredentialLockMinutewindow, from anywhere, and renew it indefinitely by repeating. That is a DoS wearing a protection's clothes.auth.maxFailedAttemptsPerIP, defaulting to four timesmaxFailedAttempts; disablingmaxFailedAttemptsdisables both, matching the existing convention.unknownbucket and is excluded from the per-address window, so a missing IP cannot pool every such caller into a single lockout.createLoginCredentialLognormalises on write, so the read and write sides cannot drift apart.Behaviour change operators need to know
A user who previously got locked out because somebody else failed passwords on their username will no longer be locked out. Conversely, an attacker rotating source addresses is no longer stopped by the per-account window - that is what the per-address window and, separately, rate limiting are for. Nothing else about the login contract changes.
Tests
TestGinDefaultTrustsEveryProxybuilds a bare engine and asserts that a forgedX-Forwarded-Foris honoured. It exists so the fix stays load-bearing: if a future Gin changes its default, that test fails and says so, rather than leaving the configuration silently decorative.Three more cover an untrusted peer's forged header being ignored, a trusted proxy's appended address beating client-prepended junk, and the platform header taking precedence over
X-Forwarded-For. A fourth asserts startup fails on a bad CIDR.Two lockout tests cover the denial of service being closed - the attacking address is locked while the legitimate owner arriving from elsewhere gets a session - and the per-address window catching a four-username spray that no single account trips.
Four existing lockout tests seeded credential logs without a client address, which is exactly the assumption this removes; they failed once the key changed and now seed one. That failure is the point: the old tests encoded the vulnerable semantics.
Config helpers are covered too, including that
TRUSTED_PROXIES="10.0.0.0/8,172.16.0.0/12"arrives as a two-element slice rather than one string - Gin rejects an unparseable CIDR at startup, so that conversion failing would take the process down rather than degrade quietly.Verification run locally
go build -tags dev ./...,go vet -tags dev ./...clean, andgo test -count=1 -tags devacrossservices,repositories,pkg,migration,bootstrap,builders,handlersallok.Not in this pull request
The upstream port is deliberately deferred. This is upstream code, so it belongs in
huabeitech/agent-desktoo, but it cannot be produced the way the earlier upload fix was. That one touched eight files that were byte-identical betweendevandupstream/main, so the commit could be replayed directly. These files are not:internal/pkg/config/config.gocarries fork-only configuration ondev, andinternal/bootstrap/server.goondevalready contains the storage hardening that is still sitting in the open upstream PR huabeitech#40. Copying either file across would ship fork configuration to upstream and duplicate an open pull request. Porting it needs the hunks applied to upstream's versions by hand, which is a separate piece of work.Rate limiting (SEC-08) is also not here. It is the third step in this chain and depends on the trustworthy address this establishes.
Note
High Risk
Changes authentication abuse controls and how client IPs are derived for login logs, last-login IP, and lockouts—misconfigured trusted proxies could still mis-identify clients or weaken protections.
Overview
Closes two linked security issues: forgeable client IPs and username-only lockout that anyone could trigger as a DoS.
Client IP.
NewServernow appliesserver.trustedProxiesandserver.trustedPlatformbefore middleware runs, replacing Gin’s default of trusting every peer (soX-Forwarded-Forcould be chosen by the caller). Empty proxy config falls back to private/loopback CIDRs; Cloudflare and similar platforms map to edge headers likeCF-Connecting-IP. Bad CIDRs fail startup. Example env/YAML documentsTRUSTED_PROXIESandTRUSTED_PLATFORM.Credential lockout. Failed attempts are counted per username + client IP, plus a new
auth.maxFailedAttemptsPerIPwindow (default 4× per-account limit) for stuffing from one address across many usernames. IPs are normalized on write; unknown IPs skip the per-IP bucket. Lockout checks now takeclientIP, which flows fromctx.ClientIP()on login.Tests pin Gin’s vulnerable default, verify trusted/untrusted/platform behavior in
NewServer, and regression-test address-scoped lockout vs cross-IP login success.Reviewed by Cursor Bugbot for commit b748036. Configure here.