- Date: 2026-08-16
- Method: Laravel Auditor (Discover → Scope → Verify → Report). Read-only; no application code modified.
- Auditor output: All findings verified against code, routes, schema, configuration, and the running test suite.
| Fact | Value |
|---|---|
| Framework | Laravel 13.24.0 |
| PHP | 8.4.23 |
| Database | SQLite (default connection) |
| Auth / API | Sanctum 4.3.3 (personal access tokens) |
| Frontend | Blade + Tailwind CSS 4 via Vite; Trix editor; Mews Purifier |
| Test suite | Pest 5 — 83 tests passing (220 assertions) over 19 files |
| App type | Blog CMS (server-rendered web CRUD) + JSON API |
| Feature | Routes | Implementation |
|---|---|---|
| Web posts CRUD | `GET | POST /posts, GET |
| Feature toggle | PATCH /posts/{post}/feature |
FeaturedPostController → TogglePostFeatureAction |
| Posts API | api/posts* (full CRUD, auth:sanctum) |
Api\PostController |
| Locale switching | GET /set-locale/{locale} |
LocaleController + SetLocale middleware |
| Misc | /, /welcome, /up |
redirect / static / health |
Models (3): User · Post (fillable: title, slug, description, image, content, published_at, category_id, tags, is_featured) · Category (title).
Schema notes: 13 tables. categories.title and posts.slug unique; posts.tags JSON-in-TEXT; is_featured INTEGER; posts has no owner/author column; category_id FK declared in migration.
Authorization state: zero gates, zero policies; can middleware unused.
Jobs/events/schedules: none beyond framework defaults.
Notable packages: laravel/sanctum, mews/purifier, barryvdh/laravel-debugbar (dev), Laravel Auditor/Boost (dev), larastan/rector (dev).
The URL-download feature lets an attacker make the server request an arbitrary URL, then writes the response body to disk.
Evidence
app/Traits/HasFileFromUrl.php:17-26— anyFILTER_VALIDATE_URLvalue triggers a server-side download.app/Support/FileUploaderFromUrl.php:16— unconditionalHttp::get($url); no scheme/host allowlist, no redirect restrictions (Guzzle follows by default), no size/timeout bound.- Reachable from anonymous
POST /posts(routes/web.php:27) andPOST|PATCH /api/posts(routes/api.php:9) viaStorePostRequest::prepareForValidation(app/Http/Requests/StorePostRequest.php:54) andUpdatePostRequest.php:52. - The downloader test confirms the arbitrary fetch (
tests/Unit/Support/FileUploaderFromUrlTest.php:10-21).
Why it matters: Anonymous attackers can probe internal network services (cloud metadata 169.254.169.254, localhost, on-prem HTTP) and the body is persisted even when the image validation rule later rejects the file. No throttle middleware exists to bound request volume.
Fix: Remove the URL-fetch capability, or restrict it to an allowlist of trusted hosts, disable redirects, cap size and timeout, re-validate the image mime from content, and process via a queue. Add throttle to store / update / feature.
The full web CRUD is anonymous; no ownership model exists.
Evidence
routes/web.php:27—Route::resource('posts', PostController::class)withwebmiddleware only; same forposts.featured(routes/web.php:39).policies_authorizationcontext tool: no gates, no policies.postsschema has no author/owner column;app/Actions/Posts/*.phpoperate on anyPostinstance.- Tests confirm anonymous mutation is current behavior —
tests/Feature/Http/Controllers/PostControllerTest.php:117-219(noactingAs). - API surface (
routes/api.php:8) enforcesauth:sanctumwhile the web surface enforces nothing — inconsistent boundaries. API tests only prove "any authenticated user can mutate any post" (tests/Feature/Http/Controllers/Api/PostControllerTest.php:151-174). GET /posts/{post}(PostController::show) applies nopublishedfilter, so unpublished/future posts are readable by guessing IDs.
Why it matters: Anyone can create, edit, feature, and soft-delete every post exactly once they can request the route. If users/roles are added later, this becomes a straight IDOR (any user can modify any post).
Fix: Require auth on mutating routes (keep reads guest-visible if intended), add user_id to posts, add a PostPolicy gating update/delete/feature to the owner, and add authorization tests asserting 403 for non-owners and guests.
Evidence: app/Providers/AppServiceProvider.php:58 — Model::unguard() runs in every environment. The explicit #[Fillable] attributes on Post/Category/User are therefore inert. Currently safe because only validated input reaches create()/update(), but any future Post::create($request->all()) would silently write arbitrary columns (up to id/deleted_at).
Fix: Remove Model::unguard() and depend on the Fillable attributes.
Evidence: image validation accepts SVG (app/Http/Requests/StorePostRequest.php:35, UpdatePostRequest.php:42). Uploads are stored publicly (Post model accessor ->store('posts','public'), app/Models/Post.php:107) and served from /storage/... (symlink configured). A script-bearing .svg served from the app origin executes on direct navigation. The P0-1 URL-download path makes injecting arbitrary bytes with a chosen filename trivial.
Fix: Reject svg (mimes:jpeg,png,gif,webp,avif,bmp), serve user uploads with a restrictive Content-Security-Policy/Content-Disposition, and re-validate stored files.
| ID | Finding | Rule | Severity | Confidence | Evidence / Fix |
|---|---|---|---|---|---|
| P2-1 | Unvalidated locale cookie | AUD-SEC-008 | low | confirmed | LocaleController.php:16 stores cookie()->forever('locale', $locale) for any string; app/Http/Middleware/SetLocale.php:20-22 applies it raw. Whitelist against Settings::getLocales() keys (en/fr/ar/hi/gu). |
| P2-2 | CORS allowed_origins = * with stateful Sanctum |
AUD-SEC-009 | low | confirmed | config/cors.php:24; sanctum.stateful pins localhost:3000/basic-crud.test. Not exploitable today (supports_credentials=false, token API) but a footgun. Pin real origins. |
| P2-3 | No rate limiting on public mutation endpoints | AUD-SEC-009 | low | confirmed | No throttle anywhere. Amplifies P0-1. Bound posts.store/update/destroy, posts.featured, set-locale. |
| P2-4 | Debug/dev surface exposed | AUD-SEC-007 | low | confirmed | app.debug=true; _boost/browser-logs route registered with no middleware; Debugbar routes in route table. Environment-gate these. |
- Testing gaps (AUD-TST-001/003, medium·confirmed): no authorization tests (unauthenticated mutation is asserted as correct behavior), no negative tests for URL-download (internal host rejection), SVG rejection, or mass-assignment.
SortDirectionresolves as a global class inapp/Builders/PostBuilder.php:11— verify it isn't an undeclared import (arch-test risk). - Performance (AUD-PER-003, low·confirmed):
PostBuilder::search()useswhereLike('title', '%…%')(PostBuilder.php:22);published_atfiltered but unindexed. Negligible on SQLite, matters if data grows. - Database (AUD-DB-002, low·confirmed): migration
2023_12_05_092225_…drops thepublishedboolean without migrating its value topublished_at— silent data loss if any row hadpublished=true. - Conventions (AUD-CON-004, low·confirmed):
UpdatePostRequest.php:32instantiatesnew StorePostRequest()outside the container; commented-outauthorize()blocks; large commented-out route alternatives inroutes/web.php.
| Severity | Count | Key risks |
|---|---|---|
| P0 | 2 | SSRF via image-URL fetch; zero authorization on web CRUD |
| P1 | 2 | Global unguard(); SVG stored-XSS |
| P2 | 4 | Unvalidated locale, CORS *, no throttling, debug surface |
| P3 | 4 | Test gaps, unindexed searches, migration data-loss, hygiene |
The two P0s share a root cause: the web surface trusts anonymous input end-to-end (no auth → no ownership → arbitrary URL fetch). Resolving P0-1 and P0-2 (auth boundary + policy + allowlisted/remotely validated image input) removes most of the risk.