Skip to content

Behind the bundled Caddy proxy every client shares one rate-limit bucket, so 51 failed logins a minute lock every user out #1732

Open

Description

@joshunrau

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:

  1. Send 51 POST /v1/auth/login requests with wrong credentials, 60 ms apart, from X-Forwarded-For: 203.0.113.1 through 203.0.113.51.
  2. 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 a remoteAddress but sending different X-Forwarded-For headers.
  • 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

  1. added
    BugType: existing behavior is wrong
    Priority: HighSignificant or frequently hit user impact; target the next release or two
    Difficulty: LowIsolated change, about 2 hours or less
    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)
    SecurityAuthentication, authorization, data exposure or a vulnerable dependency
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). Nothing has touched apps/api/src/main.ts, the Caddyfile or docker-compose.yaml since this was filed, and no open PR edits them.

    trustProxy alone does not fix the documented deployments

    Both setups in .env.template:17-19 put a web server in front of the bundled Caddy, and step 6 of the deployment tutorial is a host Caddy with reverse_proxy localhost:5500 (docs/en/2-tutorials/2.1-deployment.md:109-123). The bundled Caddy trusts no proxy, so reverse_proxy replaces the incoming X-Forwarded-For with 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 the main.ts change, request.ip is that one address for every request, and the bucket is still shared. The repro's distinct address per client only happens when clients connect to APP_PORT directly.

    So Caddy's trusted_proxies is part of the fix, not a doc note, and it can't be left as a local edit. The Caddyfile is tracked and mounted from the checkout (docker-compose.yaml:16), and docs/en/5-reference/5.6-docker.md:40 already tells admins to leave docker-compose.yaml unchanged so git pull never conflicts. Read the setting from the environment:

    {
    	servers {
    		trusted_proxies static {$TRUSTED_PROXIES}
    	}
    }
    

    Pass TRUSTED_PROXIES to the caddy service the same way as SITE_ADDRESS, and set TRUSTED_PROXIES=private_ranges in .env.template beside BIND_ADDRESS=127.0.0.1. In Caddy 2.7.6, which caddy:2.7-alpine resolves to, {$VAR} is replaced as text before the file is tokenized, and static with no ranges parses. So an .env from before this change trusts nothing, which is today's behaviour, and existing instances need the line added, like BIND_ADDRESS (2.1-deployment.md:95). With BIND_ADDRESS=0.0.0.0, TRUSTED_PROXIES must be the front proxy's address only. With private_ranges, a LAN client that connects to APP_PORT directly is trusted, Caddy keeps its forged X-Forwarded-For, and the client picks its own bucket. Say that in the same .env.template comment.

    The api's trust setting

    • Not a hop count. fastify 5.12.5, the version the lockfile resolves, turns a numeric trustProxy into trust-nothing (lib/request.js:51-55). trustProxy: 1 compiles and changes nothing.
    • 'loopback,uniquelocal' has two gaps. A front proxy on a public address, which the 0.0.0.0 setup 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 sends X-Forwarded-For: <anything> picks its own bucket.
    • A function closes both. Trust hop 0 only when it is loopback or private (Caddy), and always trust hop 1, the peer address Caddy wrote. When Caddy did not trust that peer, hop 1 is the last entry and nothing changes. When it did, hop 2 is the address the front proxy saw. The api then follows TRUSTED_PROXIES with no setting of its own. That matters because apps/api must not read process.env outside $Env (.agents/docs/playbooks/add-env-var.md, step 6). node:net's BlockList can do the private check, so no new dependency is needed.

    Apart from the throttler, nothing in apps/api/src or libnest's src reads request.ip, ips, host, hostname or protocol, so also trusting X-Forwarded-Host and X-Forwarded-Proto has no other effect there. The fix also makes ASSIGNMENT_EMAIL_THROTTLER_LIMIT per client, as its comment at apps/api/src/core/constants.ts:5 says. 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 from remoteAddress: '172.18.0.5' unless stated:

      • the issue's case;
      • X-Forwarded-For: 203.0.113.1, 172.18.0.1 is keyed on 203.0.113.1;
      • X-Forwarded-For: 198.51.100.9, 10.1.2.3, 172.18.0.1 is keyed on 10.1.2.3, not the forged entry;
      • from a public remoteAddress, X-Forwarded-For is 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 /api proxy on loopback (apps/web/vite.config.ts:68-76). That proxy does not set X-Forwarded-For itself, so testing/src/specs/auth.spec.ts can run the same check through apiRequestContext, with a different X-Forwarded-For per client. Until the fix lands, that test uses up the loopback login bucket that every other spec logs in from.

    • The Caddyfile has no test tier. Run caddy adapt in caddy:2.7-alpine with TRUSTED_PROXIES unset and with it set to private_ranges.

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessPriority: HighSignificant or frequently hit user impact; target the next release or twoSecurityAuthentication, authorization, data exposure or a vulnerable dependency

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions