Add an auth abuse-prevention playbook #24

Merged
geusebio merged 1 commit from docs/auth-hardening-playbook into main 2026-08-15 13:03:03 +00:00
Owner

Adds docs/Topics/Auth-Hardening.md: how to make sign-in and sign-up hostile to
abuse in a Symfony application.

Extracted from doing exactly that on someones.computer, but written to be
applied rather than to describe that project — the same controls belong on
every project here, vpnondemand included. File paths in the "as built" callouts
point at the reference implementation; none of the reasoning depends on it.

What is in it

Six controls, ordered by value, because the order is itself the advice:
almost every "we should add a CAPTCHA" conversation is really a missing rate
limit, and a proof-of-work widget bolted onto an unlimited endpoint is
decoration.

# Control Stops
1 Rate limit every entry point Guessing, mail bombing, floods
2 Make responses indistinguishable Account enumeration
3 Trust only provider-verified emails OAuth account takeover
4 Log successes and failures Nothing — it is how you notice
5 Pin the session cookie flags Session theft, CSRF
6 Proof of work on anonymous forms Drive-by form spam

Each one names what it stops, what it costs, and what a wrong setting looks like
from the outside. There is a sizing table with starting numbers, an adoption
checklist, and a section on what is deliberately not included
(disposable-email blocklists, IP reputation, fingerprinting, MFA) so the
omissions read as decisions rather than gaps.

Why it is worth the length

The traps. Each cost real time to find and none is obvious from a library's
README:

  • consume(0)->isAccepted() is always true. It is the natural way to peek at
    a rate limiter without spending from it. Acceptance asks whether there are at
    least N tokens left, and there are always at least zero — so written that way a
    limiter sits in the code, passes a careless test, and never once refuses a
    request. This was a live bug in the implementation, caught by a test that
    asserted the refusal rather than the wiring.
  • An enumeration oracle survives being deleted from the response body. It
    still shows in the status code, the timing, and whether a session appeared —
    and "registration signs the new account in" is the one that gets missed,
    because it can only happen when the address was free.
  • Linking an OAuth identity on an email match is an account takeover wherever
    the provider's email is self-asserted, which on GitHub it is. The library reads
    the verified flag that would say so and discards it.
  • ALTCHA solutions are replayable until they expire; single use is the
    caller's problem, not the library's.
  • Passport::getUser() in a login-failure listener throws on the most common
    failure there is — no such account — so the logging added to observe attacks
    becomes an incident itself.

Verification

markdownlint clean. Every claim about Symfony behaviour, the ALTCHA library and
the OAuth client libraries was verified against the vendored source while
implementing the reference version, not from memory.

No code in this repo, so nothing to run.

The consuming change lands separately on someones.computer (implementation plus
the submodule pointer bump), per the two-PR rule in VCS.md for a standards
change.

Adds `docs/Topics/Auth-Hardening.md`: how to make sign-in and sign-up hostile to abuse in a Symfony application. Extracted from doing exactly that on someones.computer, but written to be **applied** rather than to describe that project — the same controls belong on every project here, vpnondemand included. File paths in the "as built" callouts point at the reference implementation; none of the reasoning depends on it. ## What is in it Six controls, **ordered by value**, because the order is itself the advice: almost every "we should add a CAPTCHA" conversation is really a missing rate limit, and a proof-of-work widget bolted onto an unlimited endpoint is decoration. | # | Control | Stops | |---|---|---| | 1 | Rate limit every entry point | Guessing, mail bombing, floods | | 2 | Make responses indistinguishable | Account enumeration | | 3 | Trust only provider-verified emails | OAuth account takeover | | 4 | Log successes *and* failures | Nothing — it is how you notice | | 5 | Pin the session cookie flags | Session theft, CSRF | | 6 | Proof of work on anonymous forms | Drive-by form spam | Each one names what it stops, what it costs, and what a wrong setting looks like from the outside. There is a sizing table with starting numbers, an adoption checklist, and a section on what is deliberately **not** included (disposable-email blocklists, IP reputation, fingerprinting, MFA) so the omissions read as decisions rather than gaps. ## Why it is worth the length The traps. Each cost real time to find and none is obvious from a library's README: - **`consume(0)->isAccepted()` is always true.** It is the natural way to peek at a rate limiter without spending from it. Acceptance asks whether there are at least N tokens left, and there are always at least zero — so written that way a limiter sits in the code, passes a careless test, and never once refuses a request. This was a live bug in the implementation, caught by a test that asserted the refusal rather than the wiring. - **An enumeration oracle survives being deleted from the response body.** It still shows in the status code, the timing, and whether a session appeared — and "registration signs the new account in" is the one that gets missed, because it can only happen when the address was free. - **Linking an OAuth identity on an email match is an account takeover** wherever the provider's email is self-asserted, which on GitHub it is. The library reads the `verified` flag that would say so and discards it. - **ALTCHA solutions are replayable** until they expire; single use is the caller's problem, not the library's. - **`Passport::getUser()` in a login-failure listener throws** on the most common failure there is — no such account — so the logging added to observe attacks becomes an incident itself. ## Verification `markdownlint` clean. Every claim about Symfony behaviour, the ALTCHA library and the OAuth client libraries was verified against the vendored source while implementing the reference version, not from memory. No code in this repo, so nothing to run. ## Related The consuming change lands separately on someones.computer (implementation plus the submodule pointer bump), per the two-PR rule in `VCS.md` for a standards change.
Extracted from hardening sign-in and sign-up on someones.computer, and written
to be applied elsewhere rather than to describe what that project happens to do
— the same controls belong on every project here.

Six controls, ordered by value, because that order is itself the advice: almost
every "we should add a CAPTCHA" conversation is really a missing rate limit, and
proof of work bolted onto an unlimited endpoint is decoration.

The parts worth having written down are the traps, each of which cost real time
to find and none of which is obvious from a library's README:

- `consume(0)->isAccepted()` is the natural way to peek at a rate limiter
  without spending from it, and is always true — acceptance asks whether there
  are at least N tokens left, and there are always at least zero. Written that
  way a limiter is present in the code, passes a careless test, and never once
  refuses a request.
- An enumeration oracle survives being deleted from the response body if it
  still shows in the status code, the timing, or whether a session appeared.
  Registration signing the new account in is the one people miss.
- Linking an OAuth identity to a local account on an email match is an account
  takeover wherever the provider's email is self-asserted, which on GitHub it
  is; the common library reads the flag that would say so and discards it.
- ALTCHA's server library checks a solution's signature, answer and expiry, and
  will accept the same solved payload until it expires. Single use is the
  caller's problem.
- Reaching for `Passport::getUser()` in a login-failure listener throws on the
  most common failure there is — no such account — so logging added to observe
  attacks becomes one.

Also states what is deliberately not here (disposable-email blocklists, IP
reputation, fingerprinting, MFA) and why, so the omissions read as decisions
rather than gaps.
geusebio scheduled this pull request to auto merge when all checks succeed 2026-08-15 12:58:24 +00:00
geusebio deleted branch docs/auth-hardening-playbook 2026-08-15 13:03:03 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Grey.ooo/agent-standards!24
No description provided.