# Security

How the requirements of PRD §30 and MP §8 are met, and where each control lives.

| Concern | Control | Where | Tested by |
|---|---|---|---|
| Rich-text XSS | Symfony HtmlSanitizer allowlist matching the editor toolbar; sanitised on save (cast / `normalize()`) **and** at render (`<x-rich-text>`) | `ContentSanitizer`, `SanitizedHtml`, `AbstractBlock::normalize` | `ContentSanitizerTest`, `PageBuilderTest`, `ComponentsTest` |
| Unescaped output | `{!!` allowed only in four files (rich text, JSON-LD with `JSON_HEX_*`, two plain-text emails) | Arch test | `ArchitectureTest` |
| Embeds | Video: YouTube-nocookie/Vimeo/Loom IDs parsed from URLs. Booking: host must match the provider. No editor HTML or scripts | `VideoEmbed`, `BookingPresenter` | `BlockSystemTest`, `BookingTest` |
| CSP | Nonce-based on the public site, no `unsafe-inline`/`unsafe-eval`; integrations add only their own origins; separate admin policy | `ContentSecurityPolicy`, `SecurityHeaders` | `SecurityHeadersTest`, `BookingTest`, `AnalyticsSeoTest` |
| Other headers | nosniff, Referrer-Policy, X-Frame-Options/frame-ancestors, Permissions-Policy, COOP, HSTS (production + HTTPS) | `SecurityHeaders` | `SecurityHeadersTest` |
| Authorization | Policy per model, permission matrix seeded from config, no `Gate::before` bypass, strict Filament authorization, last-Super-Admin guard | `app/Policies`, `config/permissions.php` | `RolesAndPermissionsTest`, `AdminScreensTest`, `SettingsPagesTest`, `AdminFlowsTest` |
| Admin access | Non-obvious `ADMIN_PATH`; rate-limited login; MFA (TOTP) required for Super Admins; strong passwords (12+, mixed case, numbers, symbols, breach check in production) | `AdminPanelProvider`, `RequireMultiFactorForSuperAdmins` | `AdminAccessTest` |
| Draft preview | Signed, expiring URL + authentication + `view` policy + throttle; noindex | `routes/web.php`, `PreviewController` | `PreviewTest` |
| Spam | Honeypot (mandatory fields, CSS-hidden) + 3s minimum + optional Turnstile/reCAPTCHA v3; silent discard | `SpamGuard`, `config/honeypot.php` | `ContactFormTest` |
| Rate limits | Contact 5/min + 20/day per IP; CV 60/min; preview 30/min | `AppServiceProvider` | `ContactFormTest` |
| Uploads | MIME allowlists per collection (raster images only, PDF for the CV), size limits, randomised names, EXIF stripped by re-encoding; CV on a non-public disk | `MediaCollection`, Filament uploads, `ReplaceCv` | `ContentRulesCmsTest`, `AdminFlowsTest` |
| Privacy | Contact IPs stored as an HMAC; retention pruning; analytics carry no personal data; contact message bodies never written to the activity log | `SubmitContactMessage`, `Prunable` models | `ContactFormTest` |
| Secrets | Only in `.env`/config (CAPTCHA, mail, analytics secrets); never in settings tables or logs; `.env` git-ignored | `config/services.php` | Review |
| Cache deserialization | Only allowlisted classes are unserialized from the cache | `config/cache.php` (ADR-033) | Whole suite (array store serializes in tests) |
| Errors | No stack traces in production; static 500/503 pages with no database dependency | `resources/views/errors` | Manual check (07-QUALITY §6) |
| Dependencies | `composer audit` and `npm audit` clean (2026-10-03) | CI recommendation | 07-QUALITY §2 |
| Audit trail | Content changes, publishing (with snapshots), section structure, settings, CV, users/roles/permissions, maintenance | spatie/activitylog | `PublishingActionsTest`, `SettingsPagesTest`, `PageBuilderTest` |

**Reporting a vulnerability:** contact the site owner privately. Don't open a public issue.
