Commit Graph

12 Commits

Author SHA1 Message Date
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
2e94a107e4 refactor(auth): consolidate to single cookie post single-SPA
The dual-cookie machinery (crewli_app_token + crewli_portal_token,
Origin-based resolution) was load-bearing only when the second SPA
existed. apps/portal/ was deleted in WS-3 PR-B1; the resolver code
has been carrying dead branches since then. Collapse to one cookie.

Cookie name retained as crewli_app_token — no session breakage on
deploy. crewli_portal_token is fully purged from the server-side.

CookieBearerToken middleware:
- COOKIE_NAMES array → single COOKIE_NAME constant
- resolveCookieName method (Origin/Referer parsing, host+port
  matching against frontend_app_url/frontend_portal_url) → removed
- Body collapses to: skip if Authorization header present; else
  read crewli_app_token cookie and inject Bearer header

SetAuthCookie trait:
- COOKIE_MAP / resolveCookieName / originMatches → removed
- makeAuthCookie / forgetAuthCookie now take only $token; the
  cookie name is the trait's private constant

Five callers updated to drop the resolveCookieName($request) line
and the cookie-name argument: LoginController (3 sites),
MfaVerifyController (1 site), AuthRefreshController (1 site),
LogoutController (1 site), InvitationController (1 site — caller
list in the prompt missed this one but the same pattern applies).

frontend_portal_url config key retained (per Phase A directive Q1):
EmailChangeController, PasswordResetController, PersonController are
non-auth consumers that build per-app URL maps for outbound emails.
The map structure is now functionally redundant (production resolves
all FRONTEND_* env vars to the same host) but stays structurally
intact. Refactor tracked as TECH-FRONTEND-URL-CONSOLIDATE in the
upcoming docs commit.

HttpOnlyCookieAuthTest:
- Removed 4 dual-cookie tests (login_sets_portal_cookie_for_portal_origin,
  app_cookie_does_not_authenticate_portal_requests,
  portal_cookie_does_not_authenticate_app_requests,
  correct_cookie_authenticates_with_matching_origin)
- Renamed login_sets_app_cookie_for_unknown_origin →
  login_sets_app_cookie_regardless_of_origin; expanded to four
  Origin variants (none, app, unknown, foreign) — pins the new
  origin-agnostic contract
- Removed Origin headers from request calls in remaining tests
  (now meaningless)

Backend test count: 1491 → 1487 (-4 deleted, dual-cookie tests
encoding the obsolete contract). Pint clean. Larastan clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:24:01 +02:00
a3ca596362 S2a: purge legacy Form Builder PHP code and routes 2026-04-17 18:43:00 +02:00
65978104d8 feat: complete email infrastructure with queue, templates, logging, and API
Adds the full transactional email system:
- Redis queue (QUEUE_CONNECTION=redis), SES config in .env.example
- 3 migrations: organisation_email_settings, organisation_email_templates, email_logs
- EmailTemplateType and EmailLogStatus enums with Dutch defaults
- EmailService as central entry point for all email sending
- SendTransactionalEmail queued job with retries and idempotency
- TransactionalMail mailable with responsive HTML + plain text templates
- Organisation-level branding (colors, logo, footer, reply-to)
- Per-type template overrides with {variable} substitution
- Email log with filtering by status, type, date range, recipient
- Preview and send-test endpoints for template management
- API endpoints: email-settings, email-templates (CRUD), email-logs (read-only)
- Integrated into existing flows: invitations, password reset, email
  verification, registration approval/rejection
