Skip to content

fix(security): authorization & input hardening (#5 review)#14

Merged
DevHDI merged 6 commits into
mainfrom
fix/security-hardening-review-5
May 13, 2026
Merged

fix(security): authorization & input hardening (#5 review)#14
DevHDI merged 6 commits into
mainfrom
fix/security-hardening-review-5

Conversation

@DevHDI

@DevHDI DevHDI commented May 13, 2026

Copy link
Copy Markdown
Owner

Summary

Addresses 3 security findings surfaced by the Raven's Verdict review on bmad-method-ui#5.

Each commit is atomic and references its finding.

Findings addressed

# Severity Issue Fix
#7 🔴 Critical listRepoBranches fell through to GitHub when repoConfig was null — caller could proxy GitHub API for repos not registered in their dashboard Return NOT_FOUND when the repo is not registered for the authenticated user
#14 🟡 Moderate detectBmadRepos accepted an unbounded array, allowing ceil(N/30) sequential GraphQL requests Cap at MAX_DETECT_REPOS=300 with LIMIT_EXCEEDED early return
#2 🔴 Critical Sign-up form distinguished HTTP 422 vs other errors → email enumeration Collapse all non-403 sign-up failures into a single ambiguous message

Commits

  • 9e3b61d fix(actions): guard listRepoBranches against non-owned repos
  • 0973c93 fix(actions): cap detectBmadRepos input array (max 300)
  • 7b2c60b fix(login): use generic error for non-403 signup failures

Verification

  • pnpm tsc --noEmit — clean
  • pnpm lint — 0 errors (2 pre-existing warnings unrelated)
  • pnpm test — 151/151 passing

Test plan

  • Unit test listRepoBranches with a repo not registered for the caller → NOT_FOUND
  • Manual: POST detectBmadRepos with 500 repos → LIMIT_EXCEEDED
  • Manual: sign up with an already-registered email → generic message, no 422-specific text

Context

This is PR 1 of 4 addressing the bmad-method-ui#5 review. Follow-ups:

CSP (#4) tracked in #12. Role enum migration (#12 from review) tracked in #13.

DevHDI added 5 commits May 13, 2026 21:53
Previously, when the requested repo did not exist in the database for
the authenticated user, the local-repo check `repoConfig?.sourceType`
silently evaluated to false and the function fell through to the
GitHub API call with user-supplied owner/name. Although the call uses
the user's own OAuth token (limiting blast radius to repos they
already have GitHub access to), the missing scope guard let the
dashboard act as an unscoped proxy for arbitrary repository
enumeration.

Return NOT_FOUND when the repo is not registered for the caller.

Addresses finding #7 from bmad-method-ui#5 review.
Without a bound, an authenticated caller could pass an arbitrarily
large list of repos and force ceil(N/30) sequential GraphQL requests
against the GitHub API, exhausting the user's rate-limit quota and
blocking the event loop for several seconds.

Reject inputs above MAX_DETECT_REPOS=300 with a LIMIT_EXCEEDED code.
300 corresponds to 10 batches, which comfortably covers realistic
GitHub accounts.

Addresses finding #14 from bmad-method-ui#5 review.
Previously the form distinguished HTTP 422 ("Un compte existe déjà
avec cet email.") from other sign-up failures, allowing an
unauthenticated attacker to enumerate registered email addresses by
iterating a list and observing the response.

Collapse all non-403 failures into a single ambiguous message. The
403 branch is preserved because it reflects a server-wide flag
(ALLOW_REGISTRATION=false), not a per-email signal.

Addresses finding #2 from bmad-method-ui#5 review.
Commit 7b2c60b only normalized the form's user-visible message. The
underlying Better Auth endpoint at /api/auth/sign-up/email was still
directly reachable and returned HTTP 422 with a distinct body when the
email was already registered, letting an attacker enumerate accounts
by calling the API directly.

Add an `after` hook that intercepts every non-FORBIDDEN APIError on
/sign-up/email and rewrites it to a generic BAD_REQUEST. The
FORBIDDEN status is preserved because it reflects the
ALLOW_REGISTRATION flag (a server-wide signal), not a per-email
signal.

Closes the server-side gap missed by 7b2c60b. Addresses finding #2
from bmad-method-ui#5 review properly.
Commit 0973c93 capped detectBmadRepos at 300, which was too low for
realistic accounts (orgs commonly exceed it). Worse, the add-repo
dialog's `if (bmadResult.success)` branch silently skipped the update
when the server returned LIMIT_EXCEEDED, leaving every repo with
hasBmad=false and no UI signal.

- Raise MAX_DETECT_REPOS from 300 to 2000 (covers virtually every
  GitHub account while still bounding sequential GraphQL load).
- Track a separate `detectWarning` state in the dialog and surface
  it as an amber non-blocking message so the user knows BMAD badges
  may be missing. The repo list itself stays usable.

Addresses the follow-up review on commit 0973c93.
@DevHDI

DevHDI commented May 13, 2026

Copy link
Copy Markdown
Owner Author

Follow-up review fixes

Addressed 2 issues raised in second-pass review of this PR.

Finding 1 (P1) — email enumeration was only client-side

Original commit 7b2c60b normalized the form message but the Better Auth endpoint /api/auth/sign-up/email was still directly reachable and returned distinguishable HTTP 422 responses. Valid critique — the misleading commit message claimed enumeration was prevented when it wasn't.

Fix in c37c119: added an after hook in src/lib/auth/auth.ts that intercepts every non-FORBIDDEN APIError on /sign-up/email and rewrites it to a generic BAD_REQUEST. The FORBIDDEN branch (registration disabled) is preserved because it reflects a server-wide flag, not a per-email signal. The client-side change in 7b2c60b is kept as defense-in-depth.

Finding 2 (P2) — MAX_DETECT_REPOS=300 broke detection for large accounts

Original commit 0973c93 capped at 300 which was too low; the add-repo dialog also silently swallowed the LIMIT_EXCEEDED error, leaving all repos with hasBmad=false and no UI signal. Valid regression.

Fix in 451bae7:

  • Raised MAX_DETECT_REPOS from 300 to 2000 (covers virtually every realistic account, including orgs)
  • Added a non-blocking amber warning in the dialog when detection fails (LIMIT_EXCEEDED or transient errors). The repo list stays usable, the user just sees "BMAD badges may be missing".

Verification

  • pnpm tsc --noEmit — clean
  • pnpm lint — 0 errors (2 pre-existing warnings)
  • pnpm test — 151/151

Commit c37c119 added an `after` hook that rewrote the response body on
sign-up failures, but Better Auth's hook runner only propagates
`response` and `headers` from a re-thrown APIError — not `status`.
The HTTP status code stayed at 422 even when the body was normalized,
so an attacker could still enumerate accounts by observing the status
alone.

Move the normalization one layer up: a more specific Next.js route at
/api/auth/sign-up/email wraps `auth.handler` and rewrites any non-2xx
response (except 403, our registration-disabled gate) to a uniform
HTTP 400 with a generic body. The `after` hook is removed since the
route wrapper now controls both body and status from one place.

Reference: better-auth/dist/api/to-auth-endpoints.mjs (runAfterHooks)
shows the status is captured from the original endpoint throw and is
not updated by an after-hook re-throw.

Addresses the follow-up review on commit c37c119.
@DevHDI

DevHDI commented May 13, 2026

Copy link
Copy Markdown
Owner Author

Third-pass fix: HTTP status normalization

Reviewer's third-pass finding was correct and verified against Better Auth's source.

What I confirmed in better-auth@1.4.18

Inspecting node_modules/better-auth/dist/api/to-auth-endpoints.mjs:

  1. Endpoint throws APIError("UNPROCESSABLE_ENTITY") (the 422 for email-exists). The .catch captures {response, status: 422, headers}.
  2. runAfterHooks runs my after hook, which re-throws APIError("BAD_REQUEST"). The hook runner's .catch only captures {response, headers}not status.
  3. The outer code applies result.response = after.response but result.status stays 422.
  4. toResponse(result.response, { headers, status: result.status }) → HTTP 422 leaks.

So the body was generic but the status code still leaked email existence. Reviewer was right.

Fix in 2e7272d

Moved normalization one layer up to a Next.js route wrapper at src/app/api/auth/sign-up/email/route.ts. Being more specific than /api/auth/[...all], it takes precedence in App Router routing. The wrapper:

  • Calls auth.handler(request) (unchanged Better Auth flow)
  • Lets 2xx responses pass through unchanged (cookies/headers preserved)
  • Preserves 403 (registration-disabled flag, not a per-email signal)
  • Rewrites anything else to a uniform HTTP 400 + { message: "Sign-up failed" }

The after hook in auth.ts was removed since the route wrapper is now the single point of normalization for both status and body.

Verification

  • pnpm tsc --noEmit — clean
  • pnpm lint — 0 errors (2 pre-existing warnings)
  • pnpm test — 151/151

Manual test plan for whoever runs the env

  • curl -X POST /api/auth/sign-up/email -d '{"email":"existing@test.com","password":"...","name":"..."}' → expect HTTP 400 with {message: "Sign-up failed"}, identical to curl -X POST with a new email + invalid password.
  • curl -X POST /api/auth/sign-up/email -d '{...}' with ALLOW_REGISTRATION=false → expect HTTP 403 with the registration-disabled message.

DevHDI added a commit that referenced this pull request May 13, 2026
Commit 8090e03 had the server return `null` for repos in a failed
batch, but add-repo-dialog still applied `?? false` at the boundary
and rendered the repo identically to a confirmed "no BMAD". The data
contract was honest but the user saw the same lie as before.

Count the null values returned by detectBmadRepos and surface that
count via a new `unknownDetectCount` state. When > 0 the dialog
shows an amber warning ("BMAD detection failed for N
repositor(y/ies)…") in the same area as the existing detection
status. The repo list itself stays usable; only the misleading badge
gets the disclaimer.

State name is intentionally distinct from existing/planned warning
state in PR #14 so the two surfaces can coexist when
both PRs merge.

Addresses the follow-up review on 8090e03.
@DevHDI DevHDI merged commit e8a8def into main May 13, 2026
2 checks passed
@DevHDI DevHDI deleted the fix/security-hardening-review-5 branch May 13, 2026 22:00
DevHDI added a commit that referenced this pull request May 13, 2026
Commit 8090e03 had the server return `null` for repos in a failed
batch, but add-repo-dialog still applied `?? false` at the boundary
and rendered the repo identically to a confirmed "no BMAD". The data
contract was honest but the user saw the same lie as before.

Count the null values returned by detectBmadRepos and surface that
count via a new `unknownDetectCount` state. When > 0 the dialog
shows an amber warning ("BMAD detection failed for N
repositor(y/ies)…") in the same area as the existing detection
status. The repo list itself stays usable; only the misleading badge
gets the disclaimer.

State name is intentionally distinct from existing/planned warning
state in PR #14 so the two surfaces can coexist when
both PRs merge.

Addresses the follow-up review on 8090e03.
DevHDI added a commit that referenced this pull request May 13, 2026
* fix(admin): show parsingErrorRate as N/A until tracking exists

The admin dashboard displayed `0%` for "Parsing Errors" with the
description "Target KPI: < 1%", suggesting the metric was working when
in fact no parsing-error log table exists yet — the value was a
hardcoded zero. Admins reading "0%" would conclude the system has no
parsing errors when the reality is the data is not collected.

Change `parsingErrorRate` from `number` to `number | null` in the
UsageMetrics interface, return `null` from getUsageMetrics(), and have
the stats card render "N/A" with the description "Not yet tracked"
when the value is null. The existing display path (number + "%") is
preserved for when a real metric is wired up.

Addresses finding #17 from bmad-method-ui#5 review.

* fix(actions): represent detectBmadRepos batch failures as null

When a GraphQL batch fails (transient network error, GitHub hiccup),
the catch handler set every repo in that chunk to `false`, which is
the same value used for "confirmed no BMAD". A caller asking the UI
"does this repo have BMAD?" got "no" — when the truthful answer was
"we don't know, the API call failed".

Change the result map type from `Record<string, boolean>` to
`Record<string, boolean | null>`:
  - true  → BMAD detected
  - false → confirmed absent
  - null  → detection failed for this repo (unknown)

The single in-repo consumer (add-repo-dialog) already coerces
`bmadResult.data[r.fullName] ?? false` at the boundary so existing
behavior is preserved; surfacing the unknown count in the UI is a
follow-up that can build on this contract.

Addresses finding #16 from bmad-method-ui#5 review.

* chore(actions): log extendBmadDirs failures instead of silently catching

fetchFileContent wraps provider.extendBmadDirs in `try { ... } catch {}`
with a comment relying on the downstream getFileContent →
assertSafePath check to enforce the whitelist invariant. That
reasoning is still correct (assertSafePath refuses any path whose
top-level segment is not in the whitelist, so an extension that
failed to apply cannot be exploited), but discarding the error gives
zero visibility when the validation path is exercised in production.

Replace the silent catch with a console.warn that captures the
owner / name / segment / error message. No behavior change — the
authoritative refusal still happens in assertSafePath if the segment
was not added.

Addresses finding #15 from bmad-method-ui#5 review.

* fix(dialog): surface unknown-detection count to the user

Commit 8090e03 had the server return `null` for repos in a failed
batch, but add-repo-dialog still applied `?? false` at the boundary
and rendered the repo identically to a confirmed "no BMAD". The data
contract was honest but the user saw the same lie as before.

Count the null values returned by detectBmadRepos and surface that
count via a new `unknownDetectCount` state. When > 0 the dialog
shows an amber warning ("BMAD detection failed for N
repositor(y/ies)…") in the same area as the existing detection
status. The repo list itself stays usable; only the misleading badge
gets the disclaimer.

State name is intentionally distinct from existing/planned warning
state in PR #14 so the two surfaces can coexist when
both PRs merge.

Addresses the follow-up review on 8090e03.

* fix(dialog): use complete words for repository pluralization

The previous JSX split the word as `repositor` + `{count === 1 ? "y"
: "ies"}`. React preserves whitespace between text and expressions,
so the rendered output had a space in the middle of the word
("repositor y" / "repositor ies").

Move the whole word into the conditional ("repository" /
"repositories") and use an explicit `{" "}` to control the space
between the count and the word. The dash and trailing text are also
on their own lines, which JSX collapses to single spaces correctly.

Addresses the follow-up review on 56567db.
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