security(api): 36 read routes returned customer data to any signed-in user - #101
Open
goetchstone wants to merge 1 commit into
Open
security(api): 36 read routes returned customer data to any signed-in user#101goetchstone wants to merge 1 commit into
goetchstone wants to merge 1 commit into
Conversation
… user Chasing one reported gap — the warehouse returns queue — turned up the class. 83 Pages Router route files called getServerSession, confirmed somebody was signed in, and proceeded. No role, no permission. Several sit behind pages that ARE role-gated, which is what made it invisible: the UI looked restricted while the data was not. Triaged rather than blanket-gated. 6 mutate, and all 6 are the NextAuth handler or crons carrying a Bearer AUTO_IMPORT_API_KEY — already covered by the existing mutating-route tripwire and its reasoned allowlist. The exposure is the reads: 36 endpoints returning customer names, phone numbers, pickup addresses, delivery notes, vendor cost positions, and in two cases the entire customer book as a CSV with no date filter and no pagination. Each route takes an existing permission copied from a sibling route doing the same job, never an invented mapping. Where no API sibling existed the page's own requirePage() permission was used (service/dashboard.ts), and where neither existed the route was skipped. Two failure modes had to be held apart, and an adversarial pass caught both: WIDENING. Three routes already had inline `role !== "MANAGER" && ...` checks that the initial grep did not recognise as gates, so they were mis-triaged as unguarded. On leads/needs-attention.ts the replacement permission (sales.lead) is one DESIGNER holds — the "fix" would have LOOSENED a working control. That file and mailchimp/backfill-customer-links.ts are reverted: neither was ever unguarded, and the latter is already reasoned about in the companion test's UNGATED_BY_DESIGN. OVER-GATING. Three inventory reports were gated on inventory.count (ADMIN/MANAGER/SUPER_ADMIN/WAREHOUSE) when the pages behind them are reachable by anyone the Inventory nav admits — inventory.read, which adds DESIGNER and REGISTER. That would have 403'd staff mid-workflow, which is a worse outcome for the business than the leak. Corrected to inventory.read; scan-history and unidentified-scans keep inventory.count because they are called from the count workflow screens themselves. Deliberately NOT changed: reports/detailed-sales/export.ts takes reporting.export (ADMIN/MANAGER/SUPER_ADMIN) while the page takes reporting.read, so DESIGNER and MARKETING can view but not export. That split is documented at navPermissions.ts:71 and is the intent, not an over-gate. __tests__/apiReadRouteAuthorization.test.ts is the tripwire, companion to the existing apiRouteAuthorization.test.ts — that one covers mutating routes, this one reads, because the rules differ: a mutating route needs a gate full stop, while a read route needs one matching its page's audience. Its first draft was worthless and the both-directions check proved it: it examined only routes calling getServerSession, but adding requirePermission REPLACES that call, so every route this sweep fixed left the population the moment it was fixed. It could watch the debt shrink and never notice a gate being torn out again. It now covers all 448 routes; removing a gate fails it by name. The inline-role-check pattern also matches BOTH `role !== "X"` guards and `role === "X" || ...` allows — a first draft caught only the negation. The remaining 64 unguarded routes are pinned in a fixture as declared debt rather than silently excluded, so they are counted and shrinking them is visible work. Verified: next build exit 0, tsc 0, jest unit 213 suites / 3,302 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Chasing one reported gap — the warehouse returns queue — turned up the class. 83 Pages Router route files called
getServerSession, confirmed somebody was signed in, and proceeded. No role, no permission.Several sit behind pages that are role-gated, which is what made it invisible: the UI looked restricted while the data was not.
Triaged, not blanket-gated
AUTO_IMPORT_API_KEY— already covered by the existing mutating-route tripwire and its reasoned allowlist.Each route takes an existing permission copied from a sibling route doing the same job, never an invented mapping. Where no API sibling existed, the page's own
requirePage()permission was used (service/dashboard.ts). Where neither existed, the route was skipped.Two failure modes, both caught by the adversarial pass
Widening. Three routes already had inline
role !== "MANAGER" && …checks that my initial grep didn't recognise as gates, so they were mis-triaged as unguarded. Onleads/needs-attention.tsthe replacement permissionsales.leadis one DESIGNER holds — the "fix" would have loosened a working control. That file andmailchimp/backfill-customer-links.tsare reverted; neither was ever unguarded.Over-gating. Three inventory reports were gated on
inventory.count(ADMIN/MANAGER/SUPER_ADMIN/WAREHOUSE) when the pages behind them are reachable by anyone the Inventory nav admits —inventory.read, which adds DESIGNER and REGISTER. That would have 403'd staff mid-workflow, a worse outcome for the business than the leak. Corrected toinventory.read;scan-historyandunidentified-scanskeepinventory.countbecause they're called from the count workflow itself.Deliberately not changed:
reports/detailed-sales/export.tstakesreporting.exportwhile its page takesreporting.read, so DESIGNER and MARKETING can view but not export. That split is documented atnavPermissions.ts:71— intent, not an over-gate.The tripwire, and why its first draft was worthless
__tests__/apiReadRouteAuthorization.test.tsis a companion to the existingapiRouteAuthorization.test.ts— that one covers mutating routes, this one reads. Two files because the rules differ: a mutating route needs a gate full stop; a read route needs one matching its page's audience.My first draft examined only routes calling
getServerSession. The both-directions check killed it immediately: addingrequirePermissionreplaces that call, so every route this sweep fixed left the population the moment it was fixed. It could watch the debt shrink and never notice a gate being torn out again — the exact regression it exists to catch.It now covers all 448 routes, and removing a gate fails it by name. The inline-role-check pattern also matches both
role !== "X"guards androle === "X" || …allows; a first draft caught only the negation and misread a route that was already protected.The remaining 64 unguarded routes are pinned in a fixture as declared debt rather than silently excluded, so they're counted and shrinking them is visible work.
Still open
The pages in front of these endpoints are mostly bare
requirePage()with no roles — the nav hides them, but the URL works for anyone signed in. Gating the APIs closes the data leak; the pages are a separate pass.Verification
next build— exit 0tsc --noEmit— 0jest --selectProjects unit— 213 suites, 3,302 tests🤖 Generated with Claude Code