Skip to content

Survive a missing client address, and surface the degraded default - #138

Merged
louisbels merged 1 commit into
mainfrom
fix/reverse-proxy-client-ip
Jul 26, 2026
Merged

Survive a missing client address, and surface the degraded default#138
louisbels merged 1 commit into
mainfrom
fix/reverse-proxy-client-ip

Conversation

@louisbels

Copy link
Copy Markdown
Member

Found by deploying the previous work to a real instance behind Traefik. Both problems affect any self-hosted deployment behind a reverse proxy, not one particular setup.

1. Setting ADDRESS_HEADER took the site down

With ADDRESS_HEADER set, adapter-node throws when the header is absent rather than falling back. And it is legitimately absent on every request that does not come through the proxy — starting with the container health check.

The proxy handler called getClientAddress() unguarded, so:

health check → no X-Forwarded-For → getClientAddress() throws
  → health check fails → container never healthy
  → reverse proxy drops it from the pool → site 404s

Two minutes of downtime on a real instance, and nothing in the logs pointed at the header.

It now degrades instead of throwing. Note what happens on the fallback path:

if (clientAddress) headers.set("x-forwarded-for", clientAddress);
else headers.delete("x-forwarded-for");

Deleting rather than passing through is deliberate: falling back to the incoming header would hand the rate limiter a value the client chose — the spoofing this proxy exists to prevent. Both branches are tested, including the health-check case that caused the outage.

2. The broken-by-default state was invisible

In the bundled image every browser request reaches the API through the web app's proxy, so the API's peer is always 127.0.0.1. Without TRUSTED_PROXIES it correctly refuses to believe the forwarded address and falls back to the peer — so every user shares a single rate-limit bucket.

The limits still apply. They just apply to the whole instance at once, so one busy visitor exhausts them for everybody. No operator would notice: nothing fails, nothing is logged, and the README says "per-IP rate limiting".

The API now says so at startup:

WARNING: TRUSTED_PROXIES is empty, so per-IP rate limits are shared by all
users behind any proxy — including the web app bundled in this image.
If this instance sits behind a reverse proxy, set:
  TRUSTED_PROXIES=127.0.0.1/32
and, on the web app, ADDRESS_HEADER=X-Forwarded-For plus XFF_DEPTH=<number
of proxies in front of it>. See docs/self-hosting.md#reverse-proxy--https.
Ignore this if the API is exposed directly, with no proxy in front.

Silent when a proxy is trusted, and explicit that a directly-exposed API can ignore it.

Documentation

docs/self-hosting.md gains Making rate limiting work behind a proxy: the three variables, why XFF_DEPTH matters (X-Forwarded-For is a list the client can prefill — only the entries your own proxies appended are trustworthy, so too high lets a client pick its bucket and too low puts everyone back in one), the proxy_set_header directives Nginx needs and Caddy/Traefik set on their own, and a command to confirm the health check still passes:

docker compose exec app node -e "fetch('http://localhost:3000/api/health').then(r=>console.log(r.status))"

That last one is the check I should have run before declaring the config change good.

.env.example had TRUSTED_PROXIES documented as "leave empty when the server is exposed directly (recommended default)" — accurate for a bare API, misleading for this image, where a proxy is always involved. Corrected, with ADDRESS_HEADER / XFF_DEPTH documented alongside since all three are needed together.


843 tests at 100% coverage; lint, typecheck and build pass.

…default

Setting ADDRESS_HEADER on a real deployment took the site down, and the failure
generalises to anyone running behind a reverse proxy.

With ADDRESS_HEADER set, adapter-node *throws* when that header is absent
instead of falling back — and it is legitimately absent on every request that
does not come through the proxy, starting with the container health check. The
proxy handler called getClientAddress() unguarded, so the exception propagated:
health check failed, orchestrator marked the container unhealthy, pulled it from
the pool, and the site 404'd. Nothing in the logs pointed at the header.

It now degrades instead of throwing. When the address cannot be resolved the
incoming X-Forwarded-For is *deleted* rather than passed through: falling back
to it would hand the rate limiter a value the client chose, which is the
spoofing this proxy exists to prevent. Both paths are tested, including the
health-check case that caused the outage.

The deeper problem was that the broken-by-default state was invisible. In the
bundled image every browser request reaches the API through the web app's
proxy, so the peer is always 127.0.0.1; without TRUSTED_PROXIES the API
correctly refuses to believe the forwarded address and falls back to the peer —
meaning every user shares one rate-limit bucket. The limits still apply, they
just apply to the whole instance at once, and no operator would notice.

The API now says so at startup, naming the three variables that fix it.
docs/self-hosting.md gains a section covering them, why XFF_DEPTH matters
(X-Forwarded-For is a client-prefillable list; only the entries your own
proxies appended can be trusted), the Nginx directives Caddy and Traefik set on
their own, and a command to verify the health check still passes afterwards —
the exact check I skipped.
@louisbels
louisbels merged commit 91f28f6 into main Jul 26, 2026
12 checks passed
@louisbels
louisbels deleted the fix/reverse-proxy-client-ip branch July 26, 2026 12:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant