Files
crewli/dev-docs/SECURITY_AUDIT.md
bert.hausmans 5c42f27b26 fix: whitelist GlitchTip ingest host in CSP connect-src
PR-3 follow-up. Live smoke surfaced that the @sentry/vue SDK was
running correctly and emitting events, but Crewli's strict
connect-src directive blocked every POST at the browser layer. No
fallback — events evaporated silently with a CSP-violation log in
DevTools console only.

Updated locations (audited the CSP surface; only two locations actually
need the whitelist):

- apps/app/index.html — dev meta CSP, adds http://localhost:8200 to
  connect-src so local dev hits the docker-compose GlitchTip stack.
- deploy/nginx/csp-spa.conf — prod organizer SPA CSP, adds
  https://monitoring.hausdesign.nl to BOTH the report-only and enforce
  add_header lines so a future flip between modes can't silently break
  observability.

NOT updated (deviation from prompt):

- api/config/security.php — the API CSP is `default-src 'none';
  frame-ancestors 'none'` for JSON responses. Browsers don't enforce
  connect-src on JSON contexts (no document, no fetch origin). Adding
  connect-src would be semantically a no-op and confuse the deny-by-
  default policy.

Regression guard: tests/Feature/Security/CspConnectsToObservabilityTest.
Reads both the dev meta tag and the prod nginx conf directly (the SPA's
CSP is not Laravel-served, so $this->get() can't reach it). Apply-with-
revert verified: stashing both fixes makes both cases fail with a clear
"Refused to connect because it violates the following CSP directive"
hint; popping the stash restores green.

SECURITY_AUDIT.md A13-9 updated with a WS-7 follow-up note documenting
the GlitchTip whitelist as an explicit security control: outgoing
observability traffic restricted to a single known host.

Test count 1549 to 1551. Larastan + Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:36:05 +02:00

40 KiB
Raw Blame History

Crewli Security Audit — 2026-04-14

Summary

  • Total findings: 57
  • Critical: 10 | High: 19 | Medium: 18 | Low: 10

Audit scope: all files under api/ and apps/ (app, portal).


Findings


A01 — Broken Access Control

[CRITICAL] A01-1: PortalTokenMiddleware is a complete no-op

  • File: api/app/Http/Middleware/PortalTokenMiddleware.php:17-19
  • Description: The portal.token middleware calls $next($request) unconditionally — it performs zero token validation.
  • Risk: Any route protected by portal.token is completely unprotected. Future routes added under this middleware will silently allow unauthenticated access.
  • Fix: Implement the middleware to extract and validate a portal token from the request, establish the portal context (actor identity + permitted event), and return 401 if invalid.

[CRITICAL] A01-2: PortalShiftController::claim() does not verify the shift belongs to the event

  • File: api/app/Http/Controllers/Api/V1/Portal/PortalShiftController.php:173-202
  • Description: Route POST /portal/events/{event}/shifts/{shift}/claim resolves $person by user_id + event_id but does NOT verify that $shift belongs to $event. A volunteer can supply any shift ID from a different event or organisation.
  • Risk: Cross-tenant shift claiming — volunteers can claim shifts in events/organisations they have no association with.
  • Fix: Verify $shift->festivalSection->event_id is within the resolved event IDs before proceeding.

[CRITICAL] A01-3: ShiftAssignmentPolicy does not verify the assignment belongs to the event

  • File: api/app/Policies/ShiftAssignmentPolicy.php:19-43
  • Description: The approve, reject, and cancel policy methods check whether the user can manage $event, but never verify $assignment belongs to that event. A malicious org_admin for event A can approve/reject/cancel assignments from event B.
  • Risk: Cross-event data manipulation across organisations.
  • Fix: Add if ($assignment->shift->festivalSection->event_id !== $event->id) return false; in each method.

[HIGH] A01-4: OrganisationScope is defined but never registered on any model

  • File: api/app/Models/Scopes/OrganisationScope.php
  • Description: The scope class exists but no model registers it via booted() / addGlobalScope(). The CLAUDE.md mandates using it on all event-related models.
  • Risk: No automatic multi-tenancy safety net. Isolation relies entirely on controller-level routing, which is fragile.
  • Fix: Register the scope on Event, Person, Shift, FestivalSection, TimeSlot, Location, CrowdList, CrowdType, Company, PersonTag, RegistrationFormField, RegistrationFieldTemplate.

[HIGH] A01-5: ShiftController::assign() and claim() use unscoped Person::findOrFail()

  • File: api/app/Http/Controllers/Api/V1/ShiftController.php:74,84
  • File: api/app/Http/Requests/Api/V1/AssignShiftRequest.php:20
  • Description: Person::findOrFail($request->validated('person_id')) fetches any person by ID regardless of event or organisation. The exists:persons,id rule has no scoping.
  • Risk: Cross-organisation person assignment — an org admin can assign shifts to persons from another tenant.
  • Fix: Scope to event: Person::where('id', $personId)->where('event_id', $event->id)->firstOrFail().

[HIGH] A01-6: CrowdListController::addPerson() uses unscoped Person::findOrFail()

  • File: api/app/Http/Controllers/Api/V1/CrowdListController.php:81
  • File: api/app/Http/Requests/Api/V1/AddPersonToCrowdListRequest.php:20
  • Description: Same issue as A01-5. A person from another organisation can be added to a crowd list.
  • Risk: Cross-tenant data leakage and reference pollution.
  • Fix: Scope the exists rule: Rule::exists('persons', 'id')->where('event_id', $request->route('event')->id).

[HIGH] A01-7: BulkApproveShiftAssignmentRequest uses unscoped exists:shift_assignments,id

  • File: api/app/Http/Requests/Api/V1/BulkApproveShiftAssignmentRequest.php:21
  • File: api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php:98-103
  • Description: assignment_ids.* validated with exists:shift_assignments,id without event or organisation scope. The controller loads assignments without filtering.
  • Risk: Cross-tenant bulk approval of shift assignments.
  • Fix: Add .whereHas('shift.festivalSection', fn ($q) => $q->where('event_id', $event->id)) to the query.

[HIGH] A01-8: BulkConfirmIdentityMatchesRequest uses unscoped exists:person_identity_matches,id

  • File: api/app/Http/Requests/Api/V1/BulkConfirmIdentityMatchesRequest.php:21
  • Description: Match IDs are not scoped to the route's organisation. An org admin could include IDs from another organisation.
  • Risk: Cross-tenant identity match manipulation.
  • Fix: Scope the query to organisation events in the controller.

[HIGH] A01-9: ReorderRegistrationFormFieldsRequest uses unscoped exists:registration_form_fields,id

  • File: api/app/Http/Requests/Api/V1/ReorderRegistrationFormFieldsRequest.php:21
  • Description: Field IDs are not scoped to the event. Fields from other events could be reordered.
  • Risk: Cross-event field manipulation.
  • Fix: Add Rule::exists('registration_form_fields', 'id')->where('event_id', $this->route('event')->id).

[HIGH] A01-10: PortalMeController::index() accepts any event_id without access verification

  • File: api/app/Http/Controllers/Api/V1/PortalMeController.php:23-83
  • Description: Takes event_id as a query parameter and calls Event::findOrFail() which returns any event from any organisation.
  • Risk: A user registered for events at multiple organisations can access their profile data cross-org by probing event IDs.
  • Fix: Validate that the authenticated user has a Person record for the requested event.

[HIGH] A01-11: InvitationController::revoke() does not verify the invitation belongs to the organisation

  • File: api/app/Http/Controllers/Api/V1/InvitationController.php:76
  • Description: Route DELETE organisations/{organisation}/invitations/{invitation} resolves the invitation globally. Never checks $invitation->organisation_id === $organisation->id.
  • Risk: An org admin of Org A can revoke invitations belonging to Org B.
  • Fix: Add if ($invitation->organisation_id !== $organisation->id) abort(404);.

[HIGH] A01-12: PersonIdentityMatchController does not verify matches belong to the route organisation

  • File: api/app/Http/Controllers/Api/V1/PersonIdentityMatchController.php:51,66,85
  • Description: confirm, dismiss, and bulkConfirm resolve matches globally. The route's {organisation} parameter is unused in policy checks.
  • Risk: Cross-org identity match confirmation/dismissal.
  • Fix: Scope match resolution: ->whereHas('person', fn ($q) => $q->whereIn('event_id', $organisation->events()->select('id'))).

[HIGH] A01-13: Event routes without organisation prefix bypass scope

  • File: api/routes/api.php:152-207
  • Description: Routes like events/{event}/stats, events/{event}/locations, etc. are not nested under organisations/{organisation}. The Event model has no OrganisationScope. Route model binding finds events globally.
  • Risk: Authenticated users can access event data from other organisations. The stats endpoint specifically calls Gate::authorize('view', $event) with no organisation parameter, skipping the org ownership check in the policy.
  • Fix: Move routes under organisations/{organisation}/events/{event}/... or register OrganisationScope on the Event model.

[MEDIUM] A01-14: Multiple form requests use unscoped exists:crowd_types,id and exists:companies,id

  • File: api/app/Http/Requests/Api/V1/StorePersonRequest.php:20,26
  • File: api/app/Http/Requests/Api/V1/UpdatePersonRequest.php:20,26
  • File: api/app/Http/Requests/Api/V1/StoreCrowdListRequest.php:22,25
  • File: api/app/Http/Requests/Api/V1/UpdateCrowdListRequest.php:22,25
  • Description: These allow crowd_type_id and company_id values from any organisation.
  • Risk: Cross-tenant reference pollution.
  • Fix: Use Rule::exists()->where('organisation_id', ...).

[MEDIUM] A01-15: UpdateEventRequest uses unscoped exists:events,id for parent_event_id

  • File: api/app/Http/Requests/Api/V1/UpdateEventRequest.php:26
  • Description: parent_event_id validated with exists:events,id without organisation scoping. The update controller does not re-verify.
  • Risk: An event can be re-parented to an event from a different organisation.
  • Fix: Scope: Rule::exists('events', 'id')->where('organisation_id', $this->route('organisation')->id).

[MEDIUM] A01-16: PortalShiftController::cancel() does not verify shiftAssignment belongs to the event

  • File: api/app/Http/Controllers/Api/V1/Portal/PortalShiftController.php:204-237
  • Description: Checks person_id ownership but not that the assignment belongs to the URL's event context.
  • Risk: Cross-event assignment cancellation for users registered in multiple events.
  • Fix: Verify $shiftAssignment->shift->timeSlot->event_id is in the resolved event IDs.

[MEDIUM] A01-17: MemberController::update() and destroy() don't verify the user is a member

  • File: api/app/Http/Controllers/Api/V1/MemberController.php:27-82
  • Description: Neither verifies the target $user is actually a member of the organisation. The "last org_admin" safeguard can be bypassed.
  • Risk: Misleading success responses; bypasses admin count safeguard.
  • Fix: Add if (!$organisation->users()->where('user_id', $user->id)->exists()) abort(404);.

[MEDIUM] A01-18: RegistrationFormFieldController::importFromEvent() fragile org check

  • File: api/app/Http/Controllers/Api/V1/RegistrationFormFieldController.php:98-107
  • File: api/app/Http/Requests/Api/V1/ImportFromEventRequest.php:20-40
  • Description: The exists:events,id rule accepts any event. Cross-org check is in withValidator callback, not in the rule itself.
  • Risk: Fragile defence — removing the withValidator callback silently enables cross-org import.
  • Fix: Use Rule::exists('events', 'id')->where('organisation_id', ...) directly in the rules array.

[LOW] A01-19: OrganisationPolicy::viewAny() returns true unconditionally

  • File: api/app/Policies/OrganisationPolicy.php:13-16
  • Description: Returns true for all authenticated users. Controller correctly scopes results, but the policy provides no guard.
  • Risk: No current exposure; maintenance risk if controller query changes.
  • Fix: Document the design decision; optionally remove the policy gate call.

[LOW] A01-20: ShiftAssignmentController::assignablePersons() uses raw DB::table() query

  • File: api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php:156-170
  • Description: Raw query correctly scoped but bypasses Eloquent models and future global scopes.
  • Risk: Maintenance risk.
  • Fix: Replace with Eloquent query.

A02 — Cryptographic Failures

[CRITICAL] A02-1: Portal and invitation tokens use ULID (predictable time-prefix)

  • File: api/database/migrations/2026_04_08_170000_create_artists_table.php:31
  • File: api/app/Services/InvitationService.php:45
  • Description: Portal tokens are char(26) (ULID size). ULID has 48 predictable timestamp bits + only 80 random bits. Invitation tokens also use Str::ulid().
  • Risk: An attacker who knows the approximate creation time can reduce the search space significantly. Combined with no rate limiting (A07-5), token brute-force becomes practical.
  • Fix: Use bin2hex(random_bytes(32)) for all security tokens — provides 256 bits of cryptographic randomness.

[HIGH] A02-2: Sanctum tokens never expire

  • File: api/config/sanctum.php:52
  • Description: 'expiration' => null — all issued tokens are permanent.
  • Risk: A stolen token grants indefinite access with no automatic remediation.
  • Fix: Set 'expiration' => 60 * 24 * 7 (7 days). Implement token refresh for long-lived sessions.

[HIGH] A02-3: Portal tokens stored plaintext with no expiry or revocation

  • File: api/app/Http/Controllers/Api/V1/PortalTokenController.php
  • File: api/database/migrations/2026_04_08_170000_create_artists_table.php:31
  • Description: Portal tokens are compared with where('portal_token', $token) — plaintext storage. No expires_at column, no revocation mechanism.
  • Risk: A database dump exposes all active tokens. A compromised token is valid indefinitely.
  • Fix: Store hash('sha256', $token), add portal_token_expires_at column, expire tokens after the event ends.
  • File: api/config/session.php:172
  • Description: 'secure' => env('SESSION_SECURE_COOKIE') resolves to null/false when env var is absent. Session cookie sent over plain HTTP.
  • Risk: Session hijacking via HTTP interception.
  • Fix: Add SESSION_SECURE_COOKIE=true to .env.example. Default: env('SESSION_SECURE_COOKIE', app()->isProduction()).

[LOW] A02-5: Session encryption disabled by default

  • File: api/.env:28
  • Description: SESSION_ENCRYPT=false — session data in database is unencrypted.
  • Risk: Database access exposes session contents.
  • Fix: Set SESSION_ENCRYPT=true in production.

[LOW] A02-6: No HTTPS enforcement middleware

  • File: api/config/app.php:55
  • Description: APP_URL defaults to http:// and no ForceHttps middleware exists.
  • Risk: API accessible over HTTP in misconfigured deployments.
  • Fix: Add HTTPS enforcement middleware for production, or use URL::forceScheme('https') in AppServiceProvider.

A03 — Injection

[MEDIUM] A03-1: EventController::index() uses unvalidated query parameters in where() clauses

  • File: api/app/Http/Controllers/Api/V1/EventController.php:31-33
  • Description: Unvalidated query param type used in where() — no Form Request for index action. While Eloquent uses PDO bindings (no SQL injection), this is an input validation gap.
  • Risk: Unexpected behaviour with malformed input.
  • Fix: Add a Form Request or validate type as in:event,festival,series.

[LOW] A03-2: PersonController::reject() — unvalidated reason passed to mail

  • File: api/app/Http/Controllers/Api/V1/PersonController.php:130
  • Description: $request->input('reason') used without length validation. The Blade template escapes it ({{ }}), so no XSS, but no length cap exists.
  • Risk: Oversized email payload.
  • Fix: Add a Form Request with 'reason' => ['nullable', 'string', 'max:1000'].

Positive findings (A03)

  • All whereRaw() / selectRaw() calls use parameterised bindings — no SQL injection vectors found.
  • No exec(), shell_exec(), system(), proc_open(), passthru(), or backtick operators found.
  • No {!! !!} (unescaped Blade output) found — all output uses {{ }}.
  • No LDAP or XPath injection vectors found.

A04 — Insecure Design

[CRITICAL] A04-1: billing_status writable by org_admin via API

  • File: api/app/Http/Requests/Api/V1/UpdateOrganisationRequest.php:26
  • File: api/app/Models/Organisation.php:23
  • Description: billing_status is in Organisation::$fillable AND in UpdateOrganisationRequest rules. Any org_admin can self-upgrade from suspended/trial to active.
  • Risk: Billing bypass — organisations can override their subscription status.
  • Fix: Remove billing_status from UpdateOrganisationRequest::rules(). Create a separate super_admin-only endpoint.

[CRITICAL] A04-2: No rate limiting on auth/login endpoint

  • File: api/routes/api.php:58
  • Description: Route::post('auth/login', LoginController::class) has no throttle middleware. Unlimited credential-guessing is possible.
  • Risk: Full credential-stuffing and password-spray attacks.
  • Fix: ->middleware('throttle:5,1'). For tighter control, configure a named limiter keyed on email|ip.

[CRITICAL] A04-3: No rate limiting on portal/token-auth endpoint

  • File: api/routes/api.php:73
  • Description: No throttle middleware on POST portal/token-auth. Combined with ULID predictability (A02-1), token enumeration becomes practical.
  • Risk: Automated token brute-force/enumeration.
  • Fix: ->middleware('throttle:10,1').

[CRITICAL] A04-4: portal/token-auth returns raw model data including all columns

  • File: api/app/Http/Controllers/Api/V1/PortalTokenController.php:29-33,42-47
  • Description: Returns (array) $artist and $event?->toArray() — every database column exposed including internal fields, milestone booleans, project_leader_id, etc.
  • Risk: Internal operational data exposed to portal users. Violates "Return all responses through API resources."
  • Fix: Create ArtistPortalResource and PortalEventResource to whitelist only necessary fields.

[HIGH] A04-5: Person::$fillable includes user_id and status

  • File: api/app/Models/Person.php:27,36
  • Description: user_id (identity link) and status (approval state) are in $fillable. Controller layer is safe today, but model-level permission enables future mass-assignment.
  • Risk: Latent mass-assignment risk for identity hijacking.
  • Fix: Remove user_id from $fillable. Set explicitly: $person->user_id = $user->id.

[HIGH] A04-6: ShiftAssignment::$fillable includes audit trail fields

  • File: api/app/Models/ShiftAssignment.php:23-40
  • Description: auto_approved, assigned_by, approved_by, approved_at, cancelled_by, cancelled_at, checked_in_at, checked_out_at are fillable.
  • Risk: Future fill($request->all()) call could forge audit trails.
  • Fix: Remove all audit-trail fields from $fillable. Set explicitly in the service layer.

[HIGH] A04-7: UserInvitation::$fillable includes role, token, status

  • File: api/app/Models/UserInvitation.php:18-27
  • Description: role, token, status, organisation_id, invited_by_user_id, expires_at are all fillable — entirely system-set fields.
  • Risk: Latent mass-assignment vector.
  • Fix: Remove from $fillable, set explicitly in InvitationService.

[MEDIUM] A04-8: No self-approval guard in ShiftAssignmentPolicy

  • File: api/app/Policies/ShiftAssignmentPolicy.php:19-23
  • Description: An org_admin who is also a registered volunteer can approve their own shift assignment.
  • Risk: Self-approval of shift assignments bypasses the approval workflow.
  • Fix: Add if ($assignment->person?->user_id === $user->id) return false; in approve().

[MEDIUM] A04-9: No rate limiting on volunteer registration endpoint

  • File: api/routes/api.php:72
  • Description: events/{event}/volunteer-register has no throttle middleware.
  • Risk: Automated mass registration / spam.
  • Fix: ->middleware('throttle:5,1').

[LOW] A04-10: No rate limiting on invitation accept endpoint

  • File: api/routes/api.php:62
  • Description: invitations/{token}/accept has no throttle middleware.
  • Risk: Token brute-force (mitigated by ULID entropy).
  • Fix: ->middleware('throttle:10,1').

[LOW] A04-11: Inline validate() in PasswordResetController

  • File: api/app/Http/Controllers/Api/V1/PasswordResetController.php:28-32
  • Description: Both methods use $request->validate() inline instead of Form Requests. Violates project rules.
  • Risk: Inconsistent validation patterns.
  • Fix: Extract to dedicated Form Requests.

A05 — Security Misconfiguration

[HIGH] A05-1: No security headers middleware

  • Description: No X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, or Content-Security-Policy headers are added anywhere in the middleware stack.
  • Risk: Clickjacking, MIME sniffing attacks, missing HSTS.
  • Fix: Create and register a SecurityHeaders middleware with: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Strict-Transport-Security: max-age=31536000; includeSubDomains, X-XSS-Protection: 1; mode=block.

[MEDIUM] A05-2: APP_DEBUG=true in .env.example

  • File: api/.env.example:4
  • Description: .env.example defaults APP_DEBUG=true, training developers to leave it enabled. The exception handler (correctly) gates debug output on config('app.debug').
  • Risk: Accidental production deployment with debug enabled exposes stack traces and query details.
  • Fix: Change .env.example to APP_DEBUG=false with comment # Set to true only in local development.
  • File: api/config/session.php:202
  • Description: With two SPAs on different subdomains calling api.crewli.app, SameSite=Lax blocks session cookies on cross-origin requests.
  • Risk: Sanctum SPA authentication may fail silently in production.
  • Fix: Set SESSION_DOMAIN=.crewli.app and SESSION_SAME_SITE=none (with SESSION_SECURE_COOKIE=true) in production.

[MEDIUM] A05-4: /mail-preview/{type} web route exposes real database records without auth

  • File: api/routes/web.php:12-52
  • Description: Mail preview gated by app()->environment('local', 'staging') but fetches real Person, Event, UserInvitation records with no authentication.
  • Risk: Staging environment exposes real data including invitation tokens.
  • Fix: Add ->middleware('auth:sanctum') or generate previews using factory-only data.

[MEDIUM] A05-5: Local .env uses wrong mail sender domain

  • File: api/.env:50
  • Description: MAIL_FROM_ADDRESS="noreply@eventcrew.nl" — should be noreply@crewli.app per CLAUDE.md.
  • Risk: Failed SPF/DKIM, confused recipients.
  • Fix: Update to noreply@crewli.app.

[LOW] A05-6: allowed_headers: ['*'] in CORS config

  • File: api/config/cors.php:22
  • Description: Accepts any header from allowed origins. Only Content-Type, Authorization, X-Requested-With, Accept are needed.
  • Risk: Overly broad attack surface.
  • Fix: Constrain to needed headers.

Positive findings (A05)

  • CORS allowed_origins correctly restricted to two specific SPAs (not *).
  • supports_credentials: true correctly set for Sanctum.
  • APP_DEBUG defaults to false in config/app.php.
  • No Telescope/Horizon routes found (not installed).
  • No phpinfo endpoints exposed.
  • Password hashing uses bcrypt with BCRYPT_ROUNDS=12.
  • $password is in $hidden on User model.

A06 — Vulnerable Components

[CRITICAL] A06-1: @casl/ability 6.7.3 — Prototype Pollution

  • File: apps/app/package.json, apps/portal/package.json
  • Description: CASL is used for frontend permission enforcement across both apps. Prototype pollution could corrupt ability checks.
  • Risk: Permission bypass in the frontend. Admin tokens at elevated risk.
  • Fix: Upgrade to >=6.7.5 in both apps.

[CRITICAL] A06-2: axios ~1.13.x — SSRF and metadata exfiltration

  • File: apps/app/package.json, apps/portal/package.json
  • Description: Two critical CVEs: NO_PROXY hostname normalization bypass (SSRF) and unrestricted cloud metadata exfiltration via header injection.
  • Risk: Cloud metadata theft, SSRF in SSR contexts.
  • Fix: Upgrade to >=1.15.0 in both apps.

[CRITICAL] A06-3: swiper 11.2.10 — Prototype Pollution

  • File: apps/app/package.json, apps/portal/package.json
  • Description: Prototype pollution vulnerability.
  • Risk: Client-side code execution.
  • Fix: Upgrade to >=12.1.2 (major version bump — review migration guide).

[MEDIUM] A06-4: league/commonmark 2.8.0 — HTML injection bypass

  • File: api/composer.json
  • Description: Two CVEs: allowed_domains bypass in embed extension (CVE-2026-33347) and DisallowedRawHtml bypass via whitespace (CVE-2026-30838).
  • Risk: If user-supplied Markdown is rendered, raw HTML injection is possible.
  • Fix: composer update league/commonmark to >=2.8.2.

[LOW] A06-5: phpunit/phpunit 11.5.46 — Unsafe deserialization (dev only)

  • File: api/composer.json
  • Description: CVE-2026-24765 — dev dependency only, not deployed.
  • Risk: CI/CD pipeline risk only.
  • Fix: composer update phpunit/phpunit to >=11.5.50.

[LOW] A06-6: psy/psysh 0.12.18 — Local privilege escalation

  • File: api/composer.json (via laravel/tinker)
  • Description: CVE-2026-25129 — local privilege escalation via CWD .psysh.php auto-load.
  • Risk: Low — requires shell access. Confirm Tinker is not exposed via web route.
  • Fix: composer update laravel/tinker.

[LOW] A06-7: Frontend dev dependencies — 45 vulnerabilities in build tools

  • File: apps/app/pnpm-lock.yaml, apps/portal/pnpm-lock.yaml
  • Description: vite, rollup, tar, undici, minimatch, svgo, flatted, immutable, lodash, picomatch — various high/moderate vulnerabilities in dev/build-time dependencies.
  • Risk: Not shipped to production. CI/CD pipeline risk.
  • Fix: Update dev dependencies: pnpm update for each app.

A07 — Identification and Authentication Failures

[MEDIUM] A07-1: Account enumeration via CheckEmailController

  • File: api/app/Http/Controllers/Api/V1/CheckEmailController.php:16-18
  • Description: Returns {"exists": true/false} for any email. Rate limited to 10/min but still enables targeted enumeration.
  • Risk: Confirms which email addresses are registered users.
  • Fix: Consider removing this endpoint or requiring CAPTCHA. If needed for UX, apply stricter rate limiting.

[MEDIUM] A07-2: Existing Sanctum tokens not revoked on password reset

  • File: api/app/Http/Controllers/Api/V1/PasswordResetController.php:35-43
  • Description: Password::reset() updates the password but does not revoke existing tokens. An attacker with a stolen token retains access after reset.
  • Risk: Password reset does not evict active attackers.
  • Fix: Add $user->tokens()->delete(); in the closure after saving the new password.

[MEDIUM] A07-3: Weak password validation (min:8 only, no complexity)

  • File: api/app/Http/Requests/Api/V1/LoginRequest.php:22
  • File: api/app/Http/Requests/Api/V1/AcceptInvitationRequest.php:27
  • File: api/app/Http/Controllers/Api/V1/PasswordResetController.php:32
  • Description: All password fields only enforce min:8. No mixed case, digits, symbols, or breach-check.
  • Risk: Common/weak passwords accepted.
  • Fix: Use Password::min(8)->mixedCase()->numbers() consistently. Consider ->uncompromised().

[MEDIUM] A07-4: No rate limiting on invitation routes

  • File: api/routes/api.php:61-62
  • Description: GET/POST invitations/{token} and invitations/{token}/accept have no throttle.
  • Risk: Token brute-force and enumeration. show endpoint confirms token validity (404 vs. 200).
  • Fix: ->middleware('throttle:10,1') on both routes.

[LOW] A07-5: Schema::hasTable() runtime checks in PortalTokenController

  • File: api/app/Http/Controllers/Api/V1/PortalTokenController.php:22,37
  • Description: Schema introspection on every request. The 501 response reveals database schema info.
  • Risk: Minor info disclosure + performance cost.
  • Fix: Remove Schema::hasTable() guards; handle via migration, not runtime.

[LOW] A07-6: Email in password reset URL query string

  • File: api/app/Providers/AppServiceProvider.php:19-21
  • Description: Email address included as plaintext query parameter in the reset URL. Appears in browser history, server logs, referrer headers.
  • Risk: Email address leakage.
  • Fix: Omit email from URL; require user to enter it on the reset form.

A08 — Software and Data Integrity

Positive findings (A08)

  • CSRF intentionally disabled for stateless token-based API — appropriate architecture.
  • File uploads validate image, mimes:jpg,jpeg,png,webp, max:5120. Uses server-generated filenames.
  • All dependency lock files committed (composer.lock, pnpm-lock.yaml x3).
  • Sanctum token prefix is empty ('') — consider setting SANCTUM_TOKEN_PREFIX=crewli_ to enable GitHub secret scanning.

A09 — Security Logging and Monitoring

[HIGH] A09-1: Failed login attempts not logged

  • File: api/app/Http/Controllers/Api/V1/LoginController.php:17
  • Description: On failed Auth::attempt(), returns 401 with no log entry, no event firing.
  • Risk: Brute-force attacks leave no audit trail.
  • Fix: Add Log::warning('Failed login attempt', ['email' => ..., 'ip' => ..., 'user_agent' => ...]).

[MEDIUM] A09-2: Authorization failures (403) not logged

  • File: api/bootstrap/app.php:65-90
  • Description: AuthorizationException is handled but not logged. Cross-tenant access probes leave no trace.
  • Risk: No visibility into privilege escalation attempts.
  • Fix: Add a Log::warning() in the exception handler for AuthorizationException with user, IP, path, method context.

[MEDIUM] A09-3: Missing activity log on 10+ critical actions

  • Description: spatie/laravel-activitylog is installed but many critical actions are not logged:
    • Login success/failure
    • Logout
    • Person approved/rejected
    • Shift assignment approved/rejected/cancelled
    • Portal profile updated / password changed
    • Password reset completed
    • Organisation settings changed
    • Event status transitions
    • Identity match confirmed/dismissed
  • Risk: No audit trail for administrative decisions.
  • Fix: Add activity() or Log::info() calls for these actions.

[MEDIUM] A09-4: No suspicious pattern detection

  • Description: No mechanism to detect rapid failed logins, cross-tenant access attempts, or token enumeration.
  • Risk: Attacks proceed indefinitely without alerts.
  • Fix: Configure RateLimiter in AppServiceProvider::boot() with Lockout notifications after N failures.

[LOW] A09-5: VolunteerRegistrationService catch block swallows exception context

  • File: api/app/Services/VolunteerRegistrationService.php:143-154
  • Description: UniqueConstraintViolationException is caught and re-thrown as ValidationException without logging the original.
  • Risk: Race condition failures are invisible.
  • Fix: Log the original exception before rethrowing.

A10 — Server-Side Request Forgery

[LOW] A10-1: email_logo_url accepts any HTTP URL rendered in email templates

  • File: api/app/Http/Requests/Api/V1/UpdateOrganisationRequest.php:28
  • File: api/resources/views/mail/layouts/crewli.blade.php:22
  • Description: User-supplied URL used as <img src> in outbound emails. Email clients request this URL, leaking recipient IP and metadata.
  • Risk: Tracking pixel injection, recipient metadata exfiltration.
  • Fix: Restrict to HTTPS only (regex:/^https:\/\//). Consider server-side image proxy.

Positive findings (A10)

  • No Http::get/post, file_get_contents(), or curl_* calls in application code.
  • No webhook or callback URL configurations found.
  • No ZenderService or external HTTP client services exist.

Frontend Security (A13)

[CRITICAL] A13-1: Bearer tokens stored in localStorage (apps/app and apps/portal) RESOLVED

  • File: apps/app/src/stores/useAuthStore.ts (single SPA post WS-3)
  • Description: Pre-WS-3 (April 2026) the SPA layer used per-app cookies (crewli_app_token, crewli_portal_token) with Origin-based middleware resolution. WS-3 PR-B consolidated the dual SPAs into a single apps/app workspace; PR-B2b retired the dual-cookie machinery. The system now issues a single httpOnly crewli_app_token cookie. The localStorage-based bearer-token storage that this finding originally flagged was migrated to httpOnly cookies as part of the same consolidation arc.
  • Resolution: Tokens are httpOnly + Secure + SameSite=Strict, set server-side, never exposed to JavaScript. See dev-docs/AUTH_ARCHITECTURE.md for current architecture.

[HIGH] A13-2: Admin app cookies lack httpOnly, Secure, and SameSite flags RETIRED

  • File: apps/admin/ (removed)
  • Description: The admin SPA has been retired. Its functionality now lives in apps/app/ under /platform/* routes.
  • Resolution: Finding no longer applicable — apps/admin/ has been removed.

[HIGH] A13-3: Open redirect vulnerability on post-login redirect (all apps) RESOLVED by WS-3 PR-B2b

  • File: apps/app/src/utils/postLoginRedirect.ts (single SPA post WS-3)
  • Description: Login pages accepted ?to= query parameter and redirected to it after login without validating it's a relative path.
  • Risk: Phishing: https://crewli.app/login?to=https://evil.com/steal.
  • Resolution: WS-3 PR-B2a introduced a minimum precaution (startsWith('/') && !startsWith('//')); WS-3 PR-B2b replaced it with full validation. The isSafeRelativePath helper in apps/app/src/utils/postLoginRedirect.ts now rejects empty input, non-/-prefixed paths, protocol-relative URLs, backslashes (browsers normalise \/), ASCII control characters (\x00\x1F, \x7F), and anything the URL constructor parses to a different origin than a synthetic invalid base. 16 vitest specs pin the contract.

[HIGH] A13-4: v-html with API-sourced data in admin app (template pages) RETIRED

  • File: apps/admin/ (removed)
  • Description: The admin SPA has been retired. Its functionality now lives in apps/app/ under /platform/* routes.
  • Resolution: Finding no longer applicable — apps/admin/ has been removed.
  • File: apps/admin/ (removed)
  • Description: The admin SPA has been retired. Its functionality now lives in apps/app/ under /platform/* routes.
  • Resolution: Finding no longer applicable — apps/admin/ has been removed.

[MEDIUM] A13-6: Server error messages forwarded to UI

  • File: apps/app/src/pages/login.vue:59-69
  • File: apps/portal/src/pages/login.vue:51
  • Description: Raw data.message from API errors rendered in UI. Could expose internal details if backend misconfigured.
  • Risk: Information disclosure (table names, file paths).
  • Fix: Use safelist-based error mapping. The portal's mapLoginErrorMessage is a good pattern — extend it.

[MEDIUM] A13-7: Admin main.ts mount-error handler uses innerHTML interpolation RETIRED

  • File: apps/admin/ (removed)
  • Description: The admin SPA has been retired. Its functionality now lives in apps/app/ under /platform/* routes.
  • Resolution: Finding no longer applicable — apps/admin/ has been removed.

[MEDIUM] A13-8: Portal localStorage event state persists across session expiry

  • File: apps/portal/src/stores/usePortalStore.ts:9-34,86-94
  • Description: Event data and registration state stored in localStorage survives logout (partially cleared).
  • Risk: Stale data from previous sessions could contaminate new sessions.
  • Fix: Verify reset() is called on all logout paths including token expiry via 401 interceptor.

[LOW] A13-9: No Content Security Policy on any SPA — RESOLVED

  • File: apps/app/index.html, apps/portal/index.html, api/app/Http/Middleware/SecurityHeaders.php, deploy/nginx/
  • Description: Neither app set a CSP meta tag or header.
  • Risk: Injected scripts have unrestricted access.
  • Resolution: API CSP enforced via SecurityHeaders middleware (default-src 'none'; frame-ancestors 'none'). SPA CSP configured via Nginx snippets (deploy/nginx/csp-spa.conf, csp-portal.conf). Dev CSP meta tags added to all index.html files for local testing. See deploy/README.md for rollout instructions.
  • WS-7 follow-up (mei 2026): SPA connect-src whitelists the GlitchTip event-ingest endpoint as an explicit security control — dev http://localhost:8200, prod https://monitoring.hausdesign.nl (RFC-WS-7 §3.5). This restricts outgoing observability traffic to a single known host; without it, the strict CSP would either silently drop events (PR-3 regression) or — if loosened blindly — allow exfiltration to arbitrary hosts. Regression-guard: tests/Feature/Security/CspConnectsToObservabilityTest.php reads both the dev meta tag and the production nginx config and asserts the host is present.

[LOW] A13-10: No hardcoded secrets found in frontend code (positive)

  • Description: No API keys, secrets, or credentials found in .env.local files or source code. .env.local files correctly gitignored.

Positive Findings

The following security measures ARE correctly implemented:

  1. Password hashing: bcrypt with BCRYPT_ROUNDS=12. $password in User model's $hidden array.
  2. CORS origins: Correctly restricted to two specific SPA origins (not *). supports_credentials: true set.
  3. SQL injection prevention: All Eloquent queries use PDO parameter binding. All whereRaw()/selectRaw() calls use ? placeholders.
  4. No shell execution: No exec(), shell_exec(), system(), proc_open(), or passthru() in application code.
  5. Blade escaping: All Blade output uses {{ }} (escaped). Zero instances of {!! !!}.
  6. CSRF: Intentionally disabled for stateless token-based API — correct architecture.
  7. File uploads: Validated for type, mime, and size. Server-generated filenames prevent path traversal.
  8. Lock files: composer.lock and all pnpm-lock.yaml files committed.
  9. Password reset throttling: auth/forgot-password has throttle:5,1.
  10. Check-email throttling: public/check-email has throttle:10,1.
  11. Debug output gated: Exception handler correctly gates debug info behind config('app.debug').
  12. No Telescope/Horizon exposed: Not installed in the application.
  13. Invitation expiry: Invitations expire after 7 days and are checked for pending status + expires_at.
  14. Activity logging: spatie/laravel-activitylog in use for invitation and volunteer registration flows.
  15. Policy coverage: 16 policies exist covering all major models. Controllers consistently use Gate::authorize().
  16. No external HTTP calls: No outbound HTTP client usage in application code — no SSRF surface.

Priority Remediation Plan

Immediate (before any production deployment)

Priority Finding Fix effort
1 A04-2: Rate limit login One line
2 A04-3: Rate limit portal/token-auth One line
3 A04-4: API resources for portal/token-auth response Small
4 A04-1: Remove billing_status from UpdateOrganisationRequest One line
5 A02-2: Set Sanctum token expiration One line
6 A02-1: Replace ULID tokens with cryptographic random Small
7 A01-1: Implement PortalTokenMiddleware Medium
8 A13-3: Fix open redirect on login pages resolved by WS-3 PR-B2b Small

Short-term (within 1 sprint)

Priority Finding Fix effort
9 A01-2/3: Add event boundary checks in portal shift + policy Small
10 A01-5/6/7/8/9: Scope all exists: rules to org/event Medium
11 A01-4: Register OrganisationScope on models Medium
12 A04-5/6/7: Clean up $fillable on Person, ShiftAssignment, UserInvitation Small
13 A07-2: Revoke tokens on password reset One line
14 A05-1: Add security headers middleware Small
15 A06-1/2: Upgrade @casl/ability and axios Small
16 A13-1: Migrate token storage to httpOnly cookies Medium

Medium-term (within 2-3 sprints)

Priority Finding Fix effort
17 A01-13: Move event routes under org prefix Large
18 A09-1/2/3: Add security logging Medium
19 A07-3: Strengthen password rules Small
20 A13-2/5: Fix admin cookie security RETIRED (apps/admin removed) N/A
21 A06-3: Upgrade swiper (major version) Medium