Repository navigation
Description
Every client of a deployed instance shares one rate-limit bucket, so anyone who sends 51 wrong passwords in a minute locks every user out of login. Requests only reach the api through the bundled Caddy container, and src/main.ts creates the Fastify adapter without trustProxy, so request.ip is Caddy's container address for every request. @nestjs/throttler's default tracker keys on req.ip, so each route gets one shared counter. POST /v1/auth/login allows 50 requests a minute (ThrottleLoginRequest), and a bucket that hits its limit stays blocked for the TTL. An unauthenticated client can therefore get every real login answered with HTTP 429 for 60 seconds, and can keep it that way with about one request a second. The same sharing applies to libnest's default limits on every other route (25/s, 100/10 s and 250/min per route): a busy clinic draws on one budget instead of one per user. .env.template documents the login limit as "number of login requests from the same IP".
Where
apps/api/src/main.ts:21-69 passes no fastifyOptions, so libnest's AppContainer builds new FastifyAdapter({ bodyLimit: 1024 * 1024 * 50 }) with trustProxy off:
export default AppFactory.create({
docs: { ... },
envSchema: $Env,
imports: [ ... ],
version: '1'
});Caddyfile:10-12 is the only route to the api: docker-compose.yaml only exposes the api port, and docs/en/5-reference/5.6-docker.md says to keep Caddy even behind another proxy:
handle_path /api/* {
reverse_proxy api
}
The login limit is in apps/api/src/core/decorators/throttle-login-request.decorator.ts:23-31.
Reproduce
Verified with the api integration harness (test/helpers.ts, THROTTLER_ENABLED=true as in .env.template). Every request was injected with remoteAddress: '172.18.0.5', standing in for Caddy, and its own X-Forwarded-For:
- Send 51
POST /v1/auth/loginrequests with wrong credentials, 60 ms apart, fromX-Forwarded-For: 203.0.113.1through203.0.113.51. - Log in as the admin with the correct password from
X-Forwarded-For: 198.51.100.77.
Actual: requests 1-50 return 401, request 51 returns 429, and the admin's correct login also returns 429 with Retry-After-long: 60.
Expected: each client is throttled on its own address, so the attacker's requests block only the attacker.
Tests
apps/api/test/suites/(integration):it('should throttle logins per forwarded client address, so one client exhausting the limit does not lock out another'), with two clients sharing aremoteAddressbut sending differentX-Forwarded-Forheaders.- An e2e test in
testing/does not apply: the Playwright stack does not run behind Caddy, so every request there already comes from one address. The integration suite is the right place for this test.
Suggested fix
Pass fastifyOptions: { trustProxy: ... } to AppFactory.create in src/main.ts, trusting only the proxy in front of the api. For example, 'loopback,uniquelocal' covers the compose network, and the api is never published outside it. request.ip is then the client address Caddy writes into X-Forwarded-For. A deployment with its own proxy in front of Caddy also needs Caddy's trusted_proxies set so the real client address is passed through. Document that next to BIND_ADDRESS.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). Nothing has touchedapps/api/src/main.ts, theCaddyfileordocker-compose.yamlsince this was filed, and no open PR edits them.trustProxyalone does not fix the documented deploymentsBoth setups in
.env.template:17-19put a web server in front of the bundled Caddy, and step 6 of the deployment tutorial is a host Caddy withreverse_proxy localhost:5500(docs/en/2-tutorials/2.1-deployment.md:109-123). The bundled Caddy trusts no proxy, soreverse_proxyreplaces the incomingX-Forwarded-Forwith the address of its own peer (addForwardedHeaders, Caddy v2.7.6). That peer is the front proxy, as it appears through Docker's port publishing. With only themain.tschange,request.ipis that one address for every request, and the bucket is still shared. The repro's distinct address per client only happens when clients connect toAPP_PORTdirectly.So Caddy's
trusted_proxiesis part of the fix, not a doc note, and it can't be left as a local edit. TheCaddyfileis tracked and mounted from the checkout (docker-compose.yaml:16), anddocs/en/5-reference/5.6-docker.md:40already tells admins to leavedocker-compose.yamlunchanged sogit pullnever conflicts. Read the setting from the environment:Pass
TRUSTED_PROXIESto thecaddyservice the same way asSITE_ADDRESS, and setTRUSTED_PROXIES=private_rangesin.env.templatebesideBIND_ADDRESS=127.0.0.1. In Caddy 2.7.6, whichcaddy:2.7-alpineresolves to,{$VAR}is replaced as text before the file is tokenized, andstaticwith no ranges parses. So an.envfrom before this change trusts nothing, which is today's behaviour, and existing instances need the line added, likeBIND_ADDRESS(2.1-deployment.md:95). WithBIND_ADDRESS=0.0.0.0,TRUSTED_PROXIESmust be the front proxy's address only. Withprivate_ranges, a LAN client that connects toAPP_PORTdirectly is trusted, Caddy keeps its forgedX-Forwarded-For, and the client picks its own bucket. Say that in the same.env.templatecomment.The api's trust setting
trustProxyinto trust-nothing (lib/request.js:51-55).trustProxy: 1compiles and changes nothing.'loopback,uniquelocal'has two gaps. A front proxy on a public address, which the0.0.0.0setup allows, is not trusted, so every request is keyed on that proxy again. And behind a front proxy that appends to the header (nginx's$proxy_add_x_forwarded_for), the api walks past a private client address, so a LAN or VPN client that sendsX-Forwarded-For: <anything>picks its own bucket.TRUSTED_PROXIESwith no setting of its own. That matters becauseapps/apimust not readprocess.envoutside$Env(.agents/docs/playbooks/add-env-var.md, step 6).node:net'sBlockListcan do the private check, so no new dependency is needed.Apart from the throttler, nothing in
apps/api/srcor libnest'ssrcreadsrequest.ip,ips,host,hostnameorprotocol, so also trustingX-Forwarded-HostandX-Forwarded-Protohas no other effect there. The fix also makesASSIGNMENT_EMAIL_THROTTLER_LIMITper client, as its comment atapps/api/src/core/constants.ts:5says. Today 20 assignment emails a minute is the cap for the whole instance.Tests
Unit: put the trust function in its own module under
apps/api/src/core/, with a test for each hop rule.Integration (
apps/api/test/suites/), all fromremoteAddress: '172.18.0.5'unless stated:X-Forwarded-For: 203.0.113.1, 172.18.0.1is keyed on203.0.113.1;X-Forwarded-For: 198.51.100.9, 10.1.2.3, 172.18.0.1is keyed on10.1.2.3, not the forged entry;remoteAddress,X-Forwarded-Foris ignored.The second client can also send wrong credentials and expect 401 instead of 429. Then the suite needs no state from earlier suites.
An e2e test is possible after all. Playwright reaches the api through the web dev server's
/apiproxy on loopback (apps/web/vite.config.ts:68-76). That proxy does not setX-Forwarded-Foritself, sotesting/src/specs/auth.spec.tscan run the same check throughapiRequestContext, with a differentX-Forwarded-Forper client. Until the fix lands, that test uses up the loopback login bucket that every other spec logs in from.The
Caddyfilehas no test tier. Runcaddy adaptincaddy:2.7-alpinewithTRUSTED_PROXIESunset and with it set toprivate_ranges.Triage note written with Claude Code. Code references point at
main@d86740e.