Operations
MarkdownPre-release 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.
Related docs
Host / data-at-rest posture: Threat model & hosting · surface → gate → Pest: AuthZ map · repo SECURITY.md · Roles & permissions · Passkeys · SSO / OIDC · Public CMS API · GraphQL · Outbound webhooks · Collections API · Operations · externa-bruno
PHP security notice (CVE-2026-109618 / CVE-2026-109619)
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, GHSA-6wmf-4rv5-p3rp. Operator summary: externa-core discussion #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. Docker: re-pullghcr.io/qiick-io/externa-core:1.2.2/latestand 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 -valone 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 withphp_admin_flag[session.upload_progress.enabled] = offso.user.inicannot override — see 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↔ nginxclient_max_body_size) - [ ] nginx does not set
PHP_VALUE/PHP_ADMIN_VALUEviafastcgi_param(advisory exploitation path) - [ ] AuthZ surfaces match AuthZ map (admin Spatie, Public API / GraphQL collection+file ACL, AI tools + inbound HMAC, outbound secret, private files /
read_private) - [ ] All
routes/collections.phpactions use Spatiepermission: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.fieldsstrip/deny unreadable/unwritable fields (REST + admin) - [ ]
item_filterhides/blocks items that do not match - [ ] Roles UI can edit field ACL + item filter (Fields & item filter panel)
- [ ] GraphQL
/api/graphqlusesResolveApiAccess+ same guards +throttle:api - [ ] Project settings / appearance gated by
can-manage-project-settings - [ ] If
two_factor_requiredis on in production, users without Fortify TOTP and without a passkey are forced to/settings/securitywith a clear required-MFA banner (TOTP or passkey satisfies; optional personal 2FA/passkeys still work when the setting is off) — decision table - [ ] Passkeys:
php artisan migrateapplied (passkeystable);APP_URLorigin matches WebAuthnrelying_party_id/allowed_origins; HTTPS in production (passkeys need a secure context; rest of CMS can be HTTP but prefer HTTPS); FortifyconfirmPasswordstill on for security settings; no vendor forks/patches of Fortify or@laravel/passkeys; RP checks not widened — 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 satisfytwo_factor_required— 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 - [ ] 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 - [ ] (1.4.0)
AI_WRITE_TOOLSstaysfalseunless you need AI writes; when on, only the needed tool classes are allowlisted and someone watches the approval inbox — AI tool approvals & audit - [ ] (1.4.0) If
AI_MCP_ENABLED=true: MCP keys have an active owner, a non-admin role, narrowcollection_slugs/files_scope, andmcp:writeonly where approved writes are planned — MCP server - [ ] (1.4.0)
registration_enabledis 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_idoncollections_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/v1and/api/graphqlreviewed — API abuse controls - [ ] Production: set project
public_api_allowed_origins(or explicitCORS_ALLOWED_ORIGINS); do not rely on Origin alone — use API key + IP for strong protection (Origin allowlist) - [ ]
item_filterSQL pushdown: listmeta.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, includingsvg/html/htmand SSI/markupshtml/shtm/xml/xhtml/xht/mht/xsl/svgzfrom 1.3.0); MIME allowlist + magic bytes (MimeTypeValidator; SVG/JSON/XML out); name/metadata/tags HTML-stripped (PlainTextSanitizer) — Upload security - [ ] Docker: nginx
/storage/hasnosniff+ CSP sandbox (core PR #314) — Deploy with Docker - [ ] Effective-private files not reachable via unauthenticated
/storage/assets/...;files:migrate-private-diskrun after upgrade — Files configuration - [ ] Behind a reverse proxy:
TRUSTED_PROXIESset and verified (client IP, HTTPS detection, per-IP rate limits, API key IP allowlists) — Reverse proxy - [ ] Super-admin escalation: only super-admins grant/manage super-admin principals; collection grants fail closed for ACL-scoped admins (core PRs #307, #312) — 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 - [ ] SSRF: leave
WEBHOOKS_ALLOW_PRIVATE_HOSTS=falseunless a trusted LAN receiver needs it; private/reserved webhook & Slack URLs rejected on save + delivery — SSRF guard - [ ] AI collection-import webhook:
AI_WEBHOOK_TOKENset wherever the webhook is used (including local); clients sendX-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 - [ ] After rotating
AI_WEBHOOK_TOKEN(ai:rotate-webhook-token), clear config cache / restart workers so grace + new secret load — Rotate
Dependency audits (CI)
GitHub Actions workflow audit (.github/workflows/audit.yml, core#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. Companion: Dependabot (#33). Report app vulns via 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 · Operations.
Pest smoke (PHP 8.4)
herd php artisan test --filter='CollectionAuthorization|CollectionPermissionRules|Graphql|AdvancedFilters|CollectionItemRevision|CollectionItemAudit|CollectionItemExport|WysiwygSanitize|JobMonitor|ForbiddenUploadExtension|PlainTextSanitizer|FilesLocaleParity|FileManager|PrivateDiskFiles|many to many accepts junction'