fix(security): normalize IPv6 in rate-limit keyGenerator (ADR-0021) #260

Merged
julien merged 1 commits from fix/bff-rate-limit-ipv6-key into main 2026-06-01 12:31:54 +02:00
Owner

Summary

Fix ERR_ERL_KEY_GEN_IPV6 raised by express-rate-limit at BFF boot: the rate-limit middleware's custom keyGenerator was using req.ip verbatim, which the library v8 refuses because it lets an IPv6 attacker rotate through the host bits of their own subnet to escape per-IP rate-limiting. Surfaced during the ADR-0030 dockerised dev mode validation (the boot log shows the ValidationError immediately after the rate-limit middleware is built).

Root cause

apps/portal-bff/src/security/rate-limit.middleware.ts returned:

return `ip:${req.ip ?? 'unknown'}`;

req.ip is the raw address the IP-trust chain hands Express. For IPv4 that's already the right bucket key. For IPv6, every distinct host in an attacker's allocation hashes to a different bucket — even though the same human controls all of them. An attacker on a residential IPv6 assignment (typically /56) thus has ~2^72 trivially-rotatable buckets per /56, which makes the per-IP rate limit useless against them.

express-rate-limit v7+ ships an ipKeyGenerator helper that truncates the address to its /56 prefix before keying. The library v8 raises ERR_ERL_KEY_GEN_IPV6 at boot if a custom keyGenerator returns req.ip verbatim, precisely to refuse shipping this bypass.

Fix

File Change
apps/portal-bff/src/security/rate-limit.middleware.ts Import ipKeyGenerator from express-rate-limit; wrap req.ip through it when keying. IPv4 addresses pass through unchanged; IPv6 addresses get truncated to their /56. Comment explains the rationale + that the lib's /56 default matches a typical residential ISP customer allocation.
apps/portal-bff/src/security/rate-limit.middleware.spec.ts New test asserting two IPv6 addresses in the same /56 share a bucket (the bypass would have set both apart), and that distinct /56s remain isolated (truncation does not collapse all IPv6 traffic into one global bucket). Existing IPv4 / session / SKIP_PATHS tests are unchanged and still pass — ipKeyGenerator is a no-op for IPv4.

The session-keyed branch (s:${sessionID}) is untouched: sessions key on the BFF-issued session id, not the address.

Why the BFF kept booting despite the error

The log shows bootstrap reaching AuthModule immediately after the ValidationError printout. express-rate-limit v8's errorHandler defaults to logging the error and continuing rather than throwing for this specific validation, so the middleware was effectively running with the unfixed keyGenerator until now — i.e. the bypass was live in the dev BFF. Fixed pre-emptively, before any prod consumer.

Related

  • Per ADR-0021 — Phase-2 security baseline, rate-limit section.
  • Surfaced by the ADR-0030 dockerised dev mode validation (the SPA /api/auth/me proxy errors visible in the same log run were a side effect of the BFF restarting on every config validator until the env was fully populated; unrelated to this fix).

Test plan

  • pnpm exec nx test portal-bff --testFile=apps/portal-bff/src/security/rate-limit.middleware.spec.ts — 794 tests pass, including the new /56 isolation case.
  • Restart the BFF (./infra/local/dev.sh restart portal-bff) — ERR_ERL_KEY_GEN_IPV6 no longer appears in the boot log.
  • IPv4 traffic still rate-limits as before (existing test coverage; no behavioural change since ipKeyGenerator is identity for v4).
