PR-4 commit 3 — closure-bookkeeping nu de implementation-PRs en de twee runbooks gemerged zijn. - RFC-WS-7-OBSERVABILITY.md: nieuwe §9 Implementation status (mei 2026) vat samen welke acceptance criteria via PR-1..PR-4 zijn voldaan en welke (1, 2, 7, 9, 10) op Bert's deploy-checklist resteren. Pointer naar ARCH-OBSERVABILITY.md als levende reference; de RFC blijft historisch document. - SECURITY_AUDIT.md: nieuwe sectie 'WS-7 Observability — finale audit (mei 2026)' tussen A13-10 en Positive Findings. Bevat (1) acceptance criteria checklist met status per criterium, (2) processing register entry voor GlitchTip (controller-not-processor, retention 90 dagen, TLS+full-disk-encryption+2FA), (3) zeven security controls die WS-7 introduceert (PII scrubbing, CSP whitelist, sourcemap upload-only, listener registration discipline, runtime portal-context-split, multi-tenant tag invariant, impersonation.active binary signal), (4) pointer naar runbooks/observability-erasure.md voor Art. 17. - BACKLOG.md: status-overzicht-tabel boven de OBS-entries. Toegevoegd als entry: OBS-2 (early-pipeline log context, ✅ Resolved), OBS-3 (sentry-context middleware coverage, ✅ Resolved — opgevouwen in AuthScopeContextListener), OBS-5 (Crewli render handlers report() invariant, ✅ Resolved via48f2a00+ ExceptionReportingTest), en OBS-9 (Active — staging environment GlitchTip CSP whitelist follow-up bij staging-introductie). Bestaande OBS-1, 4, 6, 7 ongewijzigd (Active); OBS-8 staat al op Resolved sindsdee1401. - .claude-sync.conf: drie nieuwe doc-paths toegevoegd (ARCH-OBSERVABILITY.md, runbooks/observability-triage.md, runbooks/observability-erasure.md). Post-commit sync-claude-docs hook regenereert SYNC_MANIFEST.md met deze entries. Closes WS-7 documentation acceptance criteria 8 (ARCH) en 14 (SECURITY_AUDIT). Resterende criteria (1, 2, 7, 9, 10) zijn deploy-checklist door Bert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
45 KiB
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.tokenmiddleware calls$next($request)unconditionally — it performs zero token validation. - Risk: Any route protected by
portal.tokenis 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}/claimresolves$personbyuser_id + event_idbut does NOT verify that$shiftbelongs 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_idis 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, andcancelpolicy methods check whether the user can manage$event, but never verify$assignmentbelongs 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. Theexists:persons,idrule 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
existsrule: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 withexists:shift_assignments,idwithout 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_idas a query parameter and callsEvent::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, andbulkConfirmresolve 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 underorganisations/{organisation}. The Event model has no OrganisationScope. Route model binding finds events globally. - Risk: Authenticated users can access event data from other organisations. The
statsendpoint specifically callsGate::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_idandcompany_idvalues 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_idvalidated withexists:events,idwithout 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_idownership 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_idis 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
$useris 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,idrule accepts any event. Cross-org check is inwithValidatorcallback, not in the rule itself. - Risk: Fragile defence — removing the
withValidatorcallback 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
truefor 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 useStr::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. Noexpires_atcolumn, no revocation mechanism. - Risk: A database dump exposes all active tokens. A compromised token is valid indefinitely.
- Fix: Store
hash('sha256', $token), addportal_token_expires_atcolumn, expire tokens after the event ends.
[MEDIUM] A02-4: Session secure cookie not enforced
- File:
api/config/session.php:172 - Description:
'secure' => env('SESSION_SECURE_COOKIE')resolves tonull/falsewhen env var is absent. Session cookie sent over plain HTTP. - Risk: Session hijacking via HTTP interception.
- Fix: Add
SESSION_SECURE_COOKIE=trueto.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=truein production.
[LOW] A02-6: No HTTPS enforcement middleware
- File:
api/config/app.php:55 - Description:
APP_URLdefaults tohttp://and noForceHttpsmiddleware exists. - Risk: API accessible over HTTP in misconfigured deployments.
- Fix: Add HTTPS enforcement middleware for production, or use
URL::forceScheme('https')inAppServiceProvider.
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
typeused inwhere()— 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
typeasin: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_statusis inOrganisation::$fillableAND inUpdateOrganisationRequestrules. Any org_admin can self-upgrade fromsuspended/trialtoactive. - Risk: Billing bypass — organisations can override their subscription status.
- Fix: Remove
billing_statusfromUpdateOrganisationRequest::rules(). Create a separatesuper_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 nothrottlemiddleware. 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 onemail|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) $artistand$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
ArtistPortalResourceandPortalEventResourceto 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) andstatus(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_idfrom$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_atare 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_atare all fillable — entirely system-set fields. - Risk: Latent mass-assignment vector.
- Fix: Remove from
$fillable, set explicitly inInvitationService.
[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;inapprove().
[MEDIUM] A04-9: No rate limiting on volunteer registration endpoint
- File:
api/routes/api.php:72 - Description:
events/{event}/volunteer-registerhas 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}/accepthas 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, orContent-Security-Policyheaders are added anywhere in the middleware stack. - Risk: Clickjacking, MIME sniffing attacks, missing HSTS.
- Fix: Create and register a
SecurityHeadersmiddleware 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.exampledefaultsAPP_DEBUG=true, training developers to leave it enabled. The exception handler (correctly) gates debug output onconfig('app.debug'). - Risk: Accidental production deployment with debug enabled exposes stack traces and query details.
- Fix: Change
.env.exampletoAPP_DEBUG=falsewith comment# Set to true only in local development.
[MEDIUM] A05-3: Session SameSite=Lax may break cross-subdomain Sanctum cookie auth
- File:
api/config/session.php:202 - Description: With two SPAs on different subdomains calling
api.crewli.app,SameSite=Laxblocks session cookies on cross-origin requests. - Risk: Sanctum SPA authentication may fail silently in production.
- Fix: Set
SESSION_DOMAIN=.crewli.appandSESSION_SAME_SITE=none(withSESSION_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 benoreply@crewli.appper 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,Acceptare needed. - Risk: Overly broad attack surface.
- Fix: Constrain to needed headers.
Positive findings (A05)
- CORS
allowed_originscorrectly restricted to two specific SPAs (not*). supports_credentials: truecorrectly set for Sanctum.APP_DEBUGdefaults tofalseinconfig/app.php.- No Telescope/Horizon routes found (not installed).
- No phpinfo endpoints exposed.
- Password hashing uses bcrypt with
BCRYPT_ROUNDS=12. $passwordis in$hiddenon 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.5in 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_PROXYhostname normalization bypass (SSRF) and unrestricted cloud metadata exfiltration via header injection. - Risk: Cloud metadata theft, SSRF in SSR contexts.
- Fix: Upgrade to
>=1.15.0in 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_domainsbypass in embed extension (CVE-2026-33347) andDisallowedRawHtmlbypass via whitespace (CVE-2026-30838). - Risk: If user-supplied Markdown is rendered, raw HTML injection is possible.
- Fix:
composer update league/commonmarkto>=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/phpunitto>=11.5.50.
[LOW] A06-6: psy/psysh 0.12.18 — Local privilege escalation
- File:
api/composer.json(vialaravel/tinker) - Description: CVE-2026-25129 — local privilege escalation via CWD
.psysh.phpauto-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 updatefor 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}andinvitations/{token}/accepthave no throttle. - Risk: Token brute-force and enumeration.
showendpoint 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.yamlx3). - Sanctum token prefix is empty (
'') — consider settingSANCTUM_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:
AuthorizationExceptionis 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 forAuthorizationExceptionwith user, IP, path, method context.
[MEDIUM] A09-3: Missing activity log on 10+ critical actions
- Description:
spatie/laravel-activitylogis 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()orLog::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
RateLimiterinAppServiceProvider::boot()withLockoutnotifications after N failures.
[LOW] A09-5: VolunteerRegistrationService catch block swallows exception context
- File:
api/app/Services/VolunteerRegistrationService.php:143-154 - Description:
UniqueConstraintViolationExceptionis caught and re-thrown asValidationExceptionwithout 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(), orcurl_*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
localStorage (apps/app and apps/portal)- 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 singleapps/appworkspace; PR-B2b retired the dual-cookie machinery. The system now issues a single httpOnlycrewli_app_tokencookie. 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.mdfor current architecture.
[HIGH] A13-2: Admin app cookies lack httpOnly, Secure, and SameSite flags RETIRED
httpOnly, Secure, and SameSite flags- 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. TheisSafeRelativePathhelper inapps/app/src/utils/postLoginRedirect.tsnow 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
v-html with API-sourced data in admin app (template pages)- 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-5: Admin router guard relies on JS-readable cookie without server validation 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-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.messagefrom 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
mapLoginErrorMessageis a good pattern — extend it.
[MEDIUM] A13-7: Admin main.ts mount-error handler uses innerHTML interpolation RETIRED
main.ts mount-error handler uses innerHTML interpolation- 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
SecurityHeadersmiddleware (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 allindex.htmlfiles for local testing. Seedeploy/README.mdfor rollout instructions. - WS-7 follow-up (mei 2026): SPA
connect-srcwhitelists the GlitchTip event-ingest endpoint as an explicit security control — devhttp://localhost:8200, prodhttps://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.phpreads 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.localfiles or source code..env.localfiles correctly gitignored.
WS-7 Observability — finale audit (mei 2026)
Acceptance criteria
Alle 14 criteria uit RFC-WS-7-OBSERVABILITY.md §6 zijn geadresseerd:
| # | Criterium | Status |
|---|---|---|
| 1 | GlitchTip op monitoring.hausdesign.nl met TLS + 2FA |
Bert-handmatig (deploy-checklist) |
| 2 | Twee projecten + DSNs in 1Password vault | Bert-handmatig (deploy-checklist) |
| 3 | Laravel SDK geïntegreerd; errors uit prod-API verschijnen <60s | ✅ PR-2 |
| 4 | apps/app SDK geïntegreerd; errors met org/user/release context; portal-routes strict scrub | ✅ PR-3 |
| 5 | Sourcemaps upload werkt; leesbare stack traces; .map afwezig in publieke bundle |
✅ PR-3 (deploy.sh) |
| 6 | PII scrubbing-tests groen (backend + frontend) | ✅ PR-2 + PR-3 |
| 7 | Smoke test induced 500 in staging | Bert-handmatig (deploy-checklist) |
| 8 | ARCH-OBSERVABILITY.md geschreven (WS-8b) |
✅ PR-4 |
| 9 | Email-alerting geconfigureerd + getest | Bert-handmatig (GlitchTip UI configuratie) |
| 10 | Retention-policy 90 dagen toegepast | Bert-handmatig (GlitchTip admin) |
| 11 | Daily postgres-backup-script in place | ✅ PR-1 |
| 12 | Activity_log indexes (D-06) gemigreerd | ✅ Pre-existing (Spatie default nullableMorphs); regression-guard tests/Feature/Database/ActivityLogIndexesTest.php |
| 13 | Structured logging conventie geïmplementeerd; X-Request-Id round-trip getest |
✅ PR-2 |
| 14 | SECURITY_AUDIT.md bijgewerkt |
✅ This entry |
Processing register
GlitchTip is opgenomen in Crewli's processing register als zelfstandig verwerkingsproces:
- Doel: defectdetectie en service-availability monitoring.
- Categorieën persoonsgegevens: pseudonieme identifiers (ULIDs voor user/organisation/event), technische metadata (route names, HTTP methods, stack traces zonder locals).
- Bron: geautomatiseerd captured uit Laravel API en apps/app SPA bij programmer/infra errors.
- Ontvanger: alleen Bert (single super_admin met 2FA op GlitchTip web-UI).
- Bewaartermijn: 90 dagen, daarna automatisch gepurged door GlitchTip's eigen partition-maintenance loop.
- Beveiligingsmaatregelen: TLS in transit, full-disk encryption at rest, SSH-key + 2FA op web-UI.
- Controller / processor: Crewli is controller. Self-hosted op Crewli-infra; geen processor-relatie of DPA-uitbreiding nodig.
- Procedure right to erasure (Art. 17): zie
runbooks/observability-erasure.md.
Security controls die WS-7 introduceert
-
PII scrubbing op events (back + front).
SentryEventScrubber(PHP) enscrubEvent(TypeScript) strippen sensitive body-keys, headers, query-params, cookies, en form_values voordat events naar GlitchTip gestuurd worden. Regression-guards:tests/Feature/Observability/PiiScrubbingTest.php(20 cases) enapps/app/src/observability/__tests__/scrubber.spec.ts(18 cases). -
CSP
connect-srcwhitelist voor named ingest host. Outgoing Sentry-events zijn beperkt totlocalhost:8200(dev) enmonitoring.hausdesign.nl(prod). Geen exfiltration mogelijk naar arbitrary hosts. Regression-guard:tests/Feature/Security/CspConnectsToObservabilityTest.php. Zie ook A13-9 hierboven. -
Sourcemap upload-only, never public-mapped.
deploy.shupload sourcemaps naar GlitchTip vóórfind apps/app/dist -name '*.map' -delete. Stack-traces leesbaar in GlitchTip UI; geen.mapbestanden bereikbaar via productie-bundle. Default soft-fail op upload faalt zodat deploy doorgaat (unmapped frames in GlitchTip is acceptabel; geblokkeerde deploy niet). -
Listener registration discipline. Auto-discovery uitgeschakeld; alle observability listeners expliciet geregistreerd in
AppServiceProvider::boot()met array-callable form. Regression-guard:tests/Feature/Observability/EventListenerRegistrationTest.php(BACKLOG OBS-8). Voorkomt silent double-emission die op een toekomstig moment additive operations zou breken. -
Runtime context-split portal/organizer. Frontend portal-zone (
route.meta.public === true && route.meta.context === 'portal') krijgt geenuser_idofusernameop events. RFC §3.7 frontend- block punt 5 — ULID-tokens voor token-based access (artist advance, public form fill) blijven uit GlitchTip-events. Regression-guard:apps/app/src/observability/__tests__/contextBinding.spec.ts(cross-zone leak test). -
Multi-tenant invariant op tag-niveau.
actor_scope=organisationimpliceert valide ULIDorganisation_id;actor_scope=platformimpliceert geenorganisation_id(forced fallback zou misleidend zijn). Regression-guard:AuthScopeContextListenerTest::test_organisation_id_present_when_actor_scope_is_organisation. -
impersonation.activealways-present binary signal. Default- in-listener ('false') + override-in-middleware ('true') pattern garandeert dat élke authenticated event een binary signal voor filtering heeft. Regression-guard:AuthScopeContextListenerTest::test_impersonation_active_default_false_across_every_actor_scope_branch.
Pointer naar Art. 17 procedure
GDPR right-to-erasure verzoeken voor GlitchTip-data: zie
runbooks/observability-erasure.md
voor stap-voor-stap procedure. Geautomatiseerd erasure-script staat
op BACKLOG; tot dan handmatige psql-procedure met audit-trail
verplichting.
Positive Findings
The following security measures ARE correctly implemented:
- Password hashing: bcrypt with
BCRYPT_ROUNDS=12.$passwordin User model's$hiddenarray. - CORS origins: Correctly restricted to two specific SPA origins (not
*).supports_credentials: trueset. - SQL injection prevention: All Eloquent queries use PDO parameter binding. All
whereRaw()/selectRaw()calls use?placeholders. - No shell execution: No
exec(),shell_exec(),system(),proc_open(), orpassthru()in application code. - Blade escaping: All Blade output uses
{{ }}(escaped). Zero instances of{!! !!}. - CSRF: Intentionally disabled for stateless token-based API — correct architecture.
- File uploads: Validated for type, mime, and size. Server-generated filenames prevent path traversal.
- Lock files:
composer.lockand allpnpm-lock.yamlfiles committed. - Password reset throttling:
auth/forgot-passwordhasthrottle:5,1. - Check-email throttling:
public/check-emailhasthrottle:10,1. - Debug output gated: Exception handler correctly gates debug info behind
config('app.debug'). - No Telescope/Horizon exposed: Not installed in the application.
- Invitation expiry: Invitations expire after 7 days and are checked for
pendingstatus +expires_at. - Activity logging:
spatie/laravel-activitylogin use for invitation and volunteer registration flows. - Policy coverage: 16 policies exist covering all major models. Controllers consistently use
Gate::authorize(). - 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 | 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 | N/A | |
| 21 | A06-3: Upgrade swiper (major version) | Medium |