Skip to content

A login limiter that cannot lock you out, and a TOTP guard that fails closed - #368

Merged
Apolloccrypt merged 4 commits into
mainfrom
fix/login-ratelimit-and-nx
Sep 2, 2026
Merged

A login limiter that cannot lock you out, and a TOTP guard that fails closed#368
Apolloccrypt merged 4 commits into
mainfrom
fix/login-ratelimit-and-nx

Conversation

@Apolloccrypt

@Apolloccrypt Apolloccrypt commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Two findings from reviewing #367. Both are about a counter or a guard that was
pointed the wrong way, and both are pinned from the code side and the page side.

1. The login rate limit was a lockout weapon

Old. admin/server.js:975-979:

const emailKey = `paramant:user:ratelimit:email:${email.toLowerCase()}`;
const emailCount = await redis().incr(emailKey);
if (ipCount > 5 || emailCount > 10) return res.status(429).json({ error: "rate_limited" });
const user = await findUserByEmail(email);   // authentication starts HERE

The counter was keyed on the e-mail address, incremented before any
authentication ran, and never deleted on a successful sign-in (the handler,
965-1030, has no del). The address is request input, so it measured "how often
did anyone type this address", not "how often did anyone fail on it". Eleven
posts over three IP addresses, the per-IP cap being five, put the owner of that
address on 429 for the full fifteen minutes, and a correct code could not clear
it.

relay.js carried the same shape one layer down and would have kept the lockout
alive after an admin-only fix: userMfaAttemptOk(user_id) incremented on the
way in and refused at ten inside five minutes, on a user_id that comes off the
request body. That is /v2/user/verify-totp and /v2/user/consume-backup.

New. The rule the fix follows: a counter keyed on an identity the attacker
gets to name may impose cost on that identity, never denial.

Layer Keyed on Counts Over the threshold
admin/lib/login-ratelimit.js IP address attempts refused, 429, 5 per 15 min, unchanged
admin/lib/login-ratelimit.js email, hashed failures only proof-of-work, 428, after 10
relay/lib/auth-throttle.js user_id failures only delay, 250 ms per failure, capped at 2 s

The per-IP refusal stays exactly as it was, because an IP address is the
caller's own resource: refusing it costs the guesser. The per-account counters
count only attempts that actually failed, a successful sign-in deletes them, and
past the threshold the next attempt is priced rather than refused. The price at
the admin edge is the 2^18 proof-of-work that signup and password reset already
use (admin/lib/pow-captcha.js, GET /api/captcha/challenge); auth-login.js
solves it and re-posts by itself, so the honest user sees a second or two of
"Verifying" and nothing else. The request that asks for the proof is refunded to
the per-IP counter, so five attempts stay five real attempts. The relay has no client to run a challenge, so it
delays instead.

The e-mail key is hashed (paramant:user:loginfail:<sha256>), matching the
login-with-backup limiter, and it is a new namespace, so no counter written
under the old shape survives a deploy still holding somebody out.

What was given up. There is no longer any per-account ceiling that refuses.
A distributed attacker can keep guessing one address, at one proof-of-work per
attempt plus five attempts per IP per fifteen minutes. Against a six-digit code
with a one-slot window under two algorithms that is botnet-scale cost for
roughly six-in-a-million per guess. If the trade needs revisiting, raise the
difficulty for a hot address rather than reintroducing the refusal. Written up
in SECURITY.md with the reasoning.

Sabotage. Each break applied to the source, the run confirmed red, then
restored. Ten of ten caught:

  • the e-mail counter put back in front of findUserByEmail with its 429:
    admin/test/login-ratelimit.test.js red, tests/site-claims.test.mjs red;
  • clearEmailFailures on success removed: login-ratelimit.test.js red;
  • the 503 pass-through removed: login-ratelimit.test.js red;
  • userMfaAttemptOk restored in relay.js: relay/test/auth-throttle.test.js
    red, and relay/test/route-user-mfa-lockout.test.js red against a really
    booted relay;
  • the 503 handling at a signing-key call site disabled: auth-throttle.test.js red;
  • the old two-counter 429 text back on the login page: tests/ui-truthfulness.test.mjs red;
  • "ten per email per fifteen minutes" back on /security: site-claims.test.mjs red.

2. TOTP single-use failed open, and then did not fail at all

Old. relay/lib/totp.js:103:

const ok = await store.set(slotKey, '1', { NX: true, EX: 90 }).catch(() => 'OK');

