Threat model#
Written for security officers and architects who have to judge whether Whoobe CMS can be run in their environment.
It lists what is protected, who may attack, where the trust boundaries are, what the realistic threats are, which
controls answer them and which risks remain. It is a model of the product as released, not a penetration-test
report: evidence is pointed to (finding ids in findings.md, suites in security-test-suite.md),
and what no test covers is said so. Architecture background: ../architecture/architecture-overview.md.
1. Scope and assumptions#
In scope: the five services, the two browser applications, the compose files and nginx templates, the operator scripts,
and the sites and apps generated from the product.
Assumptions the controls depend on (if one is false, the corresponding risks in section 5 are higher):
- One workspace = one organisation. Whoobe is not a shared multi-tenant platform: different companies or legal entities
get different workspaces; only divisions of the same entity share one, told apart by roles. Inside a workspace every user
with
objects:write can change any website and every administrator is fully trusted. A deployment that puts unrelated
entities in one workspace is outside this model and its risks are not covered here.
- The host and the Docker daemon are trusted; anyone who can run
docker exec or read .env.<slug> has every key.
- Operators follow the install guide: services on loopback behind a TLS-terminating proxy,
TRUST_PROXY=1, fresh
secrets generated by provision-workspace.sh, backups encrypted.
- Authors are semi-trusted: they may be careless or have a stolen account, but are not assumed to be hostile.
Content they write is still treated as untrusted when it is rendered to visitors.
- PostgreSQL and the object store are reachable only from the stack.
2. Assets#
| Asset |
Why it matters |
Where |
| Administrator and editor accounts, sessions, TOTP secrets |
control of the site and its integrations |
users, sessions |
| Third-party credentials: gateway upstreams, mail providers, AI keys, Figma token |
abuse of the customer's accounts and budgets |
database (AES-256-GCM for gateway, mail, migration sources, AI, Figma and push keys; the last three only when INTEGRATION_SECRETS_ENCRYPTION_KEY is set, otherwise clear text - F-055) |
| Encryption keys and shared secrets |
decrypt everything above |
.env.<slug> |
| Published content and drafts |
integrity (defacement), confidentiality (unpublished) |
database |
| Assets, including private files and form uploads |
personal data, documents |
object store |
| Form submissions |
personal data of visitors |
data_objects |
| Availability of the public site and the editor |
business continuity |
all services |
| Reputation of the sending domain |
the mail features can be abused to send |
mail providers |
3. Actors#
| Actor |
Capability assumed |
| Anonymous internet user |
any request to the proxy; automated scanners; scripts at volume |
| Visitor with a hostile intent |
submits forms, uploads files, crafts URLs, tries to read unpublished data |
| Malicious or compromised author (low privilege) |
logs in, edits content, uploads assets, configures gateway bindings |
| Compromised administrator account |
full application access |
| Application holding an API client key (or someone who extracted it from an app) |
calls /client/v1 with that key, at volume, from anywhere its address rule allows |
| Hostile site on the internet |
lures a logged-in user's browser to send requests (CSRF/CORS) |
| Malicious tenant |
owns another workspace on the same host |
| Supply-chain attacker |
a poisoned dependency or base image |
| Insider with database or backup read access |
reads whatever is stored in clear |
4. Attack surface#
Public: /public/* (read API, form submit, upload approval), /preview/*, /dam-asset/*, PUT /public/upload-data/*,
/health, the login and password-reset endpoints, the published sites. Authenticated: 172 permission-guarded and 8
login-only core-api routes, the DAM API, the gateway admin API, the two SPAs. Outbound: gateway upstreams, OpenAPI
import, AI endpoints, mail providers, Figma. Operator: the scripts in scripts/, the compose files.
5. Threats, controls, evidence#
Severity is the residual risk after the controls, for a correctly installed workspace.
5.1 Authentication and session#
| Threat |
Controls |
Evidence |
Residual |
| Password guessing, credential stuffing |
argon2id; 5 failures per address+e-mail and 25 per e-mail lock for 15 minutes; generic error text |
auth suite |
in-memory lockout is per replica and reset by restart; password policy is length 8 only - Medium for internet-facing admin; mitigate with MFA and proxy/IP rules |
| Stolen password |
optional TOTP; code replay blocked; recovery codes hashed; password reset revokes sessions |
mfa suite |
MFA is optional and cannot be enforced - Medium |
| Session theft (XSS, network, database read) |
HttpOnly, SameSite=Lax, Secure over HTTPS; HSTS; 7-day fixed lifetime |
auth, TLS scan |
session ids are stored as hashes and expired sessions are purged (F-056) - Low |
| Account recovery abuse |
reset token hashed, one hour, single use, rate limited |
auth |
depends on the security of the mailbox |
| Default credentials |
no default password: a random one is generated and logged once; compose refuses to start without database and S3 secrets |
unit/compose-hardening, F-043 |
outside Compose the service refuses to start without DB_PASSWORD (F-057) - Low |
5.2 Authorization#
| Threat |
Controls |
Evidence |
Residual |
| Missing authorization on a route |
every route in a contract file checked against 5 identities; drift test fails on new routes |
authz-matrix (900 checks), route-drift |
asset-processor and gateway routes are not in the contract (covered by suites and ZAP) - Low |
| Privilege escalation by a low-privilege author |
publish needs objects:publish (F-035); user and settings management need their own keys; website import cannot publish without it (F-052) |
limits-and-publish, authz-matrix |
function-level permissions exist for the content areas (F-058, fixed: a role limited to pages:edit cannot touch snippets); there is no per-website scoping by design (one workspace per organisation) - Low/Medium |
| IDOR on ids |
UUID ids; malformed ids answer 4xx without SQL text |
public-surface, harness |
|
| Cross-tenant access |
separate databases, buckets, secrets and containers per workspace |
isolation suite on two stacks (14 tests) |
a shared host and Docker socket are a shared fate |
5.3 Injection and content attacks#
| Threat |
Controls |
Evidence |
Residual |
| SQL injection |
Drizzle with bound parameters; public filters evaluated in JavaScript, not SQL; error bodies never carry SQL text (F-018) |
static scan (semgrep), public-surface |
no SQL fuzzing of every authenticated endpoint - Low |
| Stored XSS via rich text, tables, form messages, Markdown |
allow-list sanitizer applied when content is served (no iframes, <style>, id, data-*, handlers, javascript: links); text nodes rendered as text |
browser/xss-sinks (Chromium), SDK SSR tests (F-045) |
Studio and generated Next/Nuxt/mobile sites are not browser-tested for the same payloads - Medium (F-008) |
| Stored XSS via uploaded HTML/SVG |
active types served as attachment, with Content-Security-Policy: sandbox and nosniff (F-022) |
uploads suite |
|
| Code injection into generated sites |
option, locale and font values are validated before being written into generated code (F-039) |
publish suite |
the generated site is not built by the test (next build/nuxt build) - Medium |
| Command injection through operator scripts |
argument validation before any docker, nginx or env-file action; services never import child_process; the generator runs one program with an argument array |
workspace-scripts unit tests |
operators-only threat model |
| Prompt injection in AI features |
provider keys never returned; per-user throttling |
limits-and-publish (AI throttle) |
impact of a malicious prompt on generated content is not tested |
5.4 Files#
| Threat |
Controls |
Evidence |
Residual |
| Malicious upload to the DAM (authors) |
per-file cap, asset count cap, path validation, image pixel cap (100 MP), thumbnails through sharp, private-by-default visibility |
uploads |
content of non-image files is not inspected; no antivirus |
| Visitor uploads in forms |
off by default per website; allow-list of verifiable types; names generated; first bytes checked against the type; pictures decoded and rewritten; size cut while streaming; signed 10-minute single-use ticket; private storage; staging deleted after 2 hours; approval and body rate limited; daily quota per website; internal attach step needs the service token |
form-uploads (26 API tests), form-uploads unit, browser spec |
no antivirus step (planned); text/CSV/zip/Office files are accepted by structure only - Medium where files are opened by staff on workstations |
| Private file read |
session required for non-public assets and the private/ area; 404 (not 403) to anonymous; Cache-Control: private |
uploads, F-024 |
|
| Hotlinking / resource abuse of public assets |
none (a CDN mirror is optional) |
|
Low |
5.5 Outbound requests and integrations#
| Threat |
Controls |
Evidence |
Residual |
| SSRF through gateway upstreams, OpenAPI import, AI endpoints |
safeFetch refuses link-local/metadata, unspecified, multicast, reserved; loopback off in production; private ranges allowed by default (OUTBOUND_ALLOW_PRIVATE=false for internet-facing installs); host allow-list |
ssrf suite |
default allows RFC1918 targets - Medium unless OUTBOUND_ALLOW_PRIVATE=false |
| Credential theft from the gateway |
AES-256-GCM at rest, injected server-side, never returned; vendor presets carry no secrets |
gateway, api-gateway-security guide |
key and process access = all credentials |
| Misuse or theft of an API client key |
keys are random (256 bits), stored as a keyed hash, shown once, revocable, rotatable, optionally limited by address and expiry; each client has explicit scopes (deny by default, matched against the configured route, not the path), a rate limit and a daily quota, and its own log lines; a key is for servers: a request carrying a key and an Origin header (a browser) is refused and logged, and an address list is available; every refusal about what exists answers the same 404; failed attempts lock an address for a minute |
client-keys-scopes, gateway-limiter unit |
Forged, replayed or stolen identity-provider token |
a script can omit Origin, so the address list is the real control; a key placed in an app is exposed (the documentation says not to; identity-provider tokens for browsers and apps are the next step) |
|
|
|
| Mail relay abuse via forms |
notification only for a block an author saved; single plain recipient; 3 mails per hour per address; honeypot; form rate limit (F-053) |
form-notify unit, API tests |
|
| Abuse of the AI budget |
per-user rate limit, keys per user |
|
cost control is the provider's |
5.6 Network, browser and infrastructure#
| Threat |
Controls |
Evidence |
Residual |
| CORS reflection / CSRF |
credentialed CORS only for an allow-list; SameSite=Lax; JSON-only mutations; no state-changing GET |
cors-csrf-headers (F-011) |
no CSRF token (see auth section 7) - Low/Medium depending on domain layout |
| Clickjacking |
frame-ancestors 'none' (dashboard) / configured ancestors (editor) |
headers test, ZAP baseline |
no full CSP on the SPAs (F-014) - Low/Medium: it matters most where many people with different trust levels use the editor; under the one-organisation model the editors are the organisation's own staff |
| TLS downgrade, weak ciphers |
TLS 1.2/1.3 only, AEAD ciphers, HSTS one year |
security:tls on the template (F-049) |
not yet run against a live host |
| Exposed database / object store |
published on 127.0.0.1 only; credentials mandatory |
compose test (F-043) |
|
| Container compromise |
unprivileged users, pinned base images by digest, production images without package managers (F-020, F-004) |
security:images (trivy) |
the main compose file sets no read-only filesystem, capability drop or resource limits (the sandbox overlay docker-compose.sandbox.yml does, and can be applied to production); no seccomp profile - Low |
| Denial of service |
per-address limits on public routes and logins, body caps, asset and pixel caps |
limits-and-publish, rate-limit tests |
no load testing; single instance per service; limiters are per process (see capacity) - Medium for a public site |
| Log injection / secret leakage in logs |
redaction of auth headers, cookies and common secret keys; generic 5xx bodies |
log review (F-018) |
form values and e-mail addresses can appear in debug logs |
5.7 Supply chain and build#
| Threat |
Controls |
Evidence |
Residual |
| Vulnerable or malicious dependency |
lockfile, pnpm install --frozen-lockfile, pnpm audit baseline with review dates, Dependabot, gitleaks, semgrep |
security:static |
two accepted advisories (xlsx, braces); no SBOM is published and releases are not signed - see supply chain |
| Poisoned base image |
images pinned by digest; trivy scan of every service image in CI |
security:images |
digests lag behind Debian fixes (patched by explicit apt-get/apk upgrade lines) |
| Secrets in the repository |
gitleaks in CI; .env.* ignored; images exclude env files and logs (F-020) |
static scan |
F-001: the secrets of an old env file were purged from the history; no running instance uses them |
6. Top residual risks (what we would tell a risk committee)#
- Third-party credentials: encrypted at rest only when
INTEGRATION_SECRETS_ENCRYPTION_KEY is set (F-055, fixed; an installation that never sets it keeps clear text and a log warning). The push provider key used to be served by an unauthenticated route (F-059, fixed): after upgrading, rotate any push key that was saved on an internet-reachable installation.
- Authorization model is functional, not per site - roles limit what a person can do (pages, snippets, themes ...), workspace-wide;
they do not fence one division's websites from another's. Use separate workspaces (export/import moves a site) for that.
- No MFA enforcement and no SSO; password policy is length only.
- No full CSP on the admin and editor applications; CSRF relies on SameSite/CORS alone.
- Files from visitors are not scanned for malware (planned as an optional step).
- Single instance, in-memory throttling, no load test - a determined volumetric attack is a proxy/CDN problem.
- Generated sites and the mobile app are not browser-tested for the XSS payloads that the editor is tested with.
- Key rotation is manual; no audit log of administrative actions beyond mail log, workflow transitions and
content versions.
7. How this model is kept honest#
New surface gets a section here and a test before release (the form-upload feature did: plan, 26 API tests, unit
tests, browser spec). Findings go to findings.md with the next id; the
status summary lists what is open. A reviewer who disagrees with a "Residual" cell should open a
finding rather than edit the cell.