fix: docs proxy gating, register session, password-reset enumeration - #2217
Merged
Conversation
The proxy was unconditional and sat ahead of page routing, so in dev:
- an app with its own `resources/views/docs.stx` could never serve it
- an app with no docs site got a proxy to a dead port instead of a 404
- `buddy serve` has no `/docs` branch and just resolves the page route, so
dev and production disagreed about what `/docs` even is
That last one is the expensive part: the page that ships is the one nobody can
review locally, and the page everyone reviews locally never ships.
Now a userland page wins outright, the proxy runs only when a `docs/` site
exists, and when neither is true `/docs` falls through to normal routing — which
turns the dead-port case into an ordinary 404.
Resolved once at startup rather than per request: both checks are filesystem
stats and the answer cannot change without a restart. A plain `existsSync`
rather than `firstExistingPath`, whose prefer-the-candidate-with-templates
tie-break is meaningless for a markdown docs site and for a single `.stx` file.
Closes #2213
`register()` called `Auth.createToken()`, a wrapper that mints a refresh token
and an expiry and then throws both away, so `RegisterAction` had nothing but an
access token to return.
With `config.auth.tokenExpiry` defaulting to one hour, a client that correctly
stores the pair and exchanges it at `/auth/refresh` worked for everyone except
the user who had just signed up — they were logged out an hour into their first
session. The userland workaround was to POST /login immediately after
registering, purely to obtain the pair: a wasted round trip against a
rate-limited endpoint that only works while the client still holds the
plaintext password.
Fixed at the root rather than in the action: `register()` now uses
`createTokenForUser` and returns the same triple `Auth.loginUsingId()` does.
Additive, so `const { token } = await register(...)` is unaffected.
`RegisterAction` now returns the OAuth2-shaped payload `LoginAction` already
documents as the contract, keeping the legacy `token` alias on both.
The result type is named (`RegistrationResult`) rather than inlined: consumers
can reference it, and pickier's unused-parameter analysis mis-reads a
multi-line return annotation and flags `credentials` as unused.
Closes #2212
`SendPasswordResetEmailAction` answered `404 "No account found with this email
address."` for an unknown address and `200` for a known one, on an
unauthenticated endpoint. Rate limiting is keyed per-email
(`password_reset:{email}`), so it does not slow enumeration ACROSS addresses at
all — which is the shape of the attack. Anyone could test an arbitrary list for
membership.
The sibling `PasswordResetAction` already refuses to leak the same fact, with a
comment saying so, making this an inconsistency between two files in one
directory rather than a disagreement about the threat.
Every path now returns one identical response. Three things had to change, not
one:
- the 404 becomes the neutral 200
- a failed job dispatch ALSO returns the neutral 200. Returning 500 there
would rebuild the oracle whenever the mailer is degraded, since an unknown
address never reaches dispatch and so can never 5xx. The operator gets the
failure in the logs instead.
- the console.log trail is gone. It echoed the submitted address at four
points, putting user emails in framework-default logs.
The message lives in one constant because the security property is that every
branch returns it byte for byte; the moment one differs, the oracle is back.
Not addressed: a known address does strictly more work (a queue dispatch), so a
timing signal remains. That needs its own change and is a much weaker oracle
than a 404 with an explanatory message.
Closes #2214
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2213, #2212, #2214. Three independent fixes, one branch — each is a few lines and they touch disjoint files.
#2213 —
/docsproxy was unconditionaldev/views.tsproxied/docsahead of page routing with no existence check, so an app with its ownresources/views/docs.stxcould never serve it, and an app with no docs site got a proxy to a dead port instead of a 404.The costly part is that
buddy servehas no/docsbranch at all — it just resolves the page route. Dev and production disagreed about what/docsis, so the page that ships was the one nobody could review locally.Now: a userland page wins outright; the proxy runs only when a
docs/site exists; otherwise/docsfalls through to normal routing, which turns the dead-port case into an ordinary 404. Resolved once at startup — both checks are filesystem stats and the answer can't change without a restart.Verified all five combinations (docs site only,
docs.stxonly,docs/index.stxonly, both, neither) — 5/5.#2212 — registration minted a session and discarded half of it
register()calledAuth.createToken(), a wrapper that mints a refresh token and expiry and throws both away. So the action had nothing but an access token to return.The issue reasoned that both endpoints call
loginUsingId()— they don't;register()never did. The root is one layer lower, which is where this fixes it:register()now usescreateTokenForUserand returns the same tripleloginUsingId()does. Additive, soconst { token } = await register(...)is unaffected.With
tokenExpirydefaulting to one hour, a client that correctly stores the pair worked for everyone except the user who had just signed up.#2214 — password reset was an account-existence oracle
404 "No account found with this email address."vs200, unauthenticated, with rate limiting keyed per-email — so it doesn't slow enumeration across addresses at all.Three things had to change, not one:
console.logtrail is gone — it echoed the submitted address at four points, putting user emails into framework-default logsThe message lives in one constant because the property is that every branch returns it byte for byte.
Not addressed: a known address does strictly more work (a queue dispatch), so a timing signal remains. That needs its own change, and it's a far weaker oracle than a 404 with an explanatory message.
Verification
Authwith onlycreateToken, and asserted{ token }alone; both now reflect the full session.One incidental finding: pickier's
no-unused-varsmis-reads a multi-line return-type annotation and flagged the still-usedcredentialsparameter. Worked around by naming the type (RegistrationResult), which is better anyway, but it's a real false positive worth a separate report.🤖 Generated with Claude Code