Skip to content

feat: add per-IP login rate limit alongside per-account lockout - #184

Open
jakubfilinger-b wants to merge 8 commits into
devfrom
feat/per-ip-login-rate-limit
Open

jakubfilinger-b wants to merge 8 commits into
devfrom
feat/per-ip-login-rate-limit

Conversation

@jakubfilinger-b

Copy link
Copy Markdown
Collaborator

What

Adds a coarse per-IP throttle on /identity/login, separate from the existing per-account
lockout (user.failedLoginAttempts / lockoutUntil) and the existing per-email request
throttle (login:<email>, 10/5min).

Why

The per-email throttle and per-account lockout both key on the email/account being attacked,
so they see repeated guessing against one account but are blind to credential stuffing
spread thin across many accounts from the same source (a botnet, a leaked-credential list).
A coarse per-IP counter closes that gap without touching the per-account behavior at all.

How

  • RATE_LIMIT_KEYS.LOGIN_IP ('login-ip') added to the rate-limit key registry.
  • IdentityServiceOptions.loginIpRateLimit (enabled?, limit?, windowMs?) lets a
    downstream operator tune or disable the threshold without a core change. Default is
    100 requests / 15 min - well above the 10/5min per-email budget so a shared network
    (office NAT) is never blocked.
  • Fails closed (onUnavailable: 'deny'), matching the other credential-guessing keys
    (login:, pwreset:, verify2fa:, ...) - an unthrottled window during a limiter outage
    is worse than a transient 429 on this surface.
  • Skipped entirely when the request carries no IP, rather than bucketing under a shared
    'unknown' key - that shared key would let one IP-less client's traffic exhaust the
    budget for every other IP-less client, a self-inflicted DoS.
  • Not reset on a successful login (unlike the per-email key), so a successful login on one
    account can't be used to clear the IP's failure budget.

Tests

identity.rate-limit.int.test.ts (real Redis): per-IP exhaustion across different accounts,
bucket isolation between IPs, the no-IP skip, the enabled: false override, fail-closed
behavior on backend unavailability.

identity.service.int.test.ts (real Postgres + Redis): locking one account never blocks a
different account signing in from the same IP.

99/99 tests green (pnpm -F @openora/core test:integration). check:types, check:lint,
check:format, check:shape clean.

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

…kout

Adds a coarse per-IP throttle on /identity/login, separate from the
existing per-account lockout and per-email request throttle. Protects
against credential stuffing spread across many accounts from one
source, which the per-email counter can't see since each guess lands
on a different key.

- New RATE_LIMIT_KEYS.LOGIN_IP ('login-ip') key prefix.
- IdentityServiceOptions.loginIpRateLimit lets an operator tune or
  disable the threshold; default is 100/15min, well above the 10/5min
  per-email budget so a shared network (office NAT) is never blocked.
- Fails closed (onUnavailable: 'deny'), matching the other
  credential-guessing keys.
- Skipped when the request has no IP, rather than bucketing under a
  shared 'unknown' key - that shared key would let one IP-less
  client's traffic exhaust the budget for every other IP-less client.
- Not reset on successful login (unlike the per-email key), so a
  successful login on one account can't be used to clear the IP's
  failure budget.

Tests cover: per-IP exhaustion across different accounts, per-IP
bucket isolation, the no-IP skip, the enabled:false override, the
fail-closed behavior on backend unavailability, and that locking one
account never blocks a different account signing in from the same IP.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread packages/core/src/pam/identity/service/identity.service.ts Outdated
Comment thread packages/core/src/contracts/adapters/identity.ts
Comment thread packages/core/src/pam/identity/__tests__/identity.rate-limit.int.test.ts Outdated

@damianrzepka damianrzepka left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NO-GO — the per-IP credential-stuffing protection can be bypassed by spoofing the request IP.

kubafilinger and others added 2 commits September 17, 2026 12:39
…keting test

