Repository navigation
fix(auth): key the compose login lockout by client, not by nginx - #6519
Conversation
willem-bd
left a comment
There was a problem hiding this comment.
Read-only static review of pinned current revision; verified introduced findings below. No PR code or tests executed.
| if cached is not None and time.monotonic() < cached[0]: | ||
| return cached[1] | ||
| try: | ||
| infos = await asyncio.get_running_loop().getaddrinfo(hostname, None, type=socket.SOCK_STREAM) |
There was a problem hiding this comment.
[P2] Coalesce concurrent trusted-proxy DNS cache misses
The cache is populated only after this await, with no per-host inflight reservation. With the new Docker default AUTH_TRUSTED_PROXIES=nginx, let the cached entry expire while DNS is slow or timing out, then send concurrent GET /api/v1/auth/setup-status requests. Every request sees the same miss and starts its own getaddrinfo before any result is cached; the endpoint's existing single-flight guard is reached only afterward. Thus an unauthenticated burst still drives one DNS lookup per request, including failing lookups, rather than one lookup per 10-second refresh. This queues redundant resolver work and stalls legitimate setup/login requests; on standard asyncio it also occupies the default executor shared with password verification and throttle policy reads. Reserve one shared resolution per hostname before yielding, have concurrent callers join it with cancellation-safe ownership, and add a gated-resolver concurrency regression rather than only the existing sequential cache test.
There was a problem hiding this comment.
Fixed in b29f449. The cache check and the in-flight registration now happen with no yield between them (_trusted_proxy_host_inflight); concurrent misses join that task through asyncio.shield, the task writes the cache itself, and a done-callback removes the in-flight entry only if it is still that task. So a cancelled request cannot cancel the shared lookup, and the result is kept even if every caller went away.
Two gated-resolver regressions (the fake getaddrinfo blocks in its executor thread): test_concurrent_cache_misses_share_one_lookup sends 20 concurrent requests → one resolver call, 20 correct answers; test_cancelled_request_does_not_cancel_the_shared_lookup cancels the request that started the lookup → the joiner still gets the answer, one call, result cached. Mutation-checked: dropping the in-flight lookup fails both, and joining without shield fails the cancellation test.
willem-bd
left a comment
There was a problem hiding this comment.
Reviewed head 9f18f4d. The diagnosis and the trust design look sound: keying on X-Real-IP only from a peer trusted by IP/CIDR/hostname, failing closed on unresolved hostnames, caching failed lookups, never resolving without X-Real-IP, and pinning nginx's $remote_addr overwrite for every Gateway location in all three configs is the right shape, and the compose default fixes the shared-bucket lockout. Two findings below, the first one blocking: PR #6501 (DB-backed login throttle) merged into main today and rewrites most of the same files, so this branch needs a rebase and one test needs rework against the store.
| assert auth_router._login_attempts[_CLIENT_IP][0] == 2 | ||
|
|
||
|
|
||
| async def test_trusted_proxy_hostname_resolution_does_not_block_loop(monkeypatch) -> None: |
There was a problem hiding this comment.
[P1] This branch predates #6501, which merged into main today and rewrote exactly these files: _login_attempts and the in-process _check_rate_limit/_record_failure_under_policy are gone (replaced by app.gateway.auth.login_throttle + deerflow.persistence.login_throttle with a store fixture), test_auth.py was heavily reshuffled around the same test_get_client_ip_* section this PR converts to async, and config.example.yaml, AUTH_DESIGN.md, and both CHANGELOGs were edited in the same regions. As written, this appended test cannot port as-is: it locks the forwarded client by writing auth_router._login_attempts[_CLIENT_IP] and drives auth_router.login_local against the old provider-stub context (_CLIENT_IP, _form()) that main's rewritten file no longer defines. Please rebase and re-lock the forwarded IP through the store fixture instead.
The good news: the two changes compose semantically. Main still has a sync _get_client_ip with the same two callers (login_local, setup_status) and keys the throttle store by the same client-IP string, so the async hostname-trust change and the compose default apply cleanly on top; only the throttle plumbing underneath differs. Note the PR's red/green and full-suite validation ran against a pre-#6501 main, so the suite should be rerun after the rebase.
There was a problem hiding this comment.
Rebased onto main with #6501 (now at b29f449). The blocking-I/O test now locks the forwarded client through the store fixture (record_failure × max_attempts), so it runs against both the memory and SQL stores; the two endpoint tests in test_auth.py use memory_throttle_store and assert on the store (fail_count under the browser address, nothing under nginx or the forged X-Forwarded-For entry). config.example.yaml, AUTH_DESIGN.md and both changelogs keep #6501's text with only this PR's sentences added.
Re-ran everything on the rebased head: red on current main (the endpoint localhost case fails 429 == 401, as does the Uvicorn-default proxy-middleware case), make test 26832 passed / 0 failed, make test-blocking-io 237 passed, and the Docker repro with #6501 in place (unset → victim 429, nginx → 401).
| # request reaches the Gateway from nginx. Trust nginx's X-Real-IP (it | ||
| # overwrites the header on every Gateway route) so one client's wrong | ||
| # passwords cannot lock everyone out. Hostname, re-resolved every 10s. | ||
| - AUTH_TRUSTED_PROXIES=${AUTH_TRUSTED_PROXIES:-nginx} |
There was a problem hiding this comment.
[P3] Compose's :- substitutes the default for an explicitly empty value too, so an operator who sets AUTH_TRUSTED_PROXIES= in .env — which the app and .env.example document as direct mode ("Empty / unset = no proxy is trusted") — silently gets nginx instead. There is no .env-only way to express direct mode inside the compose stack. If that is intended (the stack always has nginx in front, so direct mode is not meaningful there), consider saying so in the compose comment or .env.example; if not, ${AUTH_TRUSTED_PROXIES-nginx} (no colon) substitutes only when the variable is unset. Also worth covering in test_real_compose_renders_the_default_and_keeps_an_operator_override, which currently parametrizes only unset and non-empty values.
There was a problem hiding this comment.
Intended, so I kept :- and said why: inside the stack every browser request comes through nginx, so an empty value meaning direct mode would only bring the shared lockout back (and a stray AUTH_TRUSTED_PROXIES= line in .env would do that silently). The compose comment in both files and .env.example now say that an empty value falls back to nginx in Docker while it stays direct mode outside it. test_real_compose_renders_the_default_and_keeps_an_operator_override gained an explicitly-empty case (renders nginx); switching prod to ${AUTH_TRUSTED_PROXIES-nginx} fails it.
|
@hyeonsang010716 please resolve the conflicts with the main branch. |
Failed logins are counted per client IP, and the Gateway honors X-Real-IP only from a peer listed in AUTH_TRUSTED_PROXIES. The compose files never set it, and every browser request reaches the Gateway from the nginx container, so all logins shared nginx's address: five wrong passwords from anyone locked every user out for five minutes. AUTH_TRUSTED_PROXIES now also accepts hostnames, resolved off the event loop and cached for 10 seconds (failed lookups included, and only when X-Real-IP is present), and both compose files default it to the bundled nginx service, whose address Docker assigns. nginx overwrites X-Real-IP with $remote_addr on every Gateway route; a test pins that for the three maintained configs.
A burst of setup-status / login requests on an expired hostname entry started one getaddrinfo per request while DNS was slow, occupying the default executor that password verification also uses. The cache check and an in-flight task registration now happen without a yield between them; later callers join that task through asyncio.shield, so a cancelled request cannot cancel the lookup the others joined, and the task caches its own result. The compose default keeps `:-` on purpose: every browser request arrives through nginx, so an empty AUTH_TRUSTED_PROXIES would only bring back the shared lockout. The compose comment and .env.example say so, and the rendered-compose test now covers the explicitly empty value.
9f18f4d to
b29f449
Compare
|
@WillemJiang Conflicts resolved: rebased onto main including #6501 (head b29f449, mergeable again). The review items are answered in their threads; the PR description has the re-run numbers. |
willem-bd
left a comment
There was a problem hiding this comment.
Read-only static review of pinned current revision; verified introduced findings below. No PR code or tests executed.
| # `:-` on purpose: an empty value also means nginx, because every browser | ||
| # request arrives through it, so direct mode would only bring back the | ||
| # shared lockout. Set another list in .env to change whom to trust. | ||
| - AUTH_TRUSTED_PROXIES=${AUTH_TRUSTED_PROXIES:-nginx} |
There was a problem hiding this comment.
[P2] Preserve nonempty dev dotenv proxy overrides
With a nonempty AUTH_TRUSTED_PROXIES value in the checkout's .env and no shell export, normal make docker-start now silently replaces that allowlist with nginx. scripts/docker.sh runs Compose from docker/ without --env-file ../.env and only exports HTTP proxy variables, so this interpolation does not see the checkout .env. The explicit environment entry then overrides the value loaded through the Gateway's existing service env_file. Existing operator-defined IP/CIDR policies therefore stop applying: intended proxy peers can fall back to shared peer-address lockout buckets, while nginx is trusted even when absent from the configured list. Pass the checkout dotenv file to the actual dev Compose invocation (or otherwise preserve that value before applying the default), and cover the real launcher's cwd/arguments; the new render test supplies --env-file itself and misses this path.
There was a problem hiding this comment.
Confirmed and fixed in 830302f. docker.sh start runs Compose from docker/ without --env-file, so a non-empty .env value never reached interpolation, and the resolved environment: entry replaced the one env_file loaded. The launcher's load_proxy_env_from_dotenv already exports the proxy variables from .env for exactly this reason, so AUTH_TRUSTED_PROXIES is now in that list. A shell export still wins, as it does for Compose. I didn't add --env-file ../.env to the dev invocation: that would change interpolation for every variable in the dev compose file (PORT, BIND_HOST, DEER_FLOW_INTERNAL_AUTH_TOKEN, ...), which is out of scope here. While there, the loader now strips a CRLF before the quotes; before, a quoted value in a Windows-edited .env kept its quotes (this affected the proxy variables too).
test_make_docker_start_keeps_a_dotenv_override runs the real start with its own cwd, arguments and exports. A docker shell function swaps only up ... for config, and the test renders the real dev compose file. Cases: unset → nginx, explicitly empty → nginx, a .env list → verbatim, a quoted CRLF value → unquoted, and a shell export beats .env. Before the fix, the .env and CRLF cases rendered nginx. Each of the three changes is mutation-checked: dropping the export, restoring the old strip order, and letting .env override the shell each fail their case. scripts/AGENTS.md and both changelog entries now describe it, and the _render docstring no longer claims docker.sh passes --env-file.
docker.sh start runs Compose from docker/ without --env-file, so the dev
compose file's ${AUTH_TRUSTED_PROXIES:-nginx} interpolated from the shell
only, and the resolved environment entry outranked the operator's value
loaded through env_file. Export it from .env alongside the proxy variables
(a shell export still wins), and strip a CRLF before the quotes.
…onsang010716/deer-flow into fix/auth-trust-bundled-nginx
Why
The local login endpoint locks a client out after
auth.local.max_login_attemptsfailures (default 5) forlockout_seconds(default 300), counted per client IP._get_client_iptakes the TCP peer and honorsX-Real-IPonly when that peer is listed inAUTH_TRUSTED_PROXIES. That variable is not set anywhere: not in either compose file, Helm,.env.example,config.example.yamlor the README. The only mention is inbackend/docs/AUTH_DESIGN.md.In the Docker stack every browser request reaches the Gateway from the
nginxcontainer, so every login was counted under nginx's address. Five wrong passwords from anyone locked every user out of login for five minutes, and it can be repeated indefinitely. Uvicorn's ownX-Forwarded-Forhandling does not help here: it trusts only127.0.0.1by default, and nginx is never127.0.0.1inside the compose network.Reproduced on
mainwith the realdocker/nginx/nginx.confin annginx:alpinecontainer andmain's real auth router served by Uvicorn (compose's command, default proxy settings) in the Gateway image, on a separate Docker network. Each client was its own container with its own IP, going through nginx; only the password check was stubbed to always fail:The Gateway access log shows every request from the nginx container's address.
Found during a repo-wide bug review; there is no tracking issue.
What changed
AUTH_TRUSTED_PROXIESentries can now be hostnames as well as IPs and CIDRs. A hostname stands for every address it resolves to.getaddrinfo(executor), so it never blocks the event loop.resolver ... valid=10s), so a restarted proxy container that comes back on a new address is picked up.asyncio.shield, so a disconnecting request cannot cancel the lookup the others joined), and the task caches its own result.X-Real-IPnever triggers a lookup.10.0.0counts as invalid, so it is never handed to the resolver, which would read it as10.0.0.0._get_client_ipis nowasync. Its two callers,login_localandsetup_status, await it.AUTH_TRUSTED_PROXIES=${AUTH_TRUSTED_PROXIES:-nginx}to the Gateway. A value an operator sets in.envstill wins under both launchers:make up(deploy.sh) calls Compose with--env-file .env.make docker-start(docker.sh start) runs Compose fromdocker/without--env-file, so interpolation sees only the shell, and the resolvedenvironment:entry outranks the same key loaded throughenv_file.load_proxy_env_from_dotenv, which already exports the proxy variables from.envfor this reason, now exportsAUTH_TRUSTED_PROXIEStoo. A shell export still wins, as it does for Compose. It also strips a CRLF line ending before the quotes, so a quoted value in a Windows-edited.envloses its quotes (this applies to the proxy variables as well).:-is deliberate: an explicitly empty value also meansnginx, because every browser request arrives through it, so direct mode inside the stack would only bring back the shared lockout. The compose comment and.env.examplesay so.Why a hostname instead of a subnet: only
docker-compose-dev.yamlpins its subnet (192.168.200.0/24). The production network is a plain bridge whose subnet Docker assigns. Pinning it would risk colliding with operators' LAN/VPN ranges and would force a network recreate on upgrade. The service name is stable in both files, and it trusts exactly the proxy instead of every container on the network (redis, frontend, provisioner).Trusting nginx is safe only because nginx overwrites
X-Real-IPwith$remote_addron every route to the Gateway, so a client cannot choose its own bucket. That holds today in all three maintained nginx configs and is now pinned by a test.Deliberately out of scope:
$remote_addris the ingress controller (or a SNAT'd node address), so trusting the nginx pods alone would still give one bucket. A correct default needs nginx'sreal_ipconfigured for each cluster's ingress, which is a separate change.BIND_HOST=0.0.0.0behind an operator's own front door) have the same issue one hop earlier: nginx sees that proxy's address..env.examplenow says to configure nginx'sreal_ipmodule for it.(ip, email). That would let anyone who knows an address lock out that one account on purpose, which is a design change rather than this fix.FORWARDED_ALLOW_IPS=*. It makes Uvicorn take the leftmost, client-suppliedX-Forwarded-Forentry as the peer. That is pre-existing, not used by any launcher, and unaffected by this change.Forwarded/X-Forwarded-*trust on the same variable with an IP/CIDR-only parser. If it lands after this, the compose defaultnginxshould mean the same thing there. That's worth reconciling when one of the two rebases; this PR does not touch those headers.Docs:
.env.example(newAUTH_TRUSTED_PROXIESentry),backend/docs/AUTH_DESIGN.md,scripts/AGENTS.md(the dev launcher's dotenv export), the login-throttling comment inconfig.example.yaml,CHANGELOG.md,CHANGELOG_zh.md. The README does not describe the lockout or proxy trust, and with the compose default the Docker stack needs no operator action, so it remains accurate.Surface area
frontend/backend/appPOST /api/v1/auth/login/localandGET /api/v1/auth/setup-statuskey their per-IP state by the forwarded client address in the compose stack instead of nginx's.langgraph.json, or prompt changedocker/or sandboxed executiondocker/docker-compose.yamlanddocker/docker-compose-dev.yamlsetAUTH_TRUSTED_PROXIESon the Gateway.scripts/docker.sh startexportsAUTH_TRUSTED_PROXIESfrom.envfor dev-compose interpolation.skills/backend/pyproject.tomlorfrontend/package.jsonX-Real-IPfor the login lockout by default. Outside Docker nothing changes unlessAUTH_TRUSTED_PROXIESis set.Screenshots / Recording
N/A (no UI change).
Bug fix verification
app/and the compose defaultAUTH_TRUSTED_PROXIES=nginx. Rerun on the branch rebased onto fix(auth): count login lockouts in the database across Gateway replicas #6501: unset, the victim's first attempt is429; withnginx, it is401.401, and the attacker's sixth returns429..3→.4) and waiting past the cache TTL, the attacker is still locked out on their own and a new client gets401.X-Real-IPsent through nginx is overwritten and gets the sender no new bucket. I checked this in the control run onmainwith nginx's subnet trusted; the overwrite is nginx's, so it is the same here.backend/tests/test_auth.py:test_login_lockout_is_per_client_behind_a_proxy_named_by_hostname[localhost-401|None-429]drives the real login endpoint over ASGI from a proxy peer. Five failures from one forwarded client, then a different client:401with a trusted hostname. TheNonecase documents the shared bucket.test_login_lockout_key_through_uvicorn_proxy_headers[uvicorn-default|aligned-with-nginx]runs the same request through Uvicorn'sProxyHeadersMiddleware, which runs before the app. With the default trusted hosts, and withFORWARDED_ALLOW_IPSaligned to nginx, the lockout is keyed by the browser's address and never by a forgedX-Forwarded-Forentry.X-Real-IP, and invalid entries (not an ip,10.0.0) are warned about and not resolved.test_concurrent_cache_misses_share_one_lookupgates the resolver in its executor thread and sends 20 concurrent requests: one resolver call, 20 correct answers.test_cancelled_request_does_not_cancel_the_shared_lookupcancels the request that started a gated lookup: the request that joined it still gets the answer, there is one resolver call, and the result is cached.test_get_client_ip_*tests nowawaitthe function; their assertions are unchanged.backend/tests/blocking_io/test_login_local_throttle.py::test_trusted_proxy_hostname_resolution_does_not_block_loop[memory|sql]: the strict Blockbuster gate on the login path with a hostname entry. The forwarded client is locked through theLoginThrottleStorefixture from fix(auth): count login lockouts in the database across Gateway replicas #6501.backend/tests/test_compose_auth_trusted_proxies.py: both compose files carry the default and share a network withnginx. The realdocker compose configrendersnginxwhen the variable is unset or explicitly empty, and an.envoverride verbatim.test_make_docker_start_keeps_a_dotenv_overrideruns the realdocker.sh start(its cwd, arguments and environment; onlyup ...is swapped forconfig) against the real dev compose file: unset and explicitly empty rendernginx; a plain, and a quoted CRLF,.envvalue renders verbatim; a shell export beats.env. Every Gatewaylocationin the three nginx configs overwritesX-Real-IPwith$remote_addr.main, green on this branch: yes. Against currentmainincluding fix(auth): count login lockouts in the database across Gateway replicas #6501 (separate worktree), the endpoint test'slocalhostcase fails withassert 429 == 401, and so does the Uvicorn-default case of the proxy-middleware test. The hostname tests fail as well, and so do the compose default tests and the blocking-I/O test. The seven converted tests fail onmainonly because they await a sync function, which isn't meaningful.asyncio.shield: fails the cancellation test.${AUTH_TRUSTED_PROXIES-nginx}(empty value means direct mode): fails 2 tests.X-Real-IP: fails 2 tests.test_get_client_ip_xff_never_honoredalso catches the empty-header return.socket.getaddrinfoon the event loop: fails 4 tests, including the blocking-I/O test.X-Real-IP $remote_addrfrom nginx's/api/location: fails the nginx test.docker.shnot exportingAUTH_TRUSTED_PROXIESfrom.env(the state the second review found): fails thedotenv-overrideandquoted-crlfcases.quoted-crlf..envoverriding a shell export: failsshell-export-wins.Validation
These numbers are from the docker.sh fix (830302f), on top of the maintainer's merge of
main. One test was deselected:test_subagent_batch_service.py::test_cancelled_batch_stop_allows_successful_retry(from #6517) hangs in this environment. It hangs the same way on upstreammain(5ecc1c2), where the file is identical, and it is unrelated to this change.scripts/check_agent_guidance.py --base-ref main --head-ref HEAD: 0 errors, 0 warnings.AI assistance
Tool(s) used: Claude Code
How you used it: Claude Code was used as an implementation and verification assistant. I led the investigation: I picked this finding out of a repo-wide bug review, had Claude Code trace how the client IP is derived through nginx, Uvicorn's proxy-header middleware and
_get_client_ip, and accepted it as a bug only after reproducing the shared lockout with the real nginx config and auth router in Docker. That check also showed that the original finding's suggested fix, a subnet default, cannot work for the production compose file, whose subnet Docker assigns.I then defined the regression coverage: the real login endpoint from a proxy peer, the same request through Uvicorn's proxy middleware, the hostname cache's failure and expiry behavior, a strict blocking-I/O check of the lookup, the rendered compose default and override, and an nginx-config guard on the
X-Real-IPoverwrite that makes trusting nginx safe. I had Claude Code write the failing tests first, then the fix, and directed and reviewed the validation: the red check againstmain, the mutation check of each change, the end-to-end Docker reruns including an nginx restart on a new address, the full backend suite and the agent-guidance check. Claude Code helped draft the docs and changelog entries, which I reviewed and refined.I also made the final design decisions: naming the proxy by hostname instead of trusting a subnet, caching failed lookups, skipping the lookup without
X-Real-IP, making_get_client_ipasync rather than adding a separate refresh step a future caller could forget, and keeping Helm, outer reverse proxies,(ip, email)keys andFORWARDED_ALLOW_IPS=*out of this PR.In short, Claude Code accelerated implementation and verification; I owned the investigation, technical decisions, scope, review, and final changes.