# Threat model & hosting

Source: https://docs.externa.qiick.io/docs/threat-model

Externa is **self-hosted**. The app ships RBAC, hashed passwords, optional MFA, and an admin-gated activity log. **Transport encryption, database disk encryption, and backup hygiene are the operator’s job.**

> **Related**
> Repo file: [`externa-core/SECURITY.md`](https://github.com/qiick-io/externa-core/blob/develop/SECURITY.md) · App gates: [Pre-release security checklist](/docs/security-checklist) · Surface → gate → Pest: [AuthZ map](/docs/authz-map) · [Deployment](/docs/deployment) · [Activity & notifications](/docs/activity-and-notifications) · [Passkeys](/docs/passkeys) · [Roles & permissions](/docs/roles-and-permissions)

## Goals

Protect against:

- Unauthenticated access to admin UI and APIs
- Authenticated users exceeding their Spatie / collection / file permissions
- Accidental logging of passwords, 2FA secrets, and API key material
- XSS from rich text (TipTap HTML sanitizer) and from File Manager plain-text fields (name / metadata / tags via `PlainTextSanitizer`)
- Upload of common executable / script extensions **and** `svg` / `html` / `htm` into the Files pool (`ForbiddenUploadExtension` denylist — product policy: **block**)
- Guessing `/storage/assets/...` paths for effective-private files (bytes live on `private_assets`, not the public symlink)

Not designed to protect against:

- An attacker with shell access to the app host **or** a copy of the database **and** `APP_KEY`
- Network sniffing when the operator runs plain HTTP in production
- End-to-end confidentiality of chat (no client-held keys)

## Trust boundaries

```
[Browser] --TLS (operator)-- [App / PHP]
                                |
                                +-- DB (chat bodies, activity JSON, users)
                                +-- Object storage (files)
                                +-- .env / APP_KEY
```

Anyone who can read the DB (or app storage + key) can read **chat message bodies** and **activity log properties**, including short chat `body_preview` fields. That matches a normal collaborative CMS, not a Signal-style messenger.

Laravel `encrypted` casts (e.g. AI sync bearer tokens) use `APP_KEY` on the server. Useful against raw DB dumps **without** the key; useless if the app server is compromised.

## What the application covers

| Area                         | Behavior                                                                                                                                                                            |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Passwords                    | Hashed; excluded from Spatie attribute logs on `User`                                                                                                                               |
| 2FA / passkeys               | Optional; project can require MFA — [Passkeys](/docs/passkeys)                                                                                                                      |
| API keys                     | Stored hashed; hash excluded from activity logs                                                                                                                                     |
| Activity Log UI / AI tool    | Permission `can-show-activity-logs`                                                                                                                                                 |
| Collections / files          | Spatie permissions + field / item ACL rules                                                                                                                                         |
| Wysiwyg                      | `WysiwygHtmlSanitizer` strips dangerous markup                                                                                                                                      |
| File Manager uploads         | Extension denylist (`ForbiddenUploadExtension`, includes SVG/HTML) + MIME allowlist / magic bytes (`MimeTypeValidator`; SVG/JSON/XML out) + plain-text strip on name/metadata/tags  |
| Outbound webhooks / Slack    | `SafeRemoteUrlValidator` on save + delivery; no redirects; `WEBHOOKS_ALLOW_PRIVATE_HOSTS` default `false` (SSRF) — [Outbound webhooks](/docs/webhooks#ssrf-guard)                   |
| Private file bytes           | Local `private_assets` disk (not under `storage:link`); `publicUrl()` null; admin/API auth paths                                                                                    |
| AI collection-import webhook | HMAC over the **raw body** with `AI_WEBHOOK_TOKEN` (`X-AI-Webhook-Signature`); CSRF-excepted machine path; **401** on fail / unset outside `local` (1.2.2) — [AI API](/docs/ai-api) |
| Activity retention           | Default 365 days; `activitylog:clean` on the scheduler                                                                                                                              |

Team chat attachments are **not** covered by the Files extension denylist at upload time (any type by design); see [Chat](/docs/chat#attachments--limits). Saving into Files applies the denylist (including blocked SVG/HTML).

## What operators must provide

| Control                             | Why                                                 |
| ----------------------------------- | --------------------------------------------------- |
| HTTPS (`APP_URL`)                   | Cookies, passkeys, basic confidentiality in transit |
| Unique `APP_KEY`, `APP_DEBUG=false` | Session/crypt integrity; no debug leaks             |
| Encrypted DB volume + backups       | Data at rest if the disk or backup is stolen        |
| Locked-down `.env` / SSH            | Keys and secrets live on the host                   |
| Who gets `can-show-activity-logs`   | Audit UI shows PII and chat previews                |
| Queue + scheduler                   | Retention cleanup and async jobs actually run       |

Full env/deploy steps: [Deployment](/docs/deployment).

## Activity log & chat privacy

- **Activity log** stores plaintext properties (IP, event metadata, settings keys, lean chat `body_preview` ≤ 120 characters). It does **not** encrypt columns.
- **Chat** stores full `body` plaintext in `chat_messages` so hub search, replies, and moderation work. Product docs: [Chat (hub & items)](/docs/chat).
- **Do not** expect application-level encryption of chat or activity as a substitute for host security.

If you need stronger confidentiality (regulated data, E2EE messaging), that is a different product shape — not the Externa default.

## Deploy checklist (host)

- [ ] `APP_ENV=production`, `APP_DEBUG=false`, unique `APP_KEY`
- [ ] HTTPS on the public origin; `APP_URL` matches what browsers use
- [ ] Production database with volume encryption and encrypted backups
- [ ] Strong bootstrap admin credentials; rotate after first login if seeded from env
- [ ] Restrict `can-show-activity-logs` to trusted operators
- [ ] Scheduler running (`activitylog:clean` and other ops jobs) — [Operations](/docs/operations)
- [ ] Public API CORS / keys / IP policy reviewed — [Public CMS API](/docs/public-cms-api)
- [ ] Optional: `two_factor_required` for all users — [Passkeys](/docs/passkeys)

Then run the **application** gates in [Pre-release security checklist](/docs/security-checklist).

## Reporting issues

See [`SECURITY.md`](https://github.com/qiick-io/externa-core/blob/develop/SECURITY.md) in **externa-core**: prefer private disclosure, not a public issue.