## Summary Fix `ERR_ERL_KEY_GEN_IPV6` raised by `express-rate-limit` at BFF boot: the rate-limit middleware's custom `keyGenerator` was using `req.ip` verbatim, which the library v8 refuses because it lets an IPv6 attacker rotate through the host bits of their own subnet to escape per-IP rate-limiting. Surfaced during the ADR-0030 dockerised dev mode validation (the boot log shows the `ValidationError` immediately after the rate-limit middleware is built). ## Root cause `apps/portal-bff/src/security/rate-limit.middleware.ts` returned: ```ts return `ip:${req.ip ?? 'unknown'}`; ``` `req.ip` is the raw address the IP-trust chain hands Express. For IPv4 that's already the right bucket key. For IPv6, every distinct host in an attacker's allocation hashes to a different bucket — even though the same human controls all of them. An attacker on a residential IPv6 assignment (typically `/56`) thus has ~2^72 trivially-rotatable buckets per `/56`, which makes the per-IP rate limit useless against them. `express-rate-limit` v7+ ships an `ipKeyGenerator` helper that **truncates the address to its `/56` prefix** before keying. The library v8 raises `ERR_ERL_KEY_GEN_IPV6` at boot if a custom `keyGenerator` returns `req.ip` verbatim, precisely to refuse shipping this bypass. ## Fix | File | Change | | --- | --- | | `apps/portal-bff/src/security/rate-limit.middleware.ts` | Import `ipKeyGenerator` from `express-rate-limit`; wrap `req.ip` through it when keying. IPv4 addresses pass through unchanged; IPv6 addresses get truncated to their `/56`. Comment explains the rationale + that the lib's `/56` default matches a typical residential ISP customer allocation. | | `apps/portal-bff/src/security/rate-limit.middleware.spec.ts` | New test asserting two IPv6 addresses in the same `/56` share a bucket (the bypass would have set both apart), and that distinct `/56`s remain isolated (truncation does not collapse all IPv6 traffic into one global bucket). Existing IPv4 / session / `SKIP_PATHS` tests are unchanged and still pass — `ipKeyGenerator` is a no-op for IPv4. | The session-keyed branch (`s:${sessionID}`) is untouched: sessions key on the BFF-issued session id, not the address. ## Why the BFF kept booting despite the error The log shows `bootstrap` reaching `AuthModule` immediately after the `ValidationError` printout. `express-rate-limit` v8's `errorHandler` defaults to logging the error and continuing rather than throwing for this specific validation, so the middleware was effectively running with the unfixed `keyGenerator` until now — i.e. the bypass was live in the dev BFF. Fixed pre-emptively, before any prod consumer. ## Related - Per [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md) — Phase-2 security baseline, rate-limit section. - Surfaced by the ADR-0030 dockerised dev mode validation (the SPA `/api/auth/me` proxy errors visible in the same log run were a side effect of the BFF restarting on every config validator until the env was fully populated; unrelated to this fix). ## Test plan - [x] `pnpm exec nx test portal-bff --testFile=apps/portal-bff/src/security/rate-limit.middleware.spec.ts` — 794 tests pass, including the new `/56` isolation case. - [ ] Restart the BFF (`./infra/local/dev.sh restart portal-bff`) — `ERR_ERL_KEY_GEN_IPV6` no longer appears in the boot log. - [ ] IPv4 traffic still rate-limits as before (existing test coverage; no behavioural change since `ipKeyGenerator` is identity for v4).
julien added 1 commit 2026-06-01 12:31:05 +02:00
fix(security): normalize IPv6 in rate-limit keyGenerator (ADR-0021)
CI / scan (pull_request) Successful in 4m34s
CI / commits (pull_request) Successful in 4m33s
CI / check (pull_request) Successful in 5m3s
CI / a11y (pull_request) Successful in 1m53s
CI / perf (pull_request) Successful in 8m32s
6695909db2
express-rate-limit v8 raises ERR_ERL_KEY_GEN_IPV6 at boot because the
custom keyGenerator returned req.ip verbatim. For IPv6, that lets an
attacker rotate through the host bits of their own subnet (~2^72 keys
on a typical /56 residential allocation) and escape per-IP rate
limiting entirely — useful as a brute-force protection it isn't, then.

Wrap req.ip through the library's ipKeyGenerator helper before keying,
which truncates IPv6 addresses to their /56 prefix and is a no-op for
IPv4. Test coverage for IPv4 / session / SKIP_PATHS unchanged; new
case asserts two addresses in the same /56 share a bucket and that
distinct /56s remain isolated.

Surfaced by the ADR-0030 dockerised dev mode validation. The BFF kept
booting (v8's default error handler logs and continues for this
validation), so the bypass was live in dev until now.
julien merged commit f9f5f171eb into main 2026-06-01 12:31:54 +02:00
julien deleted branch fix/bff-rate-limit-ipv6-key 2026-06-01 12:31:57 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#260