totp.js:93-94 documented the fail-open as deliberate: availability over replay
protection, so a Redis blip could not block a legitimate first use.

Assessment: not defensible here, fail-closed. The NX key is the only thing
between an observed six-digit code and its replay inside the same 30-second
slot, on the path that mints admin-panel sessions. And the availability it
bought was imaginary: the session store is the same Redis, so a login that skips
the replay check still cannot be issued a session. Nothing else in the codebase
argues the other way.

New. A store failure yields { valid: false, error: 'replay_store_unavailable' }.
/v2/user/verify-totp and both /v2/user/signing-key routes answer 503 and log
totp_replay_store_unavailable; the admin login passes it through as
totp_unavailable rather than reporting a wrong code, and does not count it as
a failed attempt. A refused replay is error: 'replay', a wrong code carries no
error at all, so the three cases stay apart.

And a second finding underneath it. Fail-closed only means something if a
decision arrives. Measured on a booted relay with the redis server killed
underneath it, the endpoint did not fail open or closed: it hung, no answer
after twelve seconds. node-redis queues commands while it reconnects, so the
secret read in front of the guard never resolved and the guard was never
reached. Two one-second bounds fix it on this path: storeTimeoutMs inside
verifyTotpGeneric, and redisDeadline around the secret read in relay.js.
Same outage, after:

redis up   -> 200 {"valid":true,"algorithm":"sha256"}
redis down -> 503 {"error":"replay_store_unavailable"}
{"level":"error","msg":"totp_replay_store_unavailable","endpoint":"login","stage":"secret_read"}

That queue-forever behaviour belongs to the relay's redis client, not to this
path, and every other redis-backed route still inherits it. The fix is
disableOfflineQueue or a global command deadline, which changes the failure
mode of every call site at once, so it is recorded in SECURITY.md as open rather
than smuggled in behind an auth fix.

Sabotage. Four of four caught:

  • the .catch(() => 'OK') restored: relay/test/verify-totp.test.js red;
  • the replay-SET deadline removed: verify-totp.test.js red (the hanging-store case);
  • the secret-read bound removed: auth-throttle.test.js red, and
    route-user-mfa-lockout.test.js red against a live relay with the redis
    connection cut mid-suite.

One honest limit on the live suite: with the store gone the request is answered
by the secret-read bound, so that suite proves the endpoint decides and decides
against the code, not that the NX guard itself fails closed. The guard is pinned
where it can be reached in isolation, in verify-totp.test.js, against a
throwing store, a synchronously throwing store and a store that never answers.
The test comment says so.

#367 landed while this was open, and this branch is rebased onto it

#367 merged as b913f9e mid-run. Rebased onto it, and the three files it and
this branch both touch are resolved rather than left to conflict.

#367 did the right thing with the lockout it found: it did not just describe it,
it pinned it. Row 15 makes /help/session-issues carry the caveat "attempts
someone else makes on your address count against you too" for exactly as long as
the handler keeps an address-keyed counter that runs before authentication and
survives a success, and it forbids any page from denying it while that holds.
That is what makes the page follow the code instead of the other way round.

