fix(security): authorization & input hardening (#5 review)#14
Conversation
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.
Follow-up review fixesAddressed 2 issues raised in second-pass review of this PR. Finding 1 (P1) — email enumeration was only client-sideOriginal commit 7b2c60b normalized the form message but the Better Auth endpoint Fix in c37c119: added an Finding 2 (P2) — MAX_DETECT_REPOS=300 broke detection for large accountsOriginal commit 0973c93 capped at 300 which was too low; the add-repo dialog also silently swallowed the Fix in 451bae7:
Verification
|
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.
Third-pass fix: HTTP status normalizationReviewer's third-pass finding was correct and verified against Better Auth's source. What I confirmed in
|
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.
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(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.
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
listRepoBranchesfell through to GitHub whenrepoConfigwas null — caller could proxy GitHub API for repos not registered in their dashboardNOT_FOUNDwhen the repo is not registered for the authenticated userdetectBmadReposaccepted an unbounded array, allowingceil(N/30)sequential GraphQL requestsMAX_DETECT_REPOS=300withLIMIT_EXCEEDEDearly returnCommits
fix(actions): guard listRepoBranches against non-owned reposfix(actions): cap detectBmadRepos input array (max 300)fix(login): use generic error for non-403 signup failuresVerification
pnpm tsc --noEmit— cleanpnpm lint— 0 errors (2 pre-existing warnings unrelated)pnpm test— 151/151 passingTest plan
listRepoBrancheswith a repo not registered for the caller →NOT_FOUNDdetectBmadReposwith 500 repos →LIMIT_EXCEEDEDContext
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.