# Pre-release security checklist

Source: https://docs.externa.qiick.io/docs/security-checklist

Use this checklist before production — **application** gates (permissions, ACL, GraphQL, MFA). For host TLS / disk encryption / activity & chat data-at-rest assumptions, see [Threat model & hosting](/docs/threat-model).

> **Related docs**
> Host / data-at-rest posture: [Threat model & hosting](/docs/threat-model) · surface → gate → Pest: [AuthZ map](/docs/authz-map) · repo [`SECURITY.md`](https://github.com/qiick-io/externa-core/blob/develop/SECURITY.md) · [Roles & permissions](/docs/roles-and-permissions) · [Passkeys](/docs/passkeys) · [SSO / OIDC](/docs/sso-oidc) · [Public CMS API](/docs/public-cms-api) · [GraphQL](/docs/graphql) · [Outbound webhooks](/docs/webhooks) · [Collections API](/docs/collections-api) · [Operations](/docs/operations) · [externa-bruno](https://github.com/qiick-io/externa-bruno)

> On 21 Oct 2026 PHP released emergency patches for two critical CVEs (CVSS v4.0 **9.2**): **8.4.27**, **8.5.12**, **8.3.36**, and **8.2.35**. Every Externa deployment runs PHP 8.4+ and is affected until upgraded — upgrading is the only full mitigation. Advisories: [GHSA-w8hw-w9fx-j79w](https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w), [GHSA-6wmf-4rv5-p3rp](https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp). Operator summary: [externa-core discussion #247](https://github.com/qiick-io/externa-core/discussions/247). Keep PHP on the **latest patch** of your minor line; after upgrading, **restart php-fpm** and verify with `php -v` (CLI) plus FPM `phpinfo()` / status.

## Must pass

- [ ] PHP is on a supported, patched release (`php -v` ≥ **8.4.27** / **8.5.12**; externa-core **1.2.2+** requires `^8.4.27`) — [discussion #247](https://github.com/qiick-io/externa-core/discussions/247). Docker: re-pull `ghcr.io/qiick-io/externa-core:1.2.2` / `latest` and recreate containers; source builds use `--pull --no-cache`
- [ ] After a PHP upgrade on bare metal / Herd / Forge: **restart php-fpm** (or Herd’s PHP service) so workers load the patched binary — CLI `php -v` alone is not enough
- [ ] php.ini hardening (not a substitute for patching; keep after patch): `session.upload_progress.enabled = Off`, `session.use_strict_mode = 1`; on PHP-FPM lock with `php_admin_flag[session.upload_progress.enabled] = off` so `.user.ini` cannot override — see [Deployment — PHP runtime hardening](/docs/deployment#php-runtime-hardening)
- [ ] Body / multipart limits match the reverse proxy (`post_max_size`, `upload_max_filesize`, `max_input_vars`, `max_input_nesting_level`, `max_file_uploads` ↔ nginx `client_max_body_size`)
- [ ] nginx does **not** set `PHP_VALUE` / `PHP_ADMIN_VALUE` via `fastcgi_param` (advisory exploitation path)
- [ ] AuthZ surfaces match [AuthZ map](/docs/authz-map) (admin Spatie, Public API / GraphQL collection+file ACL, AI tools + inbound HMAC, outbound secret, private files / `read_private`)
- [ ] All `routes/collections.php` actions use Spatie `permission:` middleware (bulk via FormRequest)
- [ ] Form requests for collections authorize with the same permissions
- [ ] Pest: verified user **without** collection perms → **403** on schema/item writes (`CollectionAuthorizationTest`)
- [ ] Pest: user **with** perms → OK; super-admin bypass still works
- [ ] `collection_permissions.rules.fields` strip/deny unreadable/unwritable fields (REST + admin)
- [ ] `item_filter` hides/blocks items that do not match
- [ ] Roles UI can edit field ACL + item filter (**Fields & item filter** panel)
- [ ] GraphQL `/api/graphql` uses `ResolveApiAccess` + same guards + `throttle:api`
- [ ] Project settings / appearance gated by `can-manage-project-settings`
- [ ] If `two_factor_required` is on in production, users without Fortify TOTP **and** without a passkey are forced to `/settings/security` with a clear required-MFA banner (TOTP or passkey satisfies; optional personal 2FA/passkeys still work when the setting is off) — [decision table](/docs/passkeys#relationship-with-2-fa-two-factor-required)
- [ ] Passkeys: `php artisan migrate` applied (`passkeys` table); `APP_URL` origin matches WebAuthn `relying_party_id` / `allowed_origins`; **HTTPS in production** (passkeys need a secure context; rest of CMS can be HTTP but prefer HTTPS); Fortify `confirmPassword` still on for security settings; **no** vendor forks/patches of Fortify or `@laravel/passkeys`; RP checks not widened — [Passkeys](/docs/passkeys)
- [ ] If OIDC/SSO is enabled: redirect URI is `{APP_URL}/auth/oidc/callback`; client secret encrypted (`oidc_client_secret_configured`); link only on IdP-verified email; JIT off unless intended; OIDC does **not** satisfy `two_factor_required` — [SSO / OIDC](/docs/sso-oidc)
- [ ] Smoke: register passkey on `/settings/security`, sign in with **Sign in with passkey**, **Confirm with passkey** on `/user/confirm-password` (HTTPS / secure context), and password confirm + TOTP paths still work (password confirm also on HTTP when the passkey CTA is hidden) — [Confirm password with a passkey](/docs/passkeys#confirm-password-with-a-passkey)
- [ ] No collections admin endpoint without Gate/permission middleware
- [ ] (1.4.0) Admin responses carry `X-Frame-Options`, `Content-Security-Policy`, `Referrer-Policy`, `Permissions-Policy` (`SECURITY_HEADERS_ENABLED=true`); HSTS is set on the reverse proxy — [Security headers](/docs/configuration#security-headers)
- [ ] (1.4.0) `AI_WRITE_TOOLS` stays `false` unless you need AI writes; when on, only the needed tool classes are allowlisted and someone watches the approval inbox — [AI tool approvals & audit](/docs/ai-tool-approvals)
- [ ] (1.4.0) If `AI_MCP_ENABLED=true`: MCP keys have an active owner, a non-admin role, narrow `collection_slugs` / `files_scope`, and `mcp:write` only where approved writes are planned — [MCP server](/docs/mcp-server)
- [ ] (1.4.0) `registration_enabled` is off unless self-signup is intended (fresh installs default to off)

## Should verify

- [ ] Migrations applied: `passkeys` + `collection_item_revisions` + M2M junction object migration + `user_created_id` / `user_updated_id` on `collections_items`
- [ ] Revisions restore creates a new revision (History UI)
- [ ] M2M junction object shape documented for API consumers; item form preserves meta on save
- [ ] Jobs monitor gated by `can-show-jobs` / `can-manage-jobs`
- [ ] GraphQL query cache mode is `opcache` (or Redis), not database-serialized AST objects
- [ ] Rate limit / CORS / optional cost·GraphQL·payload caps / API key scopes for `/api/v1` and `/api/graphql` reviewed — [API abuse controls](/docs/api-abuse-controls)
- [ ] Production: set project `public_api_allowed_origins` (or explicit `CORS_ALLOWED_ORIGINS`); do not rely on Origin alone — use API key + IP for strong protection ([Origin allowlist](/docs/public-cms-api-types#origin-allowlist))
- [ ] `item_filter` SQL pushdown: list `meta.total` / pages match filtered set (not post-paginate PHP)
- [ ] Items Export CSV/JSON respects filters + cap (`CollectionItemExportService`)
- [ ] TipTap wysiwyg: toolbar works; script paste stripped by `WysiwygHtmlSanitizer`
- [ ] File Manager: dangerous extensions rejected (`ForbiddenUploadExtension`, including `svg`/`html`/`htm` and SSI/markup `shtml`/`shtm`/`xml`/`xhtml`/`xht`/`mht`/`xsl`/`svgz` from 1.3.0); MIME allowlist + magic bytes (`MimeTypeValidator`; SVG/JSON/XML out); name/metadata/tags HTML-stripped (`PlainTextSanitizer`) — [Upload security](/docs/file-manager#upload-security)
- [ ] Docker: nginx `/storage/` has `nosniff` + CSP sandbox ([core PR #314](https://github.com/qiick-io/externa-core/pull/314)) — [Deploy with Docker](/docs/deploy-with-docker)
- [ ] Effective-private files not reachable via unauthenticated `/storage/assets/...`; `files:migrate-private-disk` run after upgrade — [Files configuration](/docs/files-configuration)
- [ ] Behind a reverse proxy: `TRUSTED_PROXIES` set and verified (client IP, HTTPS detection, per-IP rate limits, API key IP allowlists) — [Reverse proxy](/docs/reverse-proxy)
- [ ] Super-admin escalation: only super-admins grant/manage super-admin principals; collection grants fail closed for ACL-scoped admins ([core PRs #307](https://github.com/qiick-io/externa-core/pull/307), [#312](https://github.com/qiick-io/externa-core/pull/312)) — [AuthZ map](/docs/authz-map)
- [ ] File Manager UI strings resolve under `files.*` for en/it/de (shell locale)
- [ ] Outbound webhook URL + secret both required for delivery; empty secret refuses queue/POST (no empty-key HMAC); secret not exposed in shared Inertia props; queue worker delivers `DeliverOutboundWebhookJob` — [Outbound webhooks](/docs/webhooks)
- [ ] SSRF: leave `WEBHOOKS_ALLOW_PRIVATE_HOSTS=false` unless a trusted LAN receiver needs it; private/reserved webhook & Slack URLs rejected on save + delivery — [SSRF guard](/docs/webhooks#ssrf-guard)
- [ ] AI collection-import webhook: `AI_WEBHOOK_TOKEN` set wherever the webhook is used (including local); clients send `X-AI-Webhook-Signature` (HMAC over the **raw body** only; **401** on failure or empty token in **every** environment — no unsigned bypass); CSRF exception only for that path — [AI API](/docs/ai-api)
- [ ] After rotating `AI_WEBHOOK_TOKEN` (`ai:rotate-webhook-token`), clear config cache / restart workers so grace + new secret load — [Rotate](/docs/ai-api#rotate-the-webhook-token)

## Dependency audits (CI)

GitHub Actions workflow **`audit`** (`.github/workflows/audit.yml`, [core#184](https://github.com/qiick-io/externa-core/issues/184)):

| Trigger                 | When                                            |
| ----------------------- | ----------------------------------------------- |
| `pull_request` / `push` | PRs and pushes to `develop` / `main` / `master` |
| `schedule`              | **Weekly** Monday 06:00 UTC (`0 6 * * 1`)       |
| `workflow_dispatch`     | Manual re-run                                   |

Jobs (blocking — no `continue-on-error`):

- `composer audit --locked` — fails on **high/critical** (low/medium ignored for now)
- `npm audit --omit=dev --audit-level=high` — **production/runtime deps only**

Policy is **blocking** from beta.3 onward (including GA). Never use `npm audit fix --force` in CI. Triage steps: [Contributing — Dependency audits](/docs/contributing#dependency-audits-ci). Companion: Dependabot (#33). Report app vulns via [`SECURITY.md`](https://github.com/qiick-io/externa-core/blob/develop/SECURITY.md).

## Code scanning & secrets

Maintainer hygiene for the public `qiick-io/externa-core` repo (not a substitute for the app gates above):

- [ ] **CodeQL** workflow present (`.github/workflows/codeql.yml`) — JS/TS on PRs / default branches + weekly schedule (PHP when CodeQL pack supports it)
- [ ] First-wave CodeQL alerts triaged (fix or dismiss with reason) under **Security → Code scanning**
- [ ] **Secret scanning** enabled; **push protection** on (repo **Settings → Code security**, or org default)
- [ ] No committed secrets; if push protection fires, rotate and remove from history

See [`SECURITY.md`](https://github.com/qiick-io/externa-core/blob/develop/SECURITY.md) · [Operations](/docs/operations#code-scanning-and-secret-scanning).

## Pest smoke (PHP 8.4)

```bash
herd php artisan test --filter='CollectionAuthorization|CollectionPermissionRules|Graphql|AdvancedFilters|CollectionItemRevision|CollectionItemAudit|CollectionItemExport|WysiwygSanitize|JobMonitor|ForbiddenUploadExtension|PlainTextSanitizer|FilesLocaleParity|FileManager|PrivateDiskFiles|many to many accepts junction'
```