All three properties are now false, so:

  • /help/session-issues drops the caveat (row 15's own else-branch says to)
    and states the per-IP refusal as the only one, with the ten failures as the
    point where an attempt starts costing work rather than as a second limit. This
    is the file the brief said not to touch, and the reason has expired: Ten more site claims pinned to the code, and the work list retaken #367 is in
    main, and its test requires the page to move with the handler.
  • Row 15 reads the three properties out instead of asserting the old shape
    still exists, so the page keeps following whichever way they fall. Put the
    address-keyed counter back and the caveat is demanded again.
  • Row 18 was weakened by Ten more site claims pinned to the code, and the work list retaken #367 to state the fail-open ("the check is skipped
    rather than failing the login"). Correct then, false now; it states the
    fail-closed behaviour and what the store outage answers instead.
  • Row 6 and /security take the new numbers. Ten more site claims pinned to the code, and the work list retaken #367's phrasing for the
    single-use half is kept, because it is better; only the clause after it flips.

Security review round

Approved after a live attack: lockout gone, no bypass through address
normalisation, no existence oracle through the 428, fail-closed in 1004 ms,
frontend solving the proof-of-work in 1.6 s. Three points came back and are
fixed in this branch, in the commit Three from the security review:

  1. The deadlines were hardcoded. PARAMANT_REDIS_DEADLINE_MS (relay.js) and
    PARAMANT_TOTP_REPLAY_TIMEOUT_MS (lib/totp.js) now carry them, default 1000 ms
    unchanged, documented in deploy/.env.example and SECURITY.md. Zero, a
    negative and a non-number all fall back to the default instead of switching
    the bound off, because an unbounded guard does not fail closed, it hangs.
    Pinned behaviourally: verify-totp.test.js reads the constant back out of a
    subprocess under six different environments.
  2. The 428 was billed to the per-IP counter, so under proof-of-work a budget
    of five left two real attempts: each sign-in cost a quote and an answer. The
    428 path refunds it, since nothing was evaluated there. refundIp will not
    create a counter that is absent, because DECR on a missing key seeds it at
    -1 with no TTL and would hand that IP a larger budget than a fresh one.
  3. "A second of CPU" was wrong in the flattering direction. Measured on this
    repository: 150 to 250 ms median for a native solver, one to two seconds only
    in a browser, where every hash is an awaited WebCrypto call. docs/api.md,
    SECURITY.md, the help page and the comment in pow-captcha.js now say what
    the proof-of-work does (prices each automated guess, stops a stranger from
    switching an account off) and name the per-IP limit as the actual brake.

Sabotage on all three, five of five caught: the refund removed
(login-ratelimit.test.js red), either deadline hardcoded again
(verify-totp.test.js / auth-throttle.test.js red), a zero allowed to disable
the bound (red), the env documentation dropped (env-documented.test.mjs red).

Follow-ups, deliberately not in this PR

  • consume-backup has no bound on sMembers. Already open in SECURITY.md.
    It sits behind the same throttle this PR gives the MFA path, which slows a
    caller down but does not bound the read itself. Its own change.
  • The admin login test is textual, not HTTP. login-ratelimit.test.js drives
    the module directly and pins the handler's shape by reading server.js; the
    relay side is exercised over real HTTP against a booted process
    (route-user-mfa-lockout.test.js) but the admin side has no equivalent, because
    nothing in admin/test/ boots server.js. Worth building the admin counterpart
    of relay/test/_relay-server.js and moving these assertions onto it; that is a
    harness, not a fix, so it should not ride along here.

Docs

  • SECURITY.md: both decisions, the table, what was given up, and the client-level
    queue-forever finding as open.
  • docs/api.md: the two counters as a table on the login endpoint, plus 428 and 503
    in the error-code table.
  • docs/site-claims.md row 6 and frontend/security.html follow the code.

Tests

  • admin/test/login-ratelimit.test.js: 8 new, module behaviour plus source pins on
    the handler order, including the IP refund on the priced path.
  • relay/test/auth-throttle.test.js: 7 new, the throttle plus the relay wiring.
  • relay/test/route-user-mfa-lockout.test.js: 4 new, a booted relay, a real redis,
    the attack, and the outage with the connection cut mid-suite.
  • relay/test/verify-totp.test.js: the fail-open test replaced by three fail-closed
    cases, a source pin, and a subprocess test for the configurable deadline.
  • tests/site-claims.test.mjs 6 and tests/ui-truthfulness.test.mjs 4 updated to the
    behaviour that exists.

Runs, all local, redis on a scratch port:

  • admin unit suite: 48 pass, 0 fail (was 40).
  • relay unit suite: 185 pass, 0 fail, nothing silent.
  • relay route suites with redis: 82 pass, 0 fail.
  • relay crypto and boot suites: 15 pass, 0 fail.
  • node --test $(grep -L "from 'playwright'" tests/*.mjs): 150 pass, 1 fail. The
    failure is tests/heartbeat-lib.test.mjs, which needs @noble/post-quantum; it
    fails identically without npm ci and is unrelated to this branch (Ten more site claims pinned to the code, and the work list retaken #367 reports
    the same).
  • bash tests/static-sanity.sh: PASS, all 11 checks clear.
  • node --test tests/env-documented.test.mjs: 6 pass, 0 fail (83 variables documented).
  • All of the above re-run after the rebase onto Ten more site claims pinned to the code, and the work list retaken #367 and again after the review round.
  • scripts/check-test-declarations.sh: 114 suites clean. New test blocks keep their
    declarations in function scope.
  • Playwright suites not run: no browser binary here. Nothing on this branch touches
    the geometry they measure; the only page edits are one sentence on /security and a
    script tag plus three error strings on /auth/login.

… closed

Two findings from the review of #367.

1. /api/user/login incremented paramant:user:ratelimit:email:<email> before it
called findUserByEmail, refused at eleven inside fifteen minutes, and never
deleted the key on a success. The address is request input, so eleven posts over
three IP addresses put the owner of that address on 429 for the full window with
no way to clear it. relay.js carried the same shape one layer down:
userMfaAttemptOk counted attempts against a caller-supplied user_id.

The per-IP refusal stays as it was, because an IP is the caller's own resource.
The per-account counters now count failures only, are cleared by a successful
sign-in, and impose cost rather than denial past the threshold: a proof-of-work
at the admin edge (the challenge signup and password reset already use), a
capped delay at the relay, which has no client to run one.

2. relay/lib/totp.js swallowed a replay-store error with .catch(() => 'OK'), so
a Redis failure accepted a code the single-use key could not mark as spent. On
the path that mints admin sessions that is a replay window inside the 30 second
slot, and the availability it bought was imaginary: the session store is the
same Redis. It fails closed now, with an error the call sites answer as 503
rather than 401, and the admin login passes that through instead of reporting a
wrong code.

Docs, and the pages that described the old behaviour, follow the code:
SECURITY.md carries both decisions and what was given up, docs/api.md gets the
two limits and the new status codes, and /security no longer advertises a
per-email limit that is not one.
Measured on a booted relay with redis killed underneath it, the previous commit
did not fail closed on the login path: it did not answer at all. node-redis
queues commands while it reconnects, so the secret read in front of the
single-use guard neither resolved nor rejected and the request hung, twelve
seconds and counting, without ever reaching the guard.

Two bounds, both one second. verifyTotpGeneric races the replay SET against a
deadline and reports a timeout exactly as it reports a thrown error, so the
guard itself can no longer hang. relay.js wraps the secret read in front of it,
so the route answers 503 instead of parking. Same test, same outage: 503
replay_store_unavailable in about a second, logged.

The queue-forever behaviour belongs to the relay's redis client, not to this
path, and every other redis-backed route still inherits it. Fixing that means
disableOfflineQueue or a global command deadline, which changes the failure mode
of every call site at once, so it is written up in SECURITY.md as open rather
than smuggled in here.

route-user-mfa-lockout.test.js runs the attack and the outage against a really
booted relay with a real redis behind a proxy the test cuts mid-run.
…bed is gone

#367 landed while this branch was open and pinned the lockout it found, on
purpose: /help/session-issues was made to carry the caveat that "attempts
someone else makes on your address count against you too" for exactly as long
as the handler kept an address-keyed counter that ran before authentication and
survived a success. That is no longer the handler, so the caveat is no longer
true and the row's own else-branch says to drop it.

Row 15 now reads the three properties instead of requiring the old shape to
exist, so the page follows whichever way they fall. Row 6 and row 18 move to the
numbers and the fail-closed behaviour this branch implements. The page states
the per-IP limit as the only refusal and the ten failures as the point where an
attempt starts costing work.
@Apolloccrypt
Apolloccrypt force-pushed the fix/login-ratelimit-and-nx branch from 8b7ea84 to 5066fa8 Compare September 2, 2026 22:42
…onest numbers

1. Both redis deadlines were hardcoded at 1000. PARAMANT_REDIS_DEADLINE_MS and
PARAMANT_TOTP_REPLAY_TIMEOUT_MS now carry them, default unchanged, documented in
deploy/.env.example (which tests/env-documented.test.mjs requires) and in
SECURITY.md. Zero, a negative and a non-number all fall back to the default
rather than switching the bound off: an unbounded guard does not fail closed, it
hangs, which is the failure the deadline exists to prevent.

2. Under proof-of-work the per-IP counter was charged twice per sign-in, once
for the 428 that asked for the proof and once for the attempt that carried it,
so a budget of five left two real tries. The 428 path refunds it. Nothing was
evaluated there: no account looked up, no code checked. refundIp will not create
a counter that is not there, because DECR on a missing key seeds it at -1 with
no TTL and would hand that IP a larger budget than it started with.

3. The docs priced the proof-of-work at "a second of CPU". Measured on this
repository it is 150 to 250 ms for a native solver and one to two seconds only
in a browser, where every hash goes through an awaited WebCrypto call. So the
pages say what it does: it prices each automated guess and it stops a stranger
from switching an account off, and the brake on guessing is the per-IP limit,
which is why that one stayed a refusal.
@Apolloccrypt
Apolloccrypt merged commit 8900a66 into main Sep 2, 2026
11 checks passed
Apolloccrypt added a commit that referenced this pull request Sep 3, 2026
…d an admin test that boots the admin

Three findings from the review of #368.

1. #368 bounded one redis read on the TOTP verify path and wrote the rest up in
SECURITY.md as open: "every other redis-backed route in relay.js still inherits
it and will still hang in an outage". It does not any more, and it turned out to
be three problems rather than one.

node-redis holds commands on an offline queue while it reconnects and never
retries out of it, so against a store that is gone a command neither resolves
nor rejects. disableOfflineQueue refuses it instead. That covers a socket that
died; it cannot see a connection that stays open and goes silent, where isReady
stays true and the reply simply never comes, and that is the outage a dropped
firewall rule or a wedged proxy actually produces. Only a per-command deadline
catches that one. And a deadline makes the caller safe, not the client: after a
command is lost that way node-redis goes on waiting for its reply and holds
every later command behind it, so the connection never recovers even once the
network does. Measured on 5.12.1 and 6.2.1, it reports itself ready and answers
nothing until the process restarts. The guard rebuilds the connection after two
unanswered commands in a row, not one, because a single slow command is a large
SCAN and not a wedge.

The bound sits on the client (lib/redis-deadline.js) rather than on the call
sites, because there are about 300 of them across relay.js, relay/lib,
envelope.js, admin/server.js and admin/lib, including the sMembers and sRem pair
behind /v2/user/consume-backup that #368 bounded on the route next to it and not
on this one. A per-call-site list is wrong the first time somebody adds a route.
An exceeded deadline, a closed socket and an offline client all arrive as one
RedisUnavailableError; redisOutage503 answers it on the 31 route-level catches
that used to report an outage as 400 bad_request or 500 internal, a new
top-level catch on the request handler answers it for everything else, and the
admin's error middleware does the same. That top-level catch is a fix in its own
right: http.createServer was handed a 4000 line async callback with nothing
behind it, so any throw was an unhandled rejection, which is no answer to the
client and a process exit on Node 22.

PARAMANT_REDIS_DEADLINE_MS keeps the name #368 gave it and is now the single
configuration source for both services. What is given up: during an outage every
request pays the deadline once before it is refused, and a deployment whose
redis routinely takes longer than a second must raise the knob rather than
remove it.

The health routes were part of the same blind spot. /v2/health/deep listed eight
checks and never mentioned the store that holds every TOTP secret, so it stayed
green through an outage. It now carries a redis check. The admin had no health
route at all: its container probe was GET /api/auth/check, which answers 401
when nobody is signed in, so healthy meant the process still refuses me. It now
has GET /health, always 200, saying degraded when redis cannot be reached. The
relay's own /health touches no redis and still answers 200, which is correct.

2. POST /api/user/login did more work for an address that exists than for one
that does not: a second relay call and one more redis read. The status codes
were already identical, deliberately, but the clock was not. Measured on a
booted admin over 200 requests per case, interleaved, one source address each,
with the relay stub given a realistic 3 ms cost for the verify call, the median
was 6.44 ms against 2.20 ms and the two ranges did not overlap at all, so one
request classified an address with no credentials at all. Every credential
answer is now held to t0 plus PARAMANT_LOGIN_MIN_ANSWER_MS, default 250 ms, and
the not-found branch makes the same number of redis calls as the found one. The
same measurement afterwards reads 251.37 against 251.27 ms with the ranges fully
overlapping. The 429 and the 428 are not padded: their status codes tell them
apart whatever the clock says, and holding the 428 back would only delay the
page that is waiting to start hashing.

3. admin/test/login-ratelimit.test.js drove the limiter module through an
attemptLogin() helper written inside the test file and then read server.js as a
string to check the order of three calls. Both halves are worth keeping and
neither ever ran the handler, so the suite could pass while the route was wrong.
admin/test/_admin-server.js is the counterpart of relay/test/_relay-server.js:
it spawns the real admin/server.js against a stub relay and speaks HTTP to it.
login-http.test.js runs the reviewer's scenario on that, ten wrong codes on one
address from three source addresses followed by the owner solving a real 2^18
proof-of-work, plus the per-IP refusal, the IP refund on a priced attempt and
the relay 503 being passed through. Checked against the code it is meant to
catch: three of its five tests fail against the pre-#368 admin, and so does
login-timing.test.js.

The admin CI job now gets a redis service and npm ci, because until now it
installed nothing, which is the reason every admin suite was a lib test or a
source-text assertion in the first place.
Apolloccrypt added a commit that referenced this pull request Sep 3, 2026
…d an admin test that boots the admin

Three findings from the review of #368.

1. #368 bounded one redis read on the TOTP verify path and wrote the rest up in
SECURITY.md as open: "every other redis-backed route in relay.js still inherits
it and will still hang in an outage". It does not any more, and it turned out to
be three problems rather than one.

node-redis holds commands on an offline queue while it reconnects and never
retries out of it, so against a store that is gone a command neither resolves
nor rejects. disableOfflineQueue refuses it instead. That covers a socket that
died; it cannot see a connection that stays open and goes silent, where isReady
stays true and the reply simply never comes, and that is the outage a dropped
firewall rule or a wedged proxy actually produces. Only a per-command deadline
catches that one. And a deadline makes the caller safe, not the client: after a
command is lost that way node-redis goes on waiting for its reply and holds
every later command behind it, so the connection never recovers even once the
network does. Measured on 5.12.1 and 6.2.1, it reports itself ready and answers
nothing until the process restarts. The guard rebuilds the connection after two
unanswered commands in a row, not one, because a single slow command is a large
SCAN and not a wedge.

The bound sits on the client (lib/redis-deadline.js) rather than on the call
sites, because there are about 300 of them across relay.js, relay/lib,
envelope.js, admin/server.js and admin/lib, including the sMembers and sRem pair
behind /v2/user/consume-backup that #368 bounded on the route next to it and not
on this one. A per-call-site list is wrong the first time somebody adds a route.
An exceeded deadline, a closed socket and an offline client all arrive as one
RedisUnavailableError; redisOutage503 answers it on the 31 route-level catches
that used to report an outage as 400 bad_request or 500 internal, a new
top-level catch on the request handler answers it for everything else, and the
admin's error middleware does the same. That top-level catch is a fix in its own
right: http.createServer was handed a 4000 line async callback with nothing
behind it, so any throw was an unhandled rejection, which is no answer to the
client and a process exit on Node 22.

PARAMANT_REDIS_DEADLINE_MS keeps the name #368 gave it and is now the single
configuration source for both services. What is given up: during an outage every
request pays the deadline once before it is refused, and a deployment whose
redis routinely takes longer than a second must raise the knob rather than
remove it.

The health routes were part of the same blind spot. /v2/health/deep listed eight
checks and never mentioned the store that holds every TOTP secret, so it stayed
green through an outage. It now carries a redis check. The admin had no health
route at all: its container probe was GET /api/auth/check, which answers 401
when nobody is signed in, so healthy meant the process still refuses me. It now
has GET /health, always 200, saying degraded when redis cannot be reached. The
relay's own /health touches no redis and still answers 200, which is correct.

2. POST /api/user/login did more work for an address that exists than for one
that does not: a second relay call and one more redis read. The status codes
were already identical, deliberately, but the clock was not. Measured on a
booted admin over 200 requests per case, interleaved, one source address each,
with the relay stub given a realistic 3 ms cost for the verify call, the median
was 6.44 ms against 2.20 ms and the two ranges did not overlap at all, so one
request classified an address with no credentials at all. Every credential
answer is now held to t0 plus PARAMANT_LOGIN_MIN_ANSWER_MS, default 250 ms, and
the not-found branch makes the same number of redis calls as the found one. The
same measurement afterwards reads 251.37 against 251.27 ms with the ranges fully
overlapping. The 429 and the 428 are not padded: their status codes tell them
apart whatever the clock says, and holding the 428 back would only delay the
page that is waiting to start hashing.

3. admin/test/login-ratelimit.test.js drove the limiter module through an
attemptLogin() helper written inside the test file and then read server.js as a
string to check the order of three calls. Both halves are worth keeping and
neither ever ran the handler, so the suite could pass while the route was wrong.
admin/test/_admin-server.js is the counterpart of relay/test/_relay-server.js:
it spawns the real admin/server.js against a stub relay and speaks HTTP to it.
login-http.test.js runs the reviewer's scenario on that, ten wrong codes on one
address from three source addresses followed by the owner solving a real 2^18
proof-of-work, plus the per-IP refusal, the IP refund on a priced attempt and
the relay 503 being passed through. Checked against the code it is meant to
catch: three of its five tests fail against the pre-#368 admin, and so does
login-timing.test.js.

The admin CI job now gets a redis service and npm ci, because until now it
installed nothing, which is the reason every admin suite was a lib test or a
source-text assertion in the first place.
@Apolloccrypt
Apolloccrypt deleted the fix/login-ratelimit-and-nx branch September 5, 2026 18:57
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