Review feedback (PR #184, @damianrzepka):

- IdentityLoginIpRateLimitOptions was defined but never re-exported from
  the @openora/core/contracts barrel alongside its siblings
  (IdentityServiceOptions, IdentityLockoutOptions), so a downstream
  operator had no supported way to import the type.
- The 'buckets IPs separately' test asserted only .rejects.not.toMatchObject
  ({ code: 'TOO_MANY_REQUESTS' }), which also passes on an unrelated
  TypeError (signInEmail is unconfigured elsewhere in this file) -
  it never proved the unthrottled IP's login actually succeeded. Stubs a
  real signInEmail success now and asserts the returned session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-ip throttle

Follow-up to review feedback (PR #184, @damianrzepka): the per-IP protection
is only meaningful behind a reverse proxy that overwrites X-Real-IP with the
real peer address before the request reaches this service - the header is
otherwise attacker-controlled (extractClientMeta reads it unconditionally).
That assumption already applies to register-ip:/verify-email-ip:/
change-email-ip:/confirm-email-change-ip: elsewhere in this file; this makes
it explicit at both the option type and the call site rather than leaving it
implicit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@jakubfilinger-b

Copy link
Copy Markdown
Collaborator Author

Addressed the barrel-export and test-assertion findings directly (`f1fdbe80`). Replied inline on the IP-spoofing finding with the scope analysis - it's a real characteristic, but pre-existing and shared by 4 other per-IP throttles already in this file (`register-ip:`, `verify-email-ip:`, `change-email-ip:`, `confirm-email-change-ip:`), not something this diff introduces. Made the trust assumption explicit in the code (`9c9c69b3`) rather than leaving it implicit. Over to you on whether that resolves the NO-GO or you'd rather see it tracked as a separate issue against `extractClientMeta` before this merges.

Comment thread packages/core/src/pam/identity/service/identity.service.ts Outdated
Comment thread packages/core/src/pam/identity/service/identity.service.ts Outdated
…g IP

Review feedback (PR #184, @zaxovaiko):

- The per-email gate ran before the per-IP one, so a single IP that had
  already tripped its own throttle could keep spending a targeted
  email's much narrower 10/5min budget on every further attempt. The
  IP gate now runs first, so an IP over its own limit is rejected
  before it ever touches an email's budget.
- Skipping the per-IP gate outright when the IP can't be derived let
  an attacker disable the new protection for free by simply not
  sending X-Real-IP. Falls back to a shared 'unknown' bucket instead,
  matching every sibling per-IP key already in this file (register-ip:,
  verify-email-ip:, change-email-ip:, confirm-email-change-ip:).

Tests: new coverage for the ordering fix (an over-limit IP can't touch
the targeted email's budget) and the 'unknown' fallback; updated two
existing tests whose assertions depended on the old ordering. 100/100
green.
zaxovaiko
zaxovaiko previously approved these changes Sep 17, 2026

@zaxovaiko zaxovaiko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More comments than actual code 😄

Comment thread packages/core/src/pam/identity/service/identity.service.ts
Every per-IP throttle (login-ip, register-ip, verify-email-ip, change-email-ip,
confirm-email-change-ip) and the geo checks key on X-Real-IP, which createApp
passed through verbatim from any caller. A direct client could rotate the header
per request and never exhaust a bucket.

createApp now decides the client address at the ingress: forwarding headers are
kept only when the socket peer is a trusted proxy. Any other peer gets its
socket address written over X-Real-IP and its X-Forwarded-For dropped. The list
comes from `trustedProxies`, then TRUSTED_PROXIES, then loopback plus the
private ranges, so an existing deployment behind nginx on the same host or
private network keeps working. Entries are validated at boot; a malformed one
fails createApp before anything is bound. In-process requests with no socket
are left as they are.
Comment thread packages/core/src/server/runtime/client-address.ts Outdated
Comment thread packages/core/src/server/runtime/client-address.ts Outdated
…F-only proxies

A trusted proxy entry with an empty suffix (`10.0.0.1/`) parsed as /0, because
`Number('')` is 0, and trusted every IPv4 peer. Entries are now matched against an
anchored pattern, so an empty or second suffix is refused at boot.

A trusted proxy that sends only X-Forwarded-For left X-Real-IP empty, so every
login behind it shared the `login-ip:unknown` bucket. Behind a trusted proxy,
X-Real-IP now always holds a valid address. That is the proxy's own X-Real-IP
when it sent a usable one. Otherwise it is the client from X-Forwarded-For,
read right to left and stopping at the first untrusted hop. If neither gives
one, the proxy's socket address is used.
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.

4 participants