fix(security): remove the mobile API surface and stop the 500 page leaking sessions
Two live disclosures and one latent account takeover, plus the infrastructure
defects that hid them.
AuthOtpController::verify() accepted a constant '0000' whenever auth_otp_mode was
'demo' — the value every instance was seeded with — and then minted a Sanctum
token for whichever active user matched the submitted phone number, staff
included. It was not exploitable as written, because 2026_08_30_000004 had
normalised users.phone to digits-only local form while normalizePhone() produced
+20…, so the lookup missed. That is one plausible bug-fix away from being live,
which is why the whole surface goes rather than the branch.
Deleting /api/v1 also removes: broadcast/send pushing to every device in the
academy with no permission check; ReceiptController's inverted ownership check,
which made any non-participant invoice world-readable to any token;
PaymentController::initiate with no ownership check at all; and
DeviceController keying updateOrCreate on the FCM token alone, letting one user
claim another's device. None of it is replaced — the member-facing surface is the
session-authenticated web portal, so a second token-authenticated surface meant
building and authorizing everything twice.
bootstrap/app.php built a full diagnostic payload for any 500 and errors/500
rendered it to the browser, ungated by APP_DEBUG. The session it printed carries
password_hash_web — the signed-in user's bcrypt hash — alongside the last ten
queries, the request input and the headers. Now gated on debug, auth keys
stripped by prefix even there, and the production page is self-contained with no
CDN. Detail still reaches storage/logs, keyed by the error id shown to the user.
ParentHome::$activeChildId was validated in mount() and selectChild() but used
raw in render() at eight query sites. Livewire is ^4.3, where a public property
is settable from the browser, so those checks were decoration: a guardian could
walk participant ids and read any child's balance, attendance and evaluations.
Locked, and re-validated in render() since the child list can change between
requests.
ParentExcuseForm wrote the attachment — typically a child's medical note — to the
PUBLIC disk, then discarded the record and flashed success. The parent believed
the absence was excused; nothing was stored, and the record kept feeding the
consecutive-absence threshold that auto-suspends a participant. It now stores
nothing and says so, until excuses are modelled properly.
Infrastructure, because each one hid a failure rather than causing one:
entrypoint continued booting after a failed migration, which serves a stale
schema and silently blocks every later migration forever; the env whitelist had
no PAYMOB_, so config:cache baked null credentials and the gateway failed closed
with no error anywhere; nginx's static-asset regex answered =404 for /sw.js
before PHP saw it; and Route::fallback returned 200 for every unrouted path, so
a deleted endpoint served a website page instead of 404.
Verified: 43/44 tests pass. The one failure is ExampleTest, which fails
identically on unmodified main — confirmed by stashing. Two new tests pin both
disclosures so they cannot return.
Co-Authored-By:
Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
This diff is collapsed.
routes/api.php
deleted
100644 → 0