fix(auth): route document integrity, transaction delete/list and agent categorize through withRouteContext (#1926)
Two handlers hand-rolled supabase.auth.getUser() and therefore skipped the MFA (AAL2) gate on hosted: DELETE /api/transactions/[id] and GET /api/transactions. Both sat next to a sibling handler that was already wrapped, and the raw-route-auth ratchet exempted a file as soon as any withRouteContext call appeared in it, so they were never flagged. GET /api/documents/[id]/integrity and POST /api/agent/categorize called requireAuth() directly (MFA enforced, but no request id, no completion log, no canonical error envelope). All four are now withRouteContext handlers with identical company scoping and responses; the transaction delete keeps its viewer rejection via requireWrite. The guard now judges each top-level export segment of a route file on its own, so a wrapped handler no longer exempts a hand-rolled sibling. Baseline is unchanged (mcp-oauth/authorize remains the one grandfathered file). Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
d3869e6694
commit
1a27b5bd4a
@@ -11,8 +11,11 @@
|
||||
* 1. raw-route-auth : an `app/api/**\/route.ts` that calls
|
||||
* `supabase.auth.getUser()` directly instead of going through
|
||||
* `requireAuth()` / `withRouteContext()` (the only guards that enforce
|
||||
* MFA AAL2 on hosted). Tracked as a file-set so a NEW offending route
|
||||
* fails CI even if an old one was fixed in the same PR.
|
||||
* MFA AAL2 on hosted). Judged per exported handler, not per file: a
|
||||
* wrapped PATCH next to a hand-rolled DELETE in the same file is still
|
||||
* a violation (that exact shape hid two MFA bypasses until 2026-08-26).
|
||||
* Tracked as a file-set so a NEW offending route fails CI even if an
|
||||
* old one was fixed in the same PR.
|
||||
* 2. naive-ore-round: `Math.round(x * 100) / 100`, which is subtly wrong on
|
||||
* exact-half values (see lib/money.ts `roundOre`). Tracked as a count.
|
||||
* The canonical rounding modules are excluded.
|
||||
@@ -136,6 +139,10 @@ const RAW_AUTH_RE = /\.auth\.getUser\(/
|
||||
// flagged. withRouteContext is usually called with a generic (`withRouteContext<…>(`),
|
||||
// so accept either `<` or `(` after the name.
|
||||
const GUARD_RE = /requireAuth\(|withRouteContext[<(]/
|
||||
// Each top-level `export` starts a new segment, so every handler (and the
|
||||
// preamble of shared helpers above the first export) is judged on its own.
|
||||
// Without this split, one wrapped handler exempted the whole file.
|
||||
const TOP_LEVEL_EXPORT_RE = /^(?=export\s)/m
|
||||
const NAIVE_ROUND_RE = /Math\.round\([^\n]*\*\s*100\s*\)\s*\/\s*100/
|
||||
|
||||
// 8. hand-rolled-invariant. Shared format contracts live in lib/invariants/
|
||||
@@ -178,14 +185,18 @@ function walk(dir, exts, out = []) {
|
||||
|
||||
const rel = (p) => path.relative(ROOT, p).split(path.sep).join('/')
|
||||
|
||||
/** True when any handler segment calls getUser() without an MFA-enforcing guard. */
|
||||
function handRollsRouteAuth(src) {
|
||||
return src
|
||||
.split(TOP_LEVEL_EXPORT_RE)
|
||||
.some((segment) => RAW_AUTH_RE.test(segment) && !GUARD_RE.test(segment))
|
||||
}
|
||||
|
||||
/** Route files that hand-roll auth instead of the MFA-enforcing guard. */
|
||||
function findRawRouteAuth() {
|
||||
const apiDir = path.join(ROOT, 'app', 'api')
|
||||
return walk(apiDir, ['route.ts'])
|
||||
.filter((f) => {
|
||||
const src = fs.readFileSync(f, 'utf8')
|
||||
return RAW_AUTH_RE.test(src) && !GUARD_RE.test(src)
|
||||
})
|
||||
.filter((f) => handRollsRouteAuth(fs.readFileSync(f, 'utf8')))
|
||||
.map(rel)
|
||||
.sort()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user