- 37 new tests across 4 test files, all existing tests updated

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:12:21 +02:00
28727f246b chore: remove admin SPA and update to two-app production setup
Remove apps/admin/ entirely — platform admin functionality now lives
in apps/app/ under /platform/* routes for super_admin users.

Production URL scheme changed:
- Organizer app: crewli.app (was app.crewli.app)
- Portal: portal.crewli.app (unchanged)
- API: api.crewli.app (unchanged)
- admin.crewli.app and app.crewli.app retired

Backend:
- Removed FRONTEND_ADMIN_URL config and admin cookie (crewli_admin_token)
  from SetAuthCookie, CookieBearerToken, cors.php, app.php
- Updated .env and .env.example (two origins, no port 5173)
- Updated cookie test: admin origin test → unknown origin fallback test

Infrastructure:
- Makefile: removed admin target
- deploy/nginx: updated CSP comment, removed admin vhost
- Updated README.md, CLAUDE.md, and all dev-docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:44:10 +02:00
a9ef384515 fix: prevent cross-app auth session sharing on localhost
Root cause: browsers don't scope cookies by port. With SESSION_DOMAIN=
localhost, all three SPAs share cookies. The CookieBearerToken middleware
iterated all cookie names and picked the first match, so logging into
the organizer app (port 5174) also authenticated the portal (port 5175).

Fix: CookieBearerToken now resolves the correct cookie name from the
Origin header (same logic as SetAuthCookie trait). It only reads the
cookie matching the requesting app — portal origin reads only
crewli_portal_token, app origin reads only crewli_app_token, etc.

Falls back to first-available cookie when no Origin header is present
(server-to-server requests, tests without explicit Origin).

Added 3 cross-app isolation tests:
- app cookie does NOT authenticate portal requests
- portal cookie does NOT authenticate app requests
- correct cookie + matching origin = authenticated

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:19:42 +02:00
940297f214 security: implement CSP headers (API middleware + Nginx configs + dev meta tags)
API middleware:
- SecurityHeaders now sets Content-Security-Policy from config/security.php
- Default API policy: "default-src 'none'; frame-ancestors 'none'"
- Supports report-only mode via CSP_REPORT_ONLY env var
- Policy value configurable via CSP_POLICY env var

Nginx deployment configs (deploy/nginx/):
- security-headers.conf: shared headers for all server blocks
- csp-api.conf: restrictive JSON-only policy for api.crewli.app
- csp-spa.conf: SPA policy for app/admin (self + unsafe-inline styles)
- csp-portal.conf: portal policy matching SPA

Development:
- CSP meta tags added to all three index.html files
- Includes 'unsafe-inline' + 'unsafe-eval' for Vite HMR/loader script
- Each app allows its own ws:// port for HMR websocket

Resolves security finding A13-9.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:14:37 +02:00
513ca519b2 security: migrate auth tokens to httpOnly cookies (hybrid bearer token approach)
Backend:
- CookieBearerToken middleware reads httpOnly cookie and injects Authorization
  header before Sanctum validates (prepended to API middleware group)
- SetAuthCookie trait provides cookie creation/expiry helpers with per-app
  cookie names (crewli_admin_token, crewli_app_token, crewli_portal_token)
- LoginController sets token via Set-Cookie, removes it from JSON body
- LogoutController expires the auth cookie on logout
- AuthRefreshController (POST /auth/refresh) rotates tokens with new cookie
- InvitationController accept also sets token via cookie, not JSON body
- All cookies: httpOnly, SameSite=Strict, Secure (in production)

Frontend (all three SPAs):
- Removed all localStorage token storage (apps/app, apps/portal)
- Removed all JS-readable cookie token storage (apps/admin)
- Removed Authorization: Bearer header interceptors from axios
- Auth stores now rely on GET /auth/me to validate httpOnly cookie
- Admin app: new Pinia auth store replaces useCookie-based auth pattern
- withCredentials: true ensures browser sends cookies automatically

Fixes security findings A13-1 (localStorage tokens) and A13-2 (admin
cookie flags). Tokens are now invisible to JavaScript.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:06:44 +02:00
836cffa232 feat: password reset, email change with verification, and password change
Password reset: multi-app support with custom notification linking to correct
frontend (app/portal/admin). Email change: self-service with password
confirmation and admin-initiated, both sending verification to new address
with 24h expiry. Confirmation sent to old email on completion. Password
change: authenticated endpoint revoking other sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:38:54 +02:00
7932e53daf security: A01-13 — nest all event routes under organisation prefix
Move all authenticated organiser-facing event sub-resource routes from
/events/{event}/... to /organisations/{organisation}/events/{event}/...
to enforce multi-tenancy at the routing layer.

Changes:
- Routes: restructured api.php to nest all event sub-resources under
  the existing organisation prefix group
- Controllers: added Organisation parameter and VerifiesOrganisationEvent
  trait to all 12 affected controllers (sections, time-slots, shifts,
  persons, crowd-lists, locations, shift-assignments, registration-fields,
  availabilities, field-values, section-preferences, stats)
- Tests: updated all 20 feature test files with new route paths
- Frontend: updated 8 API composables and 20 Vue components/pages
- API.md: updated documentation to reflect new route structure

Portal routes, public routes (volunteer-register), and invitation routes
remain unchanged as they operate without organisation context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:16:36 +02:00
51e5dd6fcb security: comprehensive security regression test suite
61 tests across 4 test files covering all OWASP categories:

MultiTenancyIsolationTest (19 tests):
- Cross-org event, person, shift, section, time-slot, location,
  registration field, shift assignment, crowd list access
- Cross-org FK references (crowd_type, company, parent_event)
- Bulk operation isolation, invitation revocation
- Portal cross-event access prevention

AuthenticationSecurityTest (15 tests):
- Rate limiting: login (5/min), portal token-auth (10/min),
  invitation show (10/min)
- Account enumeration prevention: generic error on failed login,
  200 response on password reset for unknown email
- Token lifecycle: logout revokes token, password reset revokes
  all tokens, expired tokens rejected (7-day config verified)
- Password strength: weak/no-uppercase/no-numbers rejected
- Security headers present on all responses
- Protected routes require authentication

PortalTokenSecurityTest (10 tests):
- Invalid/empty/missing token handling
- Response shape: only safe fields (no milestones, no portal_token,
  no organisation_id, no internal event fields)
- Hash-based lookup: plain token works, hash does not
- Error messages: no schema/table info leakage
- Middleware: rejects without token, rejects invalid, accepts valid,
  rejects draft event status

InputValidationSecurityTest (17 tests):
- XSS payloads stored safely in person name, event name, section name
- Oversized inputs rejected (name >255, remarks >5000)
- Invalid enum values rejected (status, event_type)
- Cross-org FK references rejected (crowd_type, company, location,
  parent_event, person assignment)
- Invalid/nonexistent ULID format rejected
- SQL injection payloads harmless (PDO binding verified)

Also fixes PortalTokenMiddleware to use request->attributes->set()
instead of request->merge() for stdClass objects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:25:47 +02:00
090d2b7d89 security: round 2 — multi-tenancy isolation (OrganisationScope, scoped validation, boundary checks)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 06:38:19 +02:00