> drobek docs: Security — https://www.drobek.app/docs/security (drobek v0.5.2). For agents: https://drobek.app/llms.txt

# Security

## Reporting a vulnerability

Report vulnerabilities in drobek privately through GitHub's **private
vulnerability reporting**:
<https://github.com/freema/drobek/security/advisories/new> (Security tab →
"Report a vulnerability"). Please do not open a public issue for a security
problem. Include the affected version (`GET /api/version` → `{ sha, version }`)
and the steps to reproduce; a proof of concept against your own instance is
welcome, against someone else's is not.

drobek is maintained by one person; expect an acknowledgement within a few
days. Fixes ship as a new release (`vX.Y.Z`, see
[`SELF-HOSTING.md`](https://www.drobek.app/docs/self-hosting) → Upgrades) with a `CHANGELOG.md`
entry.

**Not a vulnerability report?**

- Abuse of an app **hosted on** a drobek instance (phishing, malware, spam):
  use that instance's report form — every app host links it at
  `/.well-known/drobek-report`. It reaches the instance's operator, not the
  drobek project.
- A self-hosted instance is run by its operator (the addresses in its
  `SUPERADMIN_EMAIL`), not by the drobek project.

## Threat model

The design goal that shapes everything below: **drobek never executes app
code.** It compiles what an agent writes and serves the output to browsers;
an app's backend is platform modules the operator installed. That removes
the whole class of sandbox problems (escape, egress control, supply chain of
app dependencies on the server, secrets in a sandbox's environment). What
remains is a web platform that hosts untrusted front-end code next to a
dashboard that holds real credentials.

Actors: the **operator** (runs the instance, installs modules — trusted);
**workspace members** (admin / editor / viewer); their **agents** (act with
the member's grant; everything they read from apps can be hostile);
**app authors** (any member with the editor role — untrusted code);
**end users** of apps and **anonymous visitors** (untrusted input).

| Area | Threat | What drobek does (server-enforced) |
| --- | --- | --- |
| **Origin split** | App JavaScript steals the dashboard session or OAuth tokens; apps attack each other | Apps are served only on the apps origin (`APPS_DOMAIN`, a separate registrable domain recommended), each app on its **own host** = own origin (`<slug>`, `<slug>--preview`, `<slug>--v<N>`). The dashboard host never serves an app file. Dashboard cookies are `__Host-` (host-only, `Secure`, path `/`) in production and never read on the apps origin; the serving handler reads only its own app-access cookie. App CSP: scripts and `connect-src` only `'self'` + esm.sh, `media-src 'self' blob: https:` (media runs no script), `frame-src` only the curated video embeds (`www.youtube-nocookie.com`, `www.youtube.com`, `player.vimeo.com`, `drive.google.com`) plus the operator's `APP_FRAME_SRC_EXTRA` (bare `https://host[:port]` origins only — a wildcard, a path, `http:` or a quote stops the server at start, so the value can never widen `frame-src` to the web or inject a directive), `object-src 'none'`, `base-uri 'self'`, `frame-ancestors` = the dashboard origin (owner override per app adds origins; the operator's `GALLERY_FRAME_ANCESTORS` only on the production host of a gallery-listed app, see **Public gallery**), `form-action 'self'`; `nosniff`, `Referrer-Policy: no-referrer`, `noindex` on preview/version hosts. A module response can only add a stricter CSP as a second policy: uploaded files (the `files` module, types sniffed from the bytes, SVG/CSV as attachments) also carry `Content-Security-Policy: sandbox`, except PDF. |
| **App thumbnails in the dashboard** | Framing an app turns the dashboard into a clickjacking surface or lets app code reach the dashboard | The workspace app list is the only place the dashboard frames an app, and the app hosts allow exactly one extra ancestor for it: the dashboard origin (`PUBLIC_APP_URL`), no other site (the one exception is the operator's own gallery website on the production host of an app listed in the public gallery — see **Public gallery**). The iframe is `sandbox="allow-scripts allow-same-origin"`: the app keeps its own origin, which by the origin split is never the dashboard's, so it cannot touch the dashboard's DOM or cookies; without `allow-top-navigation`, `allow-popups`, `allow-forms` or `allow-modals` it cannot navigate the dashboard, open windows, submit forms or show dialogs. It is `credentialless` where the browser supports it (a throwaway cookie and storage partition, so the preview never runs as the viewer's end-user session), lazily loaded, sent no referrer, and inert (`pointer-events: none`, no focus, `aria-hidden`): nothing in it can be clicked. A password-gated, taken-down, inactive or never-compiled app gets a placeholder tile instead. Like any visit, a thumbnail load runs the app's start-up code and counts in its request stats. |
| **App assets and upload URLs** | A big binary is pushed through the model; an upload URL is replayed or guessed; an HTML page is smuggled in as a "video" and runs on the app's origin; a disk fills up | Binaries never pass through MCP: `create_asset_upload` returns a URL, and the file goes from the agent's sandbox (or the user's browser) straight to drobek. The URL is a capability — 32 random bytes, only its sha256 stored (Redis, 30 minutes), bound to one app, one path, the exact size, the type family and the user who asked (the upload is audited as theirs), and **single use**: the PUT consumes it before reading a byte. It needs the `write` scope and the editor role to obtain, and the PUT re-checks that the user it was issued for is still an editor of the app (a member removed since cannot finish an upload with an earlier link); the token is never logged and never shown on the upload page. The type is sniffed from the bytes (png, jpeg, gif, webp, avif, ico, svg, mp4, webm, m4a, mp3, ogg, wav, woff, woff2 — HTML, scripts and anything else are refused whatever the name) and served with `nosniff`; SVG goes out as an attachment with a second CSP `sandbox`. The body is streamed to disk and cut off at `APP_ASSET_MAX_BYTES` or past the declared size; `APP_ASSETS_QUOTA` is re-checked under a per-app lock; `APP_ASSET_UPLOADS_PER_HOUR` caps URLs per app. Asset paths are validated segments (no `..`, no hidden or `__drobek` segment); the disk path is the bytes' sha256 (a random key for older files), never the name. Uploads and deletes change only the draft the preview serves: the production host and custom domains serve the set the last `publish` froze, so the `write` scope alone never changes what a published URL shows. Takedown, the password gate and "not published" apply to assets like to any file. |
| **CSRF on the dashboard** | A page (or an app) makes the member's browser mutate the dashboard | Every mutating dashboard request passes the origin check (`@drobek/auth` `origin-check.ts`): an app host, `null` or a foreign `Origin` → 403. The module confirm/reject API requires an `Origin` equal to the dashboard origin. |
| **End-user sessions** | An app rides another app's session; tokens in `localStorage` | No tokens in JavaScript: the end-user session is an HttpOnly, host-only cookie on the app's own host (`__Host-drobek_eu`), resolved by core into `ctx.principal`. Mutating module calls need the app's own origin and `X-Drobek-SDK: 1` (`csrf_rejected`). The owner can end every session of an app at once (per-app epoch) or block one user (effective on the next request). |
| **End-user sign-in providers: state and handoff** | A forged or replayed IdP callback signs someone in; a callback for one app lands a session on another; the IdP answer is swapped mid-flight | The state sent to the IdP is `<random id>.<HMAC>` — HMAC-SHA256 under a key derived (HKDF) from `DROBEK_MASTER_KEY`, binding the id to the app, the app host, the provider and the nonce; its record (with the PKCE S256 verifier) is in Redis for 10 minutes and consumed by the first callback, valid or not. The app comes only from the state, never from the request. The callback hands the result to the app host as a one-time handoff code (32 random bytes, 60 s, GETDEL) that works only on the app host named in the state. A provider must return a verified e-mail address (`email_not_verified` otherwise); the app's allowlist decides as for the e-mail code, and account linking happens only by a verified address to a user not linked to another identity. Callbacks are rate-limited per client IP (`AUTH_PROVIDER_CALLBACKS_PER_IP_15MIN`). Provider errors are logged by error name only. |
| **End-user sign-in providers: login CSRF, open redirect, cookie scope** | An attacker hands a victim a callback / complete link so the victim is signed in as the attacker; `return_to` sends the browser off-site; the provider flow leaks a session across hosts | `begin` (a mutating call: app origin + `X-Drobek-SDK`) sets a flow cookie — `__Host-drobek_eu_flow` (Secure, `Path=/`, no `Domain`), HttpOnly, SameSite=Lax, 10 minutes — whose SHA-256 is bound into the state and the handoff; `complete` refuses a code without the matching cookie, so a link started in another browser signs nobody in; another app under `APPS_DOMAIN` cannot plant that cookie (a browser refuses a `__Host-` cookie with a `Domain`, and `complete` reads no other name — a Playwright spec on the https e2e stack proves it). On plain-http dev the cookie has no prefix and a sibling host can set it. `return_to` must be a path on the app host (one leading `/`, no `//`, `/\`, scheme or control characters), checked at `begin` and again at `complete`. The session cookie is set by the app host itself (host-only `__Host-drobek_eu`); the dashboard host's callback never reads or writes the dashboard session, and the dashboard's Origin check exempts only `/__drobek/auth/callback` (the IdP's form POST). Enabling a provider, or changing whose accounts it admits (its identity fields), waits for the owner's confirmation; provider secrets are dashboard-only module secrets. |
| **End-user sign-in providers: identity confusion** | The owner points a provider at another issuer (or the operator changes its `AUTH_<ID>_*` env fallback) and a different person with the same `subject` there inherits an existing user and everything keyed by its id | A person is (provider, **issuer**, subject) (`mod_auth_identities`, OIDC Core §5.7); the provider must return the issuer it verified, or the sign-in fails. The same subject from another issuer is a new user; an address held by an account linked to another identity is refused (`account_linked`), never re-linked by the address — unless the owner confirmed `relinkByEmail` for an issuer migration (audited `auth.identity_relinked`). The provider's connection (its identity fields + non-secret `AUTH_<ID>_*` variables) is bound into the state, the handoff and the session: a change refuses sign-ins in flight and ends that provider's sessions. Identities linked before issuers were recorded are claimed once, only by the same subject asserting the user's own address. |
| **Access rules** | An app or agent reads or writes data it should not; the dashboard session used as a confused deputy | One rules evaluator for every module (`public` / `user` / `owner` / `admin` per operation); the principal comes only from the end-user session; `app_id` and the record owner are set by the server. Quotas and rate limits hold regardless of the rules. An agent's change that opens access (a rule to `public`, a new recipient, an upstream for an app) is stored **pending** until the owner confirms it in the dashboard; proxy changes need a **workspace admin**. |
| **Secrets** | A secret passes through the LLM, a log or a response | Secret values are entered only in the dashboard, write-only, and stored AES-256-GCM envelope-encrypted under `DROBEK_MASTER_KEY`; MCP and the dashboard show names and `hasSecret` only. Every `write_files` and `configure_module` payload is scanned for credential patterns and refused, nothing stored (`secret_in_source` for files, `invalid_params` for a module config). The proxy injects the secret server-side. Beacon and log entries are redacted. The server refuses to start with a missing, all-zero or `change-me` master key or a weak `TLS_ASK_TOKEN`. The operator's `RESEND_API_KEY` (with `EMAIL_TRANSPORT=resend`) goes only into the `Authorization` header of the Resend call; a failed send reports the HTTP status and Resend's error name, never the key or Resend's response text. |
| **Compilation** | Escaping the virtual filesystem (`import '/etc/passwd'`), network at build time, DoS by input size | esbuild runs over an in-memory file map; the resolver plugin never touches the disk; bare imports resolve only through the app's import map to `https://` URLs marked external (the server never fetches them). Limits: 200 files, 512 KiB per file, 5 MiB per version, import depth 50, 10 s per build (cancelled), 4 concurrent builds + a bounded queue (`busy`). The output is never executed on the server. |
| **Prompt injection through tool output** | A file, a record or a log line carries instructions for the agent | `read_file`, `query_data` and `get_logs` return their content inside `<untrusted-app-file>` / `<untrusted-app-data>` / `<untrusted-app-logs>` envelopes whose closing marker carries a random per-response nonce, after a line stating it is data; these three tools answer that text only, with no `structuredContent` that a client could hand to the model past the envelope; the briefing and skills repeat the rule. The beacon accepts only same-origin reports, caps them at 8 KiB and rate-limits per app and per IP. |
| **MCP / OAuth** | Token replay, wrong audience, DCR floods, phishing clients | Tokens are bound to the user and to the audience = the MCP endpoint (RFC 8707); membership is checked on every call (a non-member gets `not_found`). PKCE S256, exact redirect URI match, RFC 9207 `iss`, refresh rotation with reuse detection (a reused refresh token burns the lineage). DCR: 10 registrations per IP per hour, at most `OAUTH_DCR_MAX_UNUSED_CLIENTS` clients that never got consent. Client ID Metadata Documents are fetched through the SSRF guard (https, ≤ 64 KiB, 5 s, no redirects, cached). `drk_` API keys are stored as hashes, shown once, revocable; OAuth connections are revocable at `/me/connections`. `/mcp` bodies are capped at 512 KiB. |
| **Authorization-code replay / PKCE brute force** | A stolen authorization code is replayed, or its `code_verifier` guessed over many exchanges | Codes are single-use, live 5 minutes and are stored as hashes. The first exchange of a code consumes it whether it succeeds or fails (wrong verifier, redirect URI or client, expired), so a verifier gets one try. Presenting a consumed code again revokes the refresh-token lineage it was exchanged for and the grant's access tokens (the refresh-reuse mechanism). |
| **Dashboard sign-in** | Brute-forcing e-mailed codes, mail bombing | Five guesses per code (atomic counter, then the code is destroyed), a per-IP verify limit, per-IP / per-address send limits with a cooldown, a global hourly brake and a kill switch (`OTP_*`). Without a trustworthy client IP the per-IP buckets are skipped rather than shared. |
| **SSRF** | The proxy module or the CIMD fetch reaches the internal network or cloud metadata | DNS resolved once and the connection pinned to that IP; private and reserved ranges blocked (unless the operator lists a hostname in `PROXY_ALLOWED_HOSTS`); ports 80/443 only (`PROXY_ALLOWED_PORTS`, checked at registration and at connect); no redirects followed; 20 s deadline, 5 MiB response cap (also for a decoded gzip/br body); at most `PROXY_MAX_CONCURRENT` calls in flight (`PROXY_MAX_CONCURRENT_PER_APP` per app). The forwarded path is checked on its fully decoded form — no `..`, encoded `/`, `\` or control character behind any depth of percent-encoding — and must stay under the upstream's base path and allowed prefixes; only allow-listed response headers (no `Set-Cookie`, CORS grants, `Clear-Site-Data`, `Link`, HSTS or absolute `Location`) reach the app origin. Upstreams are registered by workspace admins, and an app may use one only after an admin confirmed that upstream record (`allowed_app_ids` + the record id in the app's config). |
| **Spam** | Forms, sign-in codes or notifications used to send mail through the instance | Apps can never mail an arbitrary address: notifications go only to the app's owners, sign-in codes only to allowed addresses. Forms have a honeypot, an HMAC time token and per-IP / per-app limits. The operator-wide mail guard splits an hourly budget into sign-in codes and notifications with a per-app share of each; a class past its budget pauses (the other keeps working) and logs an `email_global_pause` ALERT line. |
| **Resource exhaustion** | Many versions, big apps, floods of unknown hosts or beacons | Compile limits; a 256 MiB byte LRU for serving; module quotas and rate limits (`DATA_*`, `FILES_*`, `FORMS_*`, `PROXY_*`, `AUTH_*`); asset size, quota and upload-URL limits (`APP_ASSET_*`, `APP_ASSETS_QUOTA`); per-app and per-IP beacon limits; unknown app hosts are negatively cached for 30 s and limited per client IP (`APPS_UNKNOWN_HOST_*` → 429). An answer sent before a request body fully arrived (a 413 mid-upload) closes the connection instead of draining the rest; a module request body must arrive within `APPS_MODULE_BODY_TIMEOUT_MS` (408). The files sweep removes a deleted app's uploads, unreferenced blobs and stale temp uploads (`FILES_SWEEP_*`). |
| **TLS issuance abuse** | Arbitrary SNI names make Caddy order certificates | On-demand issuance always asks drobek first; the `ask` endpoint answers only on the internal address with the token, and says 200 only for hosts of live apps and verified custom domains. Custom domains need a TXT proof and are re-checked daily. |
| **Client IP spoofing** | Forged `X-Forwarded-For` / `X-Real-IP` defeats per-IP limits; clients without a resolvable IP lock each other out | Behind the bundled Caddy (`TRUST_PROXY=x-real-ip`) only the `X-Real-IP` Caddy sets from the TCP peer is trusted. Every per-IP limit (sign-in, DCR, the password gate, module `per: 'ip'` routes, public proxy upstreams, beacons, abuse reports) needs a resolved client IP: a request without one gets no per-IP bucket — never a shared one — and a warning is logged once per bucket; the per-code, per-address, per-app and per-user limits still apply, and the password gate keeps a per-app cap on attempts. |
| **Abuse: phishing / malware hosting** | Someone publishes a fake bank login | Apps live on the apps origin, not the dashboard's. Every app host answers `/.well-known/drobek-report` and sends `X-Drobek-App: <slug>`; the public report form is rate-limited and e-mails the super-admins. A super-admin **takedown** unpublishes and locks the app: 451 on every host, every agent write refused (`app_locked_by_admin`), the owners told. A publish heuristic (password field + a brand word) files a report without blocking. |
| **Public gallery** | An agent or a stranger exposes someone's app publicly; the list leaks who owns what; scraping | Off unless the operator sets `GALLERY_ENABLED`. Only an editor+ of the app's workspace lists it, and only a published app; over MCP the tool needs the `publish` scope and `user_confirmed: true`, which the tool description allows only after the user's explicit yes. `GET /api/public/gallery` returns name, description (plain text, control characters stripped, ≤ 160 characters), production URL, publish time and configured module names (`modules`, non-empty saved configs only; no config values or pending proposals) — never an e-mail, workspace, user or app id (the cursor carries the publish time and the slug only). It filters at query time, so unpublish, takedown, delete or a super-admin's hide remove an entry with the next request; read-only, CORS `*`, per-IP limit, 60 s public cache; search, sort and page parameters are bound query parameters (search ignores case and combining accents using PostgreSQL normalization (no extension required); the search text is a LIKE pattern with `%` `_` `\` escaped). No screenshots: the server never runs app code. **Live previews** (clickjacking): with `GALLERY_FRAME_ANCESTORS` set (bare http(s) origins only — a wildcard, a path or a quote stops the server at start), those origins join `frame-ancestors` only on the production host and custom domains of an app the gallery shows right now (the same conditions as the list; a listing change, hide, unpublish, takedown, password gate or delete busts the serve cache, so the permission follows within the next request, at most the 60 s cache TTL), never on its preview or version hosts and never while `GALLERY_ENABLED` is off. Acceptable because the framing page is the operator's own website, which already links the same public page, and the owner chose to show it publicly; the gallery frames it scaled down, sandboxed and non-interactive (no clicks reach it), so no action can be tricked out of a visitor. An owner who does not want the preview unlists the app. **Likes and opens** (ballot stuffing, tracking, open redirect): a like needs a signed-in drobek account, is one row per account and app (primary key), is changed only by a same-origin POST on the dashboard page (the session cookie's SameSite plus the dashboard's cross-origin check, so the gallery website cannot like on a visitor's behalf) and is limited per account per hour; the API shows only the count. An open is a per-app, per-UTC-day counter raised by `/gallery/open/<slug>` for a GET that is not a prefetch or prerender, at most `GALLERY_OPENS_PER_IP_HOUR` per client IP (the limiter's Redis key expires within the hour); no IP, cookie or referrer is stored with it, and the redirect goes only to the entry's own production URL (`Referrer-Policy: no-referrer`, `noindex`). The like page follows `back` only to an origin in `GALLERY_FRAME_ANCESTORS`. Counts are indicative, not fraud-proof. |
| **Open sign-up used to host content** | Anyone signs up on a public server and publishes phishing or spam | Sign-up, building and previews stay open (previews are `noindex`); publishing is decided in `publish()` itself, so no surface (dashboard, MCP, rollback) skips it. With `PUBLISH_APPROVAL=open` the operator watches instead of gating: `PUBLISH_NOTIFY=first` / `every` e-mails them each new publish (one per app per hour) with links to take the app down or **block** the workspace, which refuses its publishes in every mode (`publish_blocked`) until a super-admin unblocks it. With `approval` a workspace publishes only after a super-admin allowed it. Allow / revoke / block / unblock are super-admin only (dashboard `/admin/publishing` with the origin check; the MCP tool is registered only for a super-admin and needs `user_confirmed: true`), audited; a blocked workspace's members are e-mailed. Approval-request e-mails go only to the operator, one per workspace per 24 h, and a blocked workspace sends none, so a user cannot use them to spam. Abuse reports reach every super-admin and `OPERATOR_EMAIL`. Blocking and revoking leave live apps serving — the takedown is the tool for content that must go. |
| **Third-party modules** | A malicious or tampered module | A module is server code the OPERATOR chose — trusted like Express or drizzle; no in-process sandbox is claimed (it has `ctx.db` and all of Node). App authors and agents can never install, pick or enable one: `write_files` writes app files, `configure_module` configures an installed module; the server runs only code from `DROBEK_MODULES` / `DROBEK_MODULES_DIR`, never app files. Enforced per request: `ctx.secrets` sees only the module's own secrets of that app; principal, config, rate limits, audit and e-mail are scoped to (app, module); an opt-in module switched off for the app's workspace answers `module_not_enabled` AND contributes nothing to the slots of the modules that are on (their routes, create / publish hooks, end-user sessions and the sign-in callback see only the workspace's enabled modules; only `onAppDelete` gets every contribution, for cleanup). Checked at start for a module from `DROBEK_MODULES_DIR`: listed in `modules.lock.json` with the sha512 integrity of its whole install directory (else the start is refused), installed outside the runtime without install scripts, given the server's own `@drobek/*` / `zod` / `drizzle-orm`, a `contract` range the server satisfies, migrations only on `mod_<name>[_*]` tables with foreign keys only to `apps(id)` / `workspaces(id)` and no DROP/ALTER of other tables. Install only modules you trust. |
| **Audit** | Covering tracks | The audit log is append-only; the only deletion is the age-based retention prune (`AUDIT_RETENTION_DAYS`, default 365), never exposed over an API. |

### Status of the 2026-07 review (PHY-76)

The pre-rebuild review is archived at
[`archive/threat-model-phy-76.md`](https://github.com/freema/drobek/blob/v0.5.2/docs/archive/threat-model-phy-76.md). Its
findings today:

| Finding | Status |
| --- | --- |
| HIGH — OTP code lockout non-atomic, no verify-side limit | fixed: atomic per-code counter (5 guesses) + per-IP verify limit |
| HIGH — apps same-origin with the dashboard | fixed: the apps origin, one host per app, `__Host-` dashboard cookies, origin check |
| MEDIUM — confused deputy through the dashboard session | fixed: module routes see only the end-user principal; the dashboard session never exists on app hosts |
| MEDIUM — spoofable `X-Forwarded-For` | fixed: `TRUST_PROXY=x-real-ip` behind Caddy; no shared bucket for unknown IPs |
| MEDIUM — CSV formula injection | fixed: every CSV export goes through one writer that neutralizes formula triggers (`@drobek/core` `csv.ts`) |
| LOW — unlimited DCR | fixed: per-IP limit + unused-client cap; CIMD preferred |
| LOW — placeholder secrets accepted | fixed: fail-closed start |
| LOW — app-access cookie survives a password change | **open**: the unlock cookie is a stateless HMAC token valid for 12 h; changing an app's password does not revoke cookies already issued |
| LOW — proxy to any port | fixed: ports 80/443 only |
| LOW — beacon poisoning | fixed: same-origin beacon on the app's own host, size caps, per-app and per-IP limits |

### Known limitations

Stated plainly so operators can plan around them:

- **No per-token rate limit on `/mcp` itself.** Calls are bounded by the body
  cap, the compile queue (`busy`), the single-writer lease and the per-feature
  limits.
- **App-access cookies** of password-protected apps stay valid for up to 12 h
  after a password change (see the table above).
- **Client IP behind another proxy:** with a CDN, load balancer or host proxy
  in front of Caddy, every client shares that proxy's IP for the per-IP
  limits; IPv6 clients on a Docker host without IPv6 can all appear as the
  bridge gateway.
- **Sessions are in Redis**, which `task backup` does not include: a restore
  on a new machine signs everyone out (API keys and OAuth clients survive).
- The operator of an instance can read everything stored on it (records,
  submissions, uploads); secrets are encrypted at rest but the process can
  decrypt them. Choose your host accordingly.
