Security
On this page
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 → 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 (the preview host's connect-src also the dashboard's /api/screenshots/ path, for a screenshot the agent asked for), 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: strict-origin-when-cross-origin (a third party an app loads from sees only the app's origin, never its path or query — map tile servers such as OpenStreetMap's refuse requests without a Referer), 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, but not as a page view in Analytics. |
| 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), in the dashboard or over MCP (sign_out_end_users only after the user's explicit yes). |
| 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 and expires the cookie when it signs in, 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. |
| The oidc provider: forged or misdirected ID tokens | A token signed with alg: none or HS256 keyed with the public key, one issued to another client, a replayed or stale one, or an IdP answer swapped for another issuer's signs someone in |
The ID token is verified with node:crypto against the issuer's published keys (jwks_uri, cached 1 h, an unknown kid refetches at most once a minute): only RS256, ES256 or PS256 the IdP advertises (never none or HS*, no crit), iss = the configured issuer, which must equal the discovery document's exactly, aud naming the client (azp = the client with several audiences), exp in the future, iat within 5 minutes, nonce = the sign-in's; the code exchange carries the PKCE verifier; an iss in the answer must name the issuer (RFC 9207); userinfo counts only with the ID token's sub. An address is verified only by email_verified or trustEmail; trustEmail and claims (which claim holds the address) are identity fields like issuer and clientId, so turning them on or changing them waits for the owner's confirmation. Failures are logged with a reason (addresses masked), never the secret, code or tokens. |
| 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. |
| Live data subscriptions | A subscriber receives records a list would not return (another user's records under owner, records after the rule was tightened or the user signed out); a page holds many streams open; a stopped server waits on them |
GET /__drobek/v1/data/<collection>/events is authorized like a list when it opens AND for every event: the collection's read rule is read again from the stored config per event, the caller's session at most every 10 s and on every 25 s heartbeat; under an owner-only admission only events of records whose stored _owner is the caller are sent (a delete carries only the id); a visitor never gets _owner; a rule that no longer admits the caller, a removed collection or an app deleted or taken down ends the stream with an error event, and the SDK's reconnect gets the refusal a new request would. The event id is a Redis stream id (a timestamp): a new stream starts at the current time, so it says nothing about other users' writes. Open streams are capped per app (DATA_SUBSCRIBE_MAX_PER_APP) and per signed-in user or visitor IP (DATA_SUBSCRIBE_MAX_PER_CALLER), live at most DATA_SUBSCRIBE_MAX_MS, end when 1 MiB waits unread, and a graceful stop ends them first (then 503). Caddy neither buffers nor compresses text/event-stream (caddy.test.ts) |
| Secrets | A secret passes through the LLM, a log or a response; a leaked master key keeps opening them | Secret values are entered only in the dashboard, write-only, and stored AES-256-GCM envelope-encrypted under DROBEK_MASTER_KEY; the key can be rotated — DROBEK_MASTER_KEY_PREVIOUS only decrypts, task selfhost:rekey re-wraps every stored secret's own key under the new one without decrypting a value, and a production start refuses a database holding secrets that neither key opens; MCP and the dashboard show names and hasSecret only; over MCP a module secret can only be removed (remove_module_secret, editor+, after the user's explicit yes, audited module.secret_remove), never set or read. An app's password is set only on the dashboard's Settings tab (scrypt-hashed): over MCP set_visibility re-applies a stored one or answers password_not_set with the Settings link. 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. |
| Incoming webhooks | A forged or replayed delivery writes records into an app; a flood fills the log or the collection; a delivery body or secret leaks through a log; the password gate is used to hide a writable endpoint | The webhooks module verifies every delivery over the raw body before it is parsed, in constant time (HMAC-SHA256 hex or base64, the timestamp-signing scheme with a 300 s tolerance, the code-hosting sha256= scheme; a shared token is compared as SHA-256 digests); weakening an endpoint to none-with-token, a new endpoint or a changed collection waits for the owner's confirmation. The secret is set only in the dashboard (never over MCP) and a delivery without it is refused (503), not accepted unsigned. Sender event ids are deduplicated for 7 days in one atomic insert; hmac-sha256 and the code-hosting scheme sign no timestamp, so a captured delivery replayed under a new event id is stored again — the schemes that sign a timestamp are the replay-safe choice. A none-with-token token sent as ?token= sits in the URL, so the dashboard calls it the weaker choice. Each endpoint is inbound only, POST only and stores into one declared collection through the data module (its schema and quotas apply); the body is capped (WEBHOOKS_MAX_BODY_BYTES, at most 1 MiB), deliveries per app are limited (WEBHOOKS_PER_APP_PER_MINUTE, one log row per window of refusals), and the delivery log keeps 30 days / 1000 rows per app. The log and the server log hold endpoint, status, size and reason — never the body, a header or the secret. Only routes declared passwordGate: 'skip' (this one) pass a password-protected app's gate, never a GET or a file. The module is inbound only: it fetches nothing, so no outbound request (SSRF) is involved. |
| 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, get_logs and get_analytics return their content inside <untrusted-app-file> / <untrusted-app-search> / <untrusted-app-data> / <untrusted-app-logs> / <untrusted-app-analytics> envelopes whose closing marker carries a random per-response nonce, after a line stating it is data (the page paths and referrer hosts of get_analytics are chosen by visitors); these four 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 owner's lists — list_form_submissions, list_end_users, list_uploads and list_activity — answer the same way (<untrusted-form-submissions> / <untrusted-end-users> / <untrusted-uploads> / <untrusted-activity>, at most 100 entries and 64 KiB per call), so end users' personal data — their addresses, what they typed into a form, the names of their files — reaches the agent only inside the envelope; the answers of the changing tools (role, block, delete) carry ids, roles and states, never an address, and audit context is redacted as on the Activity page. The beacon accepts only same-origin reports, caps them at 8 KiB and rate-limits per app and per IP. |
| Preview feedback | App code on the preview forges or reads notes, phishes a member through the note window, or plants instructions for the agent; the widget leaks onto the public site | The widget runs on the app's origin like the app, so it gets no credential: it makes no request and reads no cookie, it only opens the dashboard's /feedback/new with noopener and puts the version, path, spot and selector in the query — never a note's text, which only the signed-in member types on the dashboard origin. That page needs a session and membership in the app's workspace (anything else answers the same 404), passes the origin check on POST, cannot be framed (frame-ancestors 'none', X-Frame-Options: DENY), cleans and caps every value it takes, and limits notes per member per hour and open notes per app (FEEDBACK_PER_USER_HOUR, FEEDBACK_MAX_OPEN_PER_APP). A note's screenshot is untrusted input decoded on the server: its type is sniffed from the bytes (PNG, JPEG or WebP only, whatever the name or Content-Type says) and must match what sharp (libvips) decodes, every other libvips loader is blocked for the process, the pixel count is capped before decoding (40 MP), only the first frame is read, and the pixels are re-encoded to a fresh WebP (longer side 1600 px, ≤ SCREENSHOT_MAX_BYTES) — EXIF (location, camera, names), XMP, ICC and text chunks are dropped and the uploaded file is never stored or served. What is stored is the WebP in blobs with its sha256, size and dimensions on the note, and an audit flag that the note has one. The full image is served only to members of the app's workspace on the dashboard origin with nosniff, default-src 'none'; sandbox and Cross-Origin-Resource-Policy: same-origin; get_feedback_screenshot sends a JPEG re-encoding (≤ MCP_IMAGE_MAX_BYTES) between the text blocks of an untrusted envelope, because a screenshot can show text that tries to instruct the agent. The note page's body limit is SCREENSHOT_MAX_BYTES plus 64 KiB, an app holds at most FEEDBACK_SCREENSHOTS_MAX_PER_APP screenshots, deleting a note releases its screenshot, and a resolved note drops it after FEEDBACK_SCREENSHOT_RETENTION_DAYS. Notes reach the agent only inside the list_feedback untrusted envelope; resolving needs editor+, deleting the author or a workspace admin with the user's yes; every change is audited. The widget is served and injected only on the preview and version hosts, never on production or a custom domain, and "feedback": false turns it off. |
| Screenshots for the agent | App code or a stranger answers a request with a forged image or plants instructions for the agent in it; a page of another site uploads; a token is reused or outlives the request; the bar appears on the public site or pops up endlessly | request_screenshot needs editor+ and is capped per app (SCREENSHOT_REQUESTS_PER_APP_HOUR). The request reaches only the preview host's event stream (<slug>--preview, behind the app's password gate): production, version hosts and custom domains never show the bar, and "screenshots": false turns it off. The agent's note is shown as plain text (textContent, ≤ 200 characters) inside the preview script's shadow root. Each request carries a one-time 32-byte token that expires with the request (SCREENSHOT_REQUEST_TTL_SEC, 10 minutes): the answer goes to POST /api/screenshots/<request_id> on the dashboard origin with the token in the body and no credentials (fetch with credentials: "omit"), so no session or cookie is involved. The endpoint answers CORS only for the app's own hosts under APPS_DOMAIN and its verified custom domains and refuses any other Origin before reading the body; the token is compared in constant time and used up by an atomic claim (a second answer, a wrong token or an expired request is refused), and the body is capped at SCREENSHOT_MAX_BYTES plus 64 KiB, in drobek and in the generated Caddyfile. Any script on the preview's origin can read the token from the event stream, so the image is treated as untrusted: it is decoded and re-encoded on the server exactly like a feedback screenshot (type sniffed from the bytes, other libvips loaders blocked, pixel cap, metadata dropped, WebP ≤ SCREENSHOT_MAX_BYTES), stored in blobs with an app_screenshots row and deleted after SCREENSHOTS_RETENTION_DAYS. Members see it only on the dashboard origin (nosniff, default-src 'none'; sandbox); get_screenshot sends a JPEG re-encoding (≤ MCP_IMAGE_MAX_BYTES) between the text blocks of an untrusted envelope, because the image can show text that tries to instruct the agent (prompt injection). A request is answered once (Capture or Not now) and a tab never asks twice for the same request; every request, capture and decline is audited (app.screenshot.request, .capture, .decline). |
| Preview browser errors | App code or a stranger floods the error store, plants instructions for the agent, or reads what other visitors hit; a report leaks a visitor's data or a token in a URL | Only the preview (<slug>--preview) and version (<slug>--v<N>) hosts accept POST /__drobek/preview/errors; production and custom domains answer 404 and their pages never carry the reporter. The endpoint sits behind the app's password gate, refuses a cross-origin request, reads at most 16 KiB, takes at most 10 errors per report, drops anything past BROWSER_ERRORS_PER_IP_MINUTE per client IP and app, and always answers 204, so it reveals nothing. What is kept: the error type, message (≤ 1 KiB) and stack (≤ 4 KiB), the script's address without query, the page path without query string or fragment, a count and the browser family (chrome, edge, firefox, safari, other) — never page content, a cookie, storage, the full user agent or an IP address; e-mail addresses and token-shaped strings are redacted before storing. Reports live in Redis only, at most BROWSER_ERRORS_MAX_PER_VERSION distinct errors per version, and expire BROWSER_ERRORS_TTL_HOURS after the last one. Any script on the app's origin can post one, so the agent reads them only inside the get_logs untrusted envelope, and members only see them on the app's Logs tab. "browserErrors": false turns the reporter off. |
| Former members | A collaborator keeps access after leaving the team; a forgotten invite link still works | Workspace admins remove members (the Members tab, or remove_member over MCP with the user's explicit yes) and any member can leave. Access is resolved on every dashboard request and every MCP call, so the removed member's sessions, OAuth tokens and API keys stop reaching the workspace at once (not_found), the leases they held on its apps are released, and an upload URL issued to them earlier is refused at the PUT. Admins list and revoke pending invites (a revoked link stops working at once). A workspace always keeps a workspace-admin; every change is audited (member.role_change, member.remove, member.leave, member.invite_revoke). |
| Deleting a workspace or an account | A stolen dashboard session or an agent deletes everything; a deleted account keeps working through a key, a connection or another browser; a team loses its only admin; an account deletion takes a team someone just joined | A team workspace is deleted only by its workspace-admin: in the dashboard after typing its slug, over MCP only with user_confirmed: true after the user's explicit yes; a personal workspace only with its account. An account is deleted only in the dashboard (no MCP tool), after a fresh code e-mailed to its address under its own OTP scope with the sign-in code's guesses and send limits, so a session alone is not enough. The deletion removes the user's API keys and OAuth codes and tokens with the users row and every dashboard session of the user at once. It is refused while the user is the only workspace-admin of a team workspace other members use (hand the role over or delete the workspace first). A team the user was the last member of goes with the account only while nobody else has joined it: the members are counted again before each of its apps is deleted or purged and under the workspace row lock before the row goes, and a new member stops the deletion (workspaces_changed). The apps are purged at once, so their addresses and data are gone; the audit rows stay (without the author) until AUDIT_RETENTION_DAYS, audited workspace.delete and account.delete. |
| Deleting old versions | An agent or a stolen session wipes an app's history; a clean-up removes what production or the preview serves; numbers are reused so an old link shows other content | Only an editor+ of the app's workspace deletes versions (the write scope over MCP; a viewer gets forbidden). Deleting is irreversible, so delete_versions needs user_confirmed: true, which the tool description allows only after the user's explicit yes; without it the answer is the plan (what goes, what stays and why, and a plan_id fingerprint of what goes) and nothing changes. The confirmed call must carry that plan_id, and the server recomputes the plan under the app's row lock: when it differs (a write, publish or keep in between) nothing is deleted (plan_changed), so a yes never deletes more than the user saw; the dashboard's confirm panel posts its plan id the same way. The published version, the preview's, kept versions, rollback sets, the newest version and the last hour's always stay — one SQL rule shared with the retention — and the newest version staying means version numbers are never reused. Each batch is audited app.versions.delete with the member. A taken-down app refuses the clean-up (app_locked_by_admin). |
| Changing the sign-in e-mail | A stolen dashboard session moves the account to the thief's address; the change form tells whether an address has an account; a code for one account or address changes another; the previous owner is not told | The address is changed only in the dashboard (no MCP tool) after a code e-mailed to the NEW address, with the sign-in code's send limits (OTP_*, its own scope email-change) and guesses; the code is keyed to the account and the address, so it changes nothing else, and a sign-in or deletion code never changes an address. An address another account signs in with gets an "already has an account" e-mail instead of a code, and the page answers exactly as for a sent code. The change ends every dashboard session of the user (this browser is signed in again) and e-mails the previous address a notice with the new address masked and the operator to write to; API keys and OAuth connections keep working. SUPERADMIN_EMAIL is bound to the address, so a changed address gains or loses super-admin rights at once; audited account.email_change with meta.super_admin when it did. |
| 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 rotated refresh token sent again within 60 s of its rotation (and of every later rotation in its chain) is a client retry and gets a fresh pair from the lineage's newest token; after that it is reuse, which marks that lineage's refresh tokens used and revokes the access tokens issued with them, while the user's other connections of the same client keep working. The residual risk is that window: a stolen refresh token rotated less than a minute ago still mints a pair. Every refresh logs its outcome with row ids (never a token). 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. A revoked key or connection fails its next request: /mcp keeps no session, every request is authenticated from its own credential. /mcp bodies are capped at MCP_MAX_BODY_BYTES. |
| 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 access tokens issued with it (the refresh-reuse mechanism). Spent rows are deleted by a daily prune only when no check needs them: access and refresh tokens 7 days after they expired (an expired refresh token is refused before any reuse check, so a rotated one stays as long as it can reveal reuse), codes 37 days after they expired (when the lineage a code minted has expired too); a replay after that finds an unknown code, which is refused all the same. |
| 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_*); a send the mail server failed counts only per IP, so an outage never locks an address out. Every rate-limit counter is created together with its expiry in one Redis transaction, and a counter found without an expiry (by a hit or by a check that only reads it) gets its window again, so no limit stays shut past its window. SMTP sends time out (SMTP_*_TIMEOUT_MS), so a hung mail server cannot hold sign-in requests open. Without a trustworthy client IP the per-IP buckets are skipped rather than shared. Google sign-in (when configured): a state cookie (__Host-, 10 minutes) must equal the callback's state, whose Redis record — the PKCE S256 verifier and the nonce — the callback consumes once, so a missing, expired or replayed state is refused; the ID token's nonce must equal the sign-in's, and the e-mail must be verified. |
| SSRF | The proxy module, the sync module's scheduled imports, the oidc module's IdP calls or the CIMD fetch reach the internal network or cloud metadata | A scheduled import (sync) goes through the same proxy path as an app's call — no second HTTP client — with a lower response cap (SYNC_MAX_RESPONSE_BYTES); its run errors never quote the upstream's answer. The oidc module's discovery, key, token and userinfo calls use the same guard with an empty host allow-list (only the host of the operator's AUTH_OIDC_ISSUER may be private), https only, 5 s and 64 KiB. 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); a proxy redirect is followed only to the upstream's own scheme, host and port inside its allowed path prefixes (at most 3 hops, each through the same guard; the injected secret never leaves that origin), any other is refused as upstream_redirect naming only the target's path, and no 3xx but 304 reaches the app; the CIMD and oidc fetches follow no redirects; a connect timeout, a 120 s response deadline (PROXY_RESPONSE_TIMEOUT_MS) and a 5 MiB response cap (also for a decoded gzip/br body, and for the whole redirect chain); a text/event-stream answer is relayed as it arrives only for an upstream a workspace admin enabled streaming for (allow_streaming, off by default and audited as proxy.upstream.update; every other answer is buffered within the caps above), and is cut after 60 s between two chunks from the upstream, 5 minutes or 32 MiB (PROXY_STREAM_*) — a client that reads slowly is held back by backpressure and bounded by the total and the byte cap; at most PROXY_MAX_CONCURRENT calls in flight (PROXY_MAX_CONCURRENT_PER_APP per app, PROXY_MAX_CONCURRENT_PER_CALLER per end user or client IP, an open stream holding its slot until it ends). 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. A workspace invite goes to the address a workspace-admin of a team workspace entered — in the dashboard, or over MCP (invite_member) only with user_confirmed: true after the user's explicit yes — and every invite is audited member.invite with its actor; the invite link reaches only that inbox, never the agent. 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; new versions per app and per person per hour (VERSIONS_PER_APP_HOUR, VERSIONS_PER_USER_HOUR), checked before any blob is stored; the unique bytes of a workspace's versions (WORKSPACE_SOURCE_QUOTA), checked when a version is stored; the history retention keeps the newest APP_VERSIONS_KEEP versions of an app (plus the published one and the rollback sets) and the blob GC frees the rest (and the screenshots of feedback notes deleted or resolved FEEDBACK_SCREENSHOT_RETENTION_DAYS ago); a 256 MiB byte LRU for serving; /mcp holds no per-client state, and the subscriptions/listen streams of MCP 2026-07-28 clients are capped at MCP_MAX_SUBSCRIPTIONS; 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 and version hosts whose version does not exist are negatively cached for 30 s and limited per client IP (APPS_UNKNOWN_HOST_* → 429); the host-resolution cache is capped over all hosts and drops expired entries. 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). A request body of the dashboard, the sign-in or an OAuth endpoint is capped at DASHBOARD_MAX_BODY_BYTES (1 MiB; a declared length before it is read, a chunked body as it arrives → 413, refused by Caddy too); /mcp (MCP_MAX_BODY_BYTES), the asset upload URLs, the Data tab's CSV import and the feedback note page (SCREENSHOT_MAX_BYTES plus 64 KiB) keep their own limits. 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 (a --v<N> host only when version N exists and compiled) and verified custom domains of live apps (never a deleted app's). 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; its version history stays as it was taken down — a member's clean-up of old versions is refused, so the evidence is preserved (the hourly retention past APP_VERSIONS_KEEP still applies, as to every app). A super-admin's agent can take down, restore and hide a gallery entry (takedown_app, restore_app, set_gallery_hidden): the tools are registered only for a super-admin, each body checks it again, and each change needs user_confirmed: true. 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 on an app reach every super-admin and OPERATOR_EMAIL; a report on a host no app matches only waits in the queue, so the public form is no channel into their mailbox. 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. |
| Module supply chain | A module package or one of its dependencies runs code at install time; a file in the modules directory is changed after the install; an update pulls in something the operator did not review | selfhost:module:add runs npm in a throwaway container with --ignore-scripts (and --omit=dev --omit=peer), never in the runtime image, which has no package manager. The installed version is pinned in modules.lock.json with the sha512 integrity of its whole directory; a changed byte refuses the start until the operator reinstalls it (the e2e suite proves both). A new version arrives only when the operator runs selfhost:module:add again; /healthz lists every module with its version and source. |
| App traffic analytics: visitor privacy | Counting visits tracks people across days or apps; an IP address, a user agent or a URL with a token in it is stored; the counters are flooded | Only successful HTML pages of the production address and custom domains are counted, with no cookie, no localStorage and nothing else on the visitor's device. The day's unique visitors are estimated in Redis: a HyperLogLog of sha256(salt, app id, client IP, user agent), where the salt is 32 random bytes per UTC day kept only in Redis (drobek:traffic:salt:<day>, expiring right after the day) and in the server's memory for that day — never in Postgres, never logged. The HyperLogLog keeps no element, and once the salt expires a hash cannot be recomputed, so a visitor cannot be followed from one day or app to the next. Postgres holds counts only (app_traffic_daily, app_traffic_top): views, the visitor estimate, bot views, page paths without their query string or fragment and the host of an external referrer without its path. Distinct paths and referrer hosts are capped at 200 per app and day (the rest count as __other__); bots are told apart by user agent and counted separately; drobek's own drobek-… health and smoke checks, prefetches, and frames sent without a referrer or from the dashboard or the gallery website (the app thumbnail, the gallery's live preview) are not counted. Counts older than ANALYTICS_RETENTION_DAYS (90) are deleted; ANALYTICS_ENABLED=0 turns counting off. Counting is fire-and-forget after the response and never fails it. |
| 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)
Findings of the pre-rebuild review (PHY-76) 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; a page load is counted only for a version the app has, behind its own per-app and per-IP buckets, and stores a number — no IP, cookie, user agent or URL |
Known limitations
Stated plainly so operators can plan around them:
- No per-token rate limit on
/mcpitself. Calls are bounded by the body cap, the compile queue (busy), the database's statement and lock timeouts (DB_STATEMENT_TIMEOUT_MS,DB_LOCK_TIMEOUT_MS), 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 backupdoes 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.