Skip to content

fix: docs proxy gating, register session, password-reset enumeration - #2217

Merged
glennmichael123 merged 3 commits into
mainfrom
fix/2213-docs-proxy-gate
Aug 5, 2026
Merged

fix: docs proxy gating, register session, password-reset enumeration#2217
glennmichael123 merged 3 commits into
mainfrom
fix/2213-docs-proxy-gate

Conversation

@glennmichael123

Copy link
Copy Markdown
Member

Closes #2213, #2212, #2214. Three independent fixes, one branch — each is a few lines and they touch disjoint files.


#2213/docs proxy was unconditional

dev/views.ts proxied /docs ahead of page routing with no existence check, so an app with its own resources/views/docs.stx could 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 serve has no /docs branch at all — it just resolves the page route. Dev and production disagreed about what /docs is, 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 /docs falls 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.stx only, docs/index.stx only, both, neither) — 5/5.


#2212 — registration minted a session and discarded half of it

register() called Auth.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 uses createTokenForUser and returns the same triple loginUsingId() does. Additive, so const { token } = await register(...) is unaffected.

With tokenExpiry defaulting 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." vs 200, unauthenticated, with rate limiting keyed per-email — so it doesn't slow enumeration across addresses at all.

Three things had to change, not one:

  1. the 404 becomes the neutral 200
  2. a failed job dispatch also returns the neutral 200 — this is the trap the issue flagged. Returning 500 rebuilds the oracle whenever the mailer is degraded, since an unknown address never reaches dispatch and so can never 5xx
  3. the console.log trail is gone — it echoed the submitted address at four points, putting user emails into framework-default logs

The 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

  • auth: 258 pass, 0 fail. One test legitimately needed updating — it mocked Auth with only createToken, and asserted { token } alone; both now reflect the full session.
  • pickier clean, framework + app typecheck clean.

One incidental finding: pickier's no-unused-vars mis-reads a multi-line return-type annotation and flagged the still-used credentials parameter. 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

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
@github-actions github-actions Bot added actions @stacksjs/actions auth @stacksjs/auth storage @stacksjs/storage core labels Aug 5, 2026
@glennmichael123
glennmichael123 merged commit 7b2b46a into main Aug 5, 2026
5 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

actions @stacksjs/actions auth @stacksjs/auth core storage @stacksjs/storage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dev/views.js proxies /docs unconditionally, so a page route named docs can never win

1 participant