Skip to content

Instantly share code, notes, and snippets.

@MrPunyapal
Last active August 17, 2026 18:08
Show Gist options
  • Select an option

  • Save MrPunyapal/b5ce2ff8b024916f35425fd94cfbf6ea to your computer and use it in GitHub Desktop.

Select an option

Save MrPunyapal/b5ce2ff8b024916f35425fd94cfbf6ea to your computer and use it in GitHub Desktop.
Audit report of basic-crud by Laravel Auditor.

Laravel Audit Report — basic-crud

  • 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.

1. Application Overview (Discover)

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 inventory

Feature Routes Implementation
Web posts CRUD `GET POST /posts, GET
Feature toggle PATCH /posts/{post}/feature FeaturedPostControllerTogglePostFeatureAction
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).


2. Findings

P0 — Fix first

P0-1 · Server-Side Request Forgery via image URL fetch — HIGH · confirmed

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 — any FILTER_VALIDATE_URL value triggers a server-side download.
  • app/Support/FileUploaderFromUrl.php:16 — unconditional Http::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) and POST|PATCH /api/posts (routes/api.php:9) via StorePostRequest::prepareForValidation (app/Http/Requests/StorePostRequest.php:54) and UpdatePostRequest.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.


P0-2 · Missing authorization on the entire web CRUD — HIGH · confirmed

The full web CRUD is anonymous; no ownership model exists.

Evidence

  • routes/web.php:27Route::resource('posts', PostController::class) with web middleware only; same for posts.featured (routes/web.php:39).
  • policies_authorization context tool: no gates, no policies.
  • posts schema has no author/owner column; app/Actions/Posts/*.php operate on any Post instance.
  • Tests confirm anonymous mutation is current behavior — tests/Feature/Http/Controllers/PostControllerTest.php:117-219 (no actingAs).
  • API surface (routes/api.php:8) enforces auth:sanctum while 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 no published filter, 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.


P1 — Fix soon

P1-1 · Global Model::unguard() disables mass-assignment protection everywhere — MEDIUM · confirmed

Evidence: app/Providers/AppServiceProvider.php:58Model::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.


P1-2 · Stored same-origin XSS via SVG upload — MEDIUM · high confidence

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.


P2 — Fix when convenient

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.

P3 — Notes

  • 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. SortDirection resolves as a global class in app/Builders/PostBuilder.php:11 — verify it isn't an undeclared import (arch-test risk).
  • Performance (AUD-PER-003, low·confirmed): PostBuilder::search() uses whereLike('title', '%…%') (PostBuilder.php:22); published_at filtered but unindexed. Negligible on SQLite, matters if data grows.
  • Database (AUD-DB-002, low·confirmed): migration 2023_12_05_092225_… drops the published boolean without migrating its value to published_at — silent data loss if any row had published=true.
  • Conventions (AUD-CON-004, low·confirmed): UpdatePostRequest.php:32 instantiates new StorePostRequest() outside the container; commented-out authorize() blocks; large commented-out route alternatives in routes/web.php.

3. Summary

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.

Laravel Audit Report — basic-crud

Date: 2026-08-16 Method: Laravel Auditor (Discover → Scope → Investigate → Verify → Report) Mode: Read-only. No application code modified.


1. Project facts

Fact Value
Framework Laravel 13.24.0
PHP 8.4.23
Database SQLite (60 posts, 8 soft-deleted)
Test framework Pest 5 (phpunit 13)
Auth Sanctum 4.3.3
Sanitization mews/purifier (CleanHtmlInput cast)
HTTP Guzzle 7.15
Frontend Blade + Tailwind (no Livewire/Inertia/Filament)
Routes 24 (web posts CRUD + feature toggle, set-locale, api/posts, debugbar/boost)
Models User, Post, Category
Migrations 9
Tests 19 (8 feature, 11 unit)
Jobs / queues / cron none
Policies / gates none
Composer audit 0 advisories

2. Audit scope

Domains audited (deep):

  • Security / authorization
  • Database (queries, indexes, schema interactions)
  • Testing (authorization coverage)

Domains skipped (no signal): queues/events (none exist), config hygiene (defaults, local env).

Verified-good (scoped out)

  • CSRF tokens present on every web form (@csrf + @method spoofing).
  • Output escaping correct; content rendered raw via {!! !!} is safe because it is sanitized at write time by the CleanHtmlInput purifier cast (app/Models/Post.php:127).
  • whereLike / orderBy are parameter-bound; sort column and direction are whitelisted (app/Builders/PostBuilder.php:44-50) — no SQL injection found.
  • No N+1 in list/show (withAggregate / loadAggregate in PostController).
  • Actions layer cleanly separates domain logic from controllers.

3. Findings

P0-1 · Missing authorization boundary on the entire web posts surface

Field Value
Rule AUD-SEC-001 — Missing authorization boundary
Domain Security
Severity High
Confidence Confirmed

Summary

Every web route that mutates data is unauthenticated and backed by zero policies or gates. Anonymous visitors can create, edit, delete (soft-delete), and feature posts.

Why it matters

The web surface (POST/PATCH/DELETE /posts*) is reachable by anyone on the internet. There is no authentication, no ownership model, and no login/register routes at all. The API requires a Sanctum token, but the web layer is wide open — an inconsistent, fully exploitable boundary. There is also nothing gating "who may feature a post."

Evidence

  • routes/web.php:27Route::resource('posts', PostController::class); :39PATCH posts/{post}/feature. Middleware list is only web, no auth.
  • app/Http/Requests/StorePostRequest.php:19-22 and UpdatePostRequest.php:17-23authorize() is commented out (defaults to true).
  • policies_authorization context: gates: [], policies: [], policy_files: [].
  • Feature tests run unauthenticated, encoding this as intended behavior: tests/Feature/Http/Controllers/PostControllerTest.php:117,174,200 and FeaturedPostControllerTest.php:11,26.

Affected resources

routes/web.php · app/Http/Controllers/PostController.php · app/Http/Controllers/FeaturedPostController.php · app/Http/Requests/StorePostRequest.php · app/Http/Requests/UpdatePostRequest.php

Recommendation

Define the authorization model (e.g. authenticated editors) and enforce it: add auth middleware to the web routes and add policies/gates for update, destroy, and feature. Re-enable authorize() in the Form Requests.

Remediation

  1. Wrap the resource + feature routes in middleware that requires authentication.
  2. Create and register a PostPolicy guarding update / destroy / feature.
  3. Restore meaningful authorize() methods in both Form Requests.
  4. Add authorization tests (see P2-3).

P0-2 · Unauthenticated SSRF + unbounded download via "image-from-URL"

Field Value
Rule AUD-SEC-005 — Dangerous file handling
Domain Security
Severity High
Confidence Confirmed

Summary

Both Form Requests resolve the image field to a file by having the server fetch an arbitrary user-supplied URL. FileUploaderFromUrl issues Http::get($url) with no scheme/host/IP/size restrictions, and the request fires during prepareForValidation()before image validation — for unauthenticated requests.

Why it matters

  • SSRF: any visitor can make the app request internal addresses (cloud metadata 169.254.169.254, localhost services, internal network), and use the app as a probe of internal hosts/protocols.
  • DoS: the full response is buffered and written to an unbounded temp file that is never cleaned, enabling memory/disk exhaustion.
  • Amplified by the missing rate limiting from P0-1.

Evidence

  • app/Support/FileUploaderFromUrl.php:16$response = Http::get($url); followed by :24 File::put($tempFile, $response->body());.
  • app/Traits/HasFileFromUrl.php:17-26 — triggered from prepareForValidation() in both Form Requests (StorePostRequest.php:52-55, UpdatePostRequest.php:49-53); the HTTP call happens regardless of later validation outcome.
  • 'image' => ['required', 'image'] (StorePostRequest.php:35) / ['nullable','image'] (UpdatePostRequest.php:42) — the GET fires before these checks can fail.
  • tests/Unit/Support/FileUploaderFromUrlTest.php only fakes example.com/* responses; no negative (internal-IP) test.

Affected resources

app/Support/FileUploaderFromUrl.php · app/Traits/HasFileFromUrl.php · both Form Requests · web + API store/update

Recommendation

Restrict fetchable URLs (host allowlist, http/https only, DNS-resolution check that blocks RFC1918 / link-local addresses), add connect/read timeouts, and enforce a maximum downloaded size. Consider gating URL-fetch behind an authenticated, throttled action.

Remediation

  1. Replace the bare Http::get($url) with a guarded helper that blocks internal IPs and non-http(s) schemes (resolve DNS, compare against private ranges).
  2. Enforce ->timeout(5) / ->connectTimeout(3) and stream to the temp file, aborting over a size cap (e.g. 10 MB).
  3. Delete the temp file in a finally/cleanup path after store().
  4. Add tests asserting internal-address and oversized-response rejection.

P1-1 · Soft-deleted posts permanently block slug reuse

Field Value
Rule AUD-CON-006 (validation gap) / AUD-DB-004 (missing constraint handling)
Domain Database
Severity Medium
Confidence Confirmed

Summary

posts.slug has a DB unique index and the validation rule is plain unique:posts — neither ignores soft-deleted (trashed) rows. After deleting a post, recreating any post with that slug fails: validation treats the slug as taken, and bypassing validation still hits the SQLite UNIQUE constraint and returns a 500.

Why it matters

Soft deletes are enabled (app/Models/Post.php:60); 8 rows are already trashed. Delete-then-recreate with the same slug is a normal workflow and will 500 on the constraint. Slug space is finite and user-meaningful.

Evidence

  • Schema: posts_slug_unique unique index + nullable deleted_at.
  • StorePostRequest.php:33'slug' => ['required','max:120','unique:posts','alpha_dash:ascii'] (no ignoreTrashed).
  • UpdatePostRequest.php:37-39 — rebuilds the rule, still no ignoreTrashed.
  • app/Actions/Posts/DeletePostAction.php:11-14 — soft delete only.
  • DB state: total=60, soft_deleted=8, duplicate_slugs=[] (latent — constraint prevents duplicates from ever being observable).

Affected resources

posts schema · StorePostRequest · UpdatePostRequest · DeletePostAction

Recommendation

Use Rule::unique('posts', 'slug')->ignoreTrashed() in both requests and, where the DB supports it, keep a partial unique index on (slug) WHERE deleted_at IS NULL.

Verification notes

Confirmed from code path + schema. Runtime reproduction deliberately not performed (read-only audit).


P1-2 · API: token issuance absent, no expiry, no per-token capability model

Field Value
Rule AUD-API-002 (overly broad token abilities) / AUTH gap
Domain Security / Architecture
Severity Medium
Confidence High

Summary

There is no login/token-issuance endpoint anywhere; sanctum.expiration is null (tokens never expire); no createToken(...)->abilities() call exists; and any authenticated user can run all CRUD on every post. The API is simultaneously unusable (no way to obtain a token) and all-powerful for anyone who holds one.

Why it matters

"Authentication" provides no real security value: every post is fully mutable by whoever is authenticated, tokens (hand-created in tinker/DB) live forever with ['*'] abilities, and no ownership or policy check exists at the API layer.

Evidence

  • Route table: no login / register / token endpoints.
  • config/sanctum.php:54'expiration' => null.
  • tests/Feature/Http/Controllers/Api/PostControllerTest.php:13-15Sanctum::actingAs(..., ['*']) bypasses real issuance.
  • app/Http/Controllers/Api/PostController.php — no policy or ownership checks on any method.

Affected resources

routes/api.php · app/Http/Controllers/Api/PostController.php · config/sanctum.php

Recommendation

Define the intended auth flow (token endpoint, expiry, scoped abilities such as posts:write), or for a demo explicitly drop the phantom auth and document the API surface. Add ownership/policy checks if multi-user.


P2-1 · Locale cookie accepts an arbitrary, unvalidated value

Field Value
Rule AUD-SEC-008 — Unsafe validation assumptions
Domain Security / Conventions
Severity Low
Confidence Confirmed (behavior) / Medium (impact)

Summary

LocaleController writes any {locale} to a forever cookie and SetLocale applies it as the app locale, while an explicit Settings::LOCALES whitelist exists but is never used.

Why it matters

Low direct impact (translations only fall back to default), but it is an existing whitelist bypass with forever-cookie semantics and is trivially hardenable.

Evidence

  • app/Http/Controllers/LocaleController.php:14-17cookie()->forever('locale', $locale).
  • app/Http/Middleware/SetLocale.php:20-22app()->setLocale(...) with no whitelist check.
  • app/Support/Settings.php:13-19LOCALES whitelist unused.
  • tests/Feature/Http/Controllers/LocaleControllerTest.php — no test asserts rejection of an invalid locale.

Recommendation

Validate $locale against Settings::LOCALES in LocaleController (and/or SetLocale); fall back to the default locale otherwise.


P2-2 · Missing indexes on frequently queried posts columns

Field Value
Rule AUD-PER-003 — Missing index on a frequently-queried column
Domain Performance / Database
Severity Low
Confidence High

Summary

posts.category_id (FK added in migration 2023_12_12_134928…, but no index in the schema), is_featured, and published_at are filter/sort targets with no indexes. Search uses LIKE '%…%', which is index-defeating by design.

Why it matters

Only 60 rows on SQLite today, so negligible now. Noted for the intended MySQL/Postgres deployment, where sorting by is_featured/created_at and filtering published() (app/Builders/PostBuilder.php:27-32) or joining category would scan.

Evidence

  • database_schema: posts indexes — only posts_slug_unique.
  • app/Builders/PostBuilder.php:20-25,44-50whereLike('title', '%'.$search.'%') and ordering by title|is_featured|created_at.

Recommendation

When scaling: add an index on category_id, and consider a composite (is_featured, published_at); keep LIKE %x% search only for small tables or adopt FTS.


P2-3 · No authorization tests; API never exercises the unauthenticated path

Field Value
Rule AUD-TST-003 — Missing authorization tests
Domain Testing
Severity Medium
Confidence Low

Summary

No test asserts that web mutations are rejected for anonymous users (they aren't — see P0-1). No API test checks for a 401 on api/posts/*. There is also no test for the SSRF guard (P0-2) or slug-reuse (P1-1).

Why it matters

The suite validates happy paths only. Once authorization is added, tests will not catch regressions — and today they silently lock in the vulnerability as "expected behavior".

Evidence

  • tests/Feature/Http/Controllers/Api/PostControllerTest.php:12-16beforeEach always authenticates.
  • PostControllerTest.php / FeaturedPostControllerTest.php — run unauthenticated.
  • No negative test for authz/401 exists in any of the 8 feature files.

Recommendation

Add tests once the boundary is defined: unauthenticated → redirect/401 for store/update/destroy/feature; SSRF-blocking tests; soft-delete slug-reuse test.


P3-1 · Api\PostController::update ignores the action's boolean result

Field Value
Rule AUD-CON-003 — Misuse of framework lifecycle/features
Domain Conventions
Severity Low
Confidence High

Summary

$action->execute($post, ...) returns bool; Api\PostController::update returns HTTP 200 with the (possibly unmodified) post regardless of whether update() succeeded.

Affected resources

app/Http/Controllers/Api/PostController.php:46-51

Recommendation

Inspect the return value and surface a 422/500 on failure, mirroring the web flow's error handling.


P3-2 · Production cookie/debug posture (defaults)

Field Value
Rule AUD-SEC-007(n/a — config, not code)
Domain Security
Severity Info
Confidence High (config state, no exploit)

Summary

session.secure is unset (cookie not forced over HTTPS in production) and app.debug=true (local env). Standard local development posture; only matters at deploy time.

Recommendation

Set SESSION_SECURE_COOKIE=true and APP_DEBUG=false in production.


4. Report summary

Priority Count Findings
P0 2 Missing web authorization boundary (AUD-SEC-001); unauthenticated SSRF via image URL fetch (AUD-SEC-005)
P1 2 Soft-delete slug collision (functional, 500); phantom API auth / non-expiring all-capabilities tokens
P2 3 Unvalidated locale cookie; missing post indexes; zero authorization tests
P3 2 Ignored update() result; prod cookie/debug posture
Info 1 Verified-good: CSRF, escaping, purifier, parameter binding, no N+1

Key risks

  1. The entire write surface of the app is publicly writable (P0-1).
  2. An unauthenticated attacker can instruct the server to fetch internal URLs (P0-2).
  3. Database and test-suite findings are healthy-but-incomplete rather than severe.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment