This file lists every trust-boundary finding in the servers and infra/, and
how each one was closed. Scope: code/apps/storage/src,
code/apps/realtime/src, infra/. Secrets: the full git history
(git log -p).
State on 2026-10-01: all rows are closed. Rows 1–4, 8 and 9 closed
when managed mode was deleted: Atlasdraw is self-host only, one trusted
tenant per deployment (docs/architecture/adr/0013-self-host-only.md). The
other rows harden a single-tenant server that faces the internet. What is
left is accepted, not open: see "Accepted risks" below the table.
Fix rule: one finding per commit, the commit message names its row, and a test re-checks it.
| # | finding | file | severity | user trust broken | fix commit | re-audit |
|---|---|---|---|---|---|---|
| 1 | GET /maps/:id and PUT /maps/:id never compare the map's stored workspace_id to the caller's workspace — client.getMap(id)/updateMap(id) take only the id |
routes/maps.ts:47-85; adapters sqlite-fs.ts/postgres-minio.ts getMap ignore workspace |
HIGH | Cross-tenant read + overwrite: in managed mode any caller with any valid X-Workspace-ID reads or clobbers any map by knowing its 21-char id |
removed with managed mode, ADR-0013 | no managed-mode code remains |
| 2 | POST /maps/:id/share mints a 7-day public read token for any map id with no ownership check (getMap presence check only) |
routes/share.ts:38-77 |
HIGH | Cross-tenant exfiltration: a tenant mints a public share link for another tenant's map, then reads it (and its blob) unauthenticated via /share/:token |
removed with managed mode, ADR-0013 | no managed-mode code remains |
| 3 | /yjs/:roomId upgrade took the whole path suffix verbatim as docName, with no auth; the data-layer and comments Y.Docs are plaintext to the relay (ADR-0014). Anyone who knew a room id could read and write the room |
realtime/rooms.ts (was yjs-server.ts); packages/protocol/src/room-link.ts |
HIGH | Any client knowing a roomId read and wrote another room's data layers and comment text |
W6: the room token is the capability (ADR-0014). The client derives it from the link secret (HKDF-SHA256, salt room id, info atlasdraw-room-auth) and sends it as the first WebSocket message, never in the URL. The relay stores SHA-256 of the first token per room (in SQLite with the room, ADR-0018) and closes any other with 4403. The room id must be a UUID (else HTTP 400). The caps come from one table, packages/protocol/src/wire.ts ROOM_SIZE. A message over MAX_MESSAGE_BYTES (16 MiB) closes its socket with 1009, and the process stays up: every socket has an error listener (until 2026-10-01 it had none, and one oversize frame from anyone, before any token, ended the relay). An update that would take a room past MAX_ROOM_BYTES (64 MiB) closes its socket with 4413 "room too large: N > cap" before it is applied (until 2026-10-01 the check ran only at save). MAX_ROOMS and MAX_ROOM_SIZE (connections per room) cap the rest. The relay still reads every room: see "What the relay can see" in ADR-0014 |
apps/realtime/tests/rooms.test.ts (wrong token denied and sent nothing; a restart keeps the claim; a non-token first message denied; bad path 400; an update past the room cap refused); apps/realtime/tests/relay-process.test.ts (a 17 MiB frame before and after the token, and a text frame that is not UTF-8, against src/index.ts in its own process: the socket closes, /health answers, another room keeps syncing); collab.known-red.test.ts "a client whose link has another key… is denied", "a room's size limits reach the user" |
| 4 | GET /api/workspaces returns every workspace row with no auth ("Phase 6 v1 has no user auth so this returns every row") |
routes/workspaces.ts:27-33 |
MED | Full tenant enumeration in managed mode: workspace ids + plan tiers of all customers exposed to any managed-mode caller | removed with managed mode, ADR-0013 | no managed-mode code remains |
| 5 | Socket.IO CORS origin: "*", no auth on JOIN_ROOM; the workspaceId room-namespace is client-supplied and unvalidated |
realtime/index.ts:34; realtime/socket-io-server.ts:127-165 (both gone) |
MED | Room-slot exhaustion and injection into any known room | W6 deleted the Socket.IO relay. The room server admits a connection only with the room's token (row 3); a full room closes new connections with 4409 | apps/realtime/tests/rooms.test.ts "refuses a connection over the room's limit as full" |
| 6 | Relay published directly on host 4001:4001, bypassing the Caddy/TLS front door; Caddyfile had no /yjs route |
infra/docker-compose.yml:113-114; infra/caddy/Caddyfile:16-22 |
MED | Realtime traffic (the plaintext room docs and presence) rides unencrypted in transit in the documented full stack | Caddy now proxies /yjs/* → realtime:4001 (WS upgrade auto-proxied, TLS-terminated); compose changed ports: 4001:4001 → expose: 4001 so the relay is internal-only, reachable only through Caddy |
docker compose config valid (base + realtime profile); caddy validate = Valid configuration |
| 7 | No per-IP / per-client rate limiting on any storage HTTP route — only a 50 MiB body cap; storage throttles nothing | storage/index.ts:53-56 (no rate-limit plugin) |
MED | Abuse / brute-force of the internet-facing storage API: unbounded POST /maps, share-token guessing attempts, blob-storage fill |
added middleware/rate-limit.ts — hand-rolled per-IP fixed-window limiter (no new dep), wired in index.ts with trustProxy so request.ip is the real client behind Caddy; /health exempt; RATE_LIMIT_MAX/RATE_LIMIT_WINDOW_MS config (0 disables). Since row 11, trustProxy follows TRUST_PROXY |
4 new unit tests (cap→429, /health exempt, per-IP scoping, disabled); 114 storage tests green |
| 8 | POST /api/billing/checkout trusts workspaceId from the request body and never checks it matches the caller's X-Workspace-ID |
routes/billing.ts:168-207 |
LOW | A tenant can open a Stripe checkout that credits/upgrades a different workspaceId (payment still required, so impact is limited) |
removed with managed mode, ADR-0013 | no managed-mode code remains |
| 9 | Stripe webhook idempotency + replay protection is in-memory only — lost on restart, not shared across instances (acknowledged in-code) | routes/billing.ts:63-98 |
LOW | A webhook replayed during the restart window (or against another instance) reprocesses; single-instance v1 accepts this per the module docstring | removed with managed mode, ADR-0013 | no managed-mode code remains |
| 10 | GET /share/:token returned the full map record, including its id, and PUT /maps/:id needs only the id — so a read-only share link gave write access. Self-host, not managed-only (audit 2026-10-01) |
routes/share.ts (route removed) |
HIGH | Anyone with a read link could overwrite the map every other recipient sees, and mint fresh 7-day links without limit | route deleted (no client called it); map responses no longer carry blob_ref; test: a token holder never sees the map id |
share.test.ts "a share token holder…", maps.test.ts "never exposes where the blob is stored" |
| 11 | trustProxy: true unconditionally: with storage reachable directly (minimal stack publishes :4000), a client sets its own X-Forwarded-For and walks around the row-7 rate limiter |
storage/index.ts |
MED | Rate limiting bypassed by changing one header per request | TRUST_PROXY env, default off; full stack sets "1" (Caddy) |
config.test.ts TRUST_PROXY cases |
| 12 | After row 10 the map id was still the only write capability: PUT /maps/:id checked nothing else, and POST /maps/:id/share minted links for any id. The owner held no secret of their own, and the backup could not be read back (audit 2026-10-01 S1, S2, C3) |
storage/routes/maps.ts, routes/share.ts |
HIGH | Anyone who ever learned a map id (an old share link, a log, a shoulder) could overwrite the map and mint links to it | a map carries a write key (ADR-0017): 256 random bits, returned once by POST /maps, stored as SHA-256, compared in constant time. PUT /maps/:id, GET /maps/:id/blob, POST /maps/:id/share and DELETE /maps/:id/share/:token need Authorization: Bearer <key>; none → 401, wrong → 403. A share token is never a key. GET /maps/:id deleted. Maps from before the key get none and can never be written (migration 003) |
routes/maps.test.ts, routes/share.test.ts "a share token holder…": every write, share, revoke and backup read with the token is 403; infra/smoke-minimal.sh refuses a keyless PUT |
| 13 | POST /maps takes 50 MiB from anyone, and nothing was ever deleted: every upload share made a new map that outlived its token (audit S3, H6) |
storage/routes/maps.ts, hooks/useShareLink.ts |
MED | Disk fill by anyone who reaches the API, and unbounded growth from normal sharing | MAX_TOTAL_BYTES caps the sum of map sizes (507 past it; since row 22 the default is 10 GiB and the cap is checked atomically). The client shares the document's own map instead of a new one. A sweep at start and every SWEEP_INTERVAL_MS deletes expired tokens and keyless maps no live token reads. A map with a key is never collected: its owner may return |
maps.test.ts "the total-size cap"; adapter-contract.test.ts "sweep deletes…" on SQLite and Postgres |
| 14 | Anyone who reaches the relay can make rooms: the first token for a new room id claims it, and a room is never deleted. There is no per-IP limit on new rooms and no cap on the SQLite file (W6, 2026-10-01) | realtime/rooms.ts, realtime/room-store.ts |
MED | Disk fill on an internet-facing relay; MAX_ROOMS bounds memory, not disk |
W6b: a cap on all stored rooms together (MAX_TOTAL_ROOM_BYTES, default 2 GiB; a new room or a growing save past it is closed with 4507 "relay storage full"); per client address, new rooms per hour (MAX_NEW_ROOMS_PER_IP, 30) and open connections (MAX_CONNECTIONS_PER_IP, 64), both closed with 4429; the address comes from X-Forwarded-For only with TRUST_PROXY (compose sets 1 for Caddy). Rooms nobody was in for ROOM_EXPIRY_DAYS (90) are deleted by a sweep at start and every ROOM_SWEEP_INTERVAL_MS. Knobs: docs/self-host/production.md "Realtime relay". Residual: people behind one NAT share the per-address limits |
apps/realtime/tests/rooms.test.ts "abuse limits" (new rooms per hour and the next hour; forged header ignored without trust, read with it; connections per address; total cap refuses a new room and a growing save; expiry at start and on the interval, a live room kept); collab.known-red.test.ts "a client over the relay's new-room limit is told so and stops trying" |
| 15 | Every 500 answered with err.message, and /health answered 503 with the error text: a database password failure or a closed-connection message reached unauthenticated callers (audit2 S5) |
storage/index.ts error handler; routes/health.ts |
LOW | Internal details (user names, driver errors) disclosed to anyone | app.ts#buildApp answers every 5xx with {"error":"internal error"} and /health with {"status":"error"} only; the detail goes to the log (and to Sentry when set) |
app.test.ts "answers a 500 with a generic message…", "answers a failed /health with no error text"; health.test.ts |
| 16 | The share token is in the URL, so Fastify's request log at info (the default) wrote every read capability (audit2 S6) |
storage/logger.ts; Caddy and nginx access logs |
LOW | Anyone with log access, or a log aggregator, could read every shared map | The request serializer writes /share/[redacted] (logger.ts#redactUrl). Caddy's log filter and nginx's atlas_redacted log format write [redacted] for the token in /m/…, /embed/…, /api/share/… and in the Referer. Residual: nginx's error log can still show a request line |
app.test.ts "never writes a share token to the log"; the Caddy and nginx logs checked in the minimal and full stacks |
| 17 | The rate limiter keyed on the full client address; one IPv6 host has a whole /64 and can use a new address for each request (audit2 S7) | storage/middleware/rate-limit.ts |
LOW | Rate limit bypassed over IPv6 | Every per-address limit keys an IPv6 client by its /64 (middleware/client-key.ts), an IPv4-mapped address as IPv4 |
client-key.test.ts; app.test.ts "rate-limits an IPv6 client by its /64…" |
| 18 | Shutdown closed the store before it drained requests: SIGTERM during a 45 MiB PUT gave a 500, and the blob on disk no longer matched the row (audit2 structural 1). The composed app was never tested, which is how it shipped | storage/index.ts |
MED (integrity) | Every docker compose stop or upgrade could break a save in flight |
One buildApp for tests and production. app.close() stops new connections, lets requests in flight finish, sends Connection: close so keep-alive clients let go, and only then closes the store (onClose). SHUTDOWN_TIMEOUT_MS (25 s) bounds it |
app.test.ts "drains a request in flight before it closes the store" (4 MiB PUT half sent at close: 200, row and blob agree, close under 2 s) |
| 19 | getBlob read the whole blob into one Buffer and the route sent it, with no limit on open requests and no send timeout: 40 stalled readers of one 50 MiB share link took the server from 111 MB to 2.16 GB, through Caddy, with no credentials (audit2 S1). Uploads were buffered whole too |
storage/adapters/*#getBlob, routes/share.ts, index.ts body parser |
HIGH (internet-facing) | Anyone with one share link could exhaust the server's memory | Blobs stream both ways (routes/blob-body.ts, lib/body.ts): a body needs Content-Length (411), is refused past MAX_MAP_BYTES before a byte is read (413), and is stored as exactly that many bytes or not at all. A connection with no bytes moving for IDLE_TIMEOUT_MS (30 s) is closed; a request must arrive in REQUEST_TIMEOUT_MS (5 min), headers in 20 s. One address may have MAX_CONCURRENT_PER_IP (16) requests open; past it, 429 |
limits.test.ts: 16 readers stalled on a 32 MiB share blob retain under 48 MiB (whole-blob reads: 481 MiB, measured by reverting getBlob); a stalled body and a stalled reader are closed; the 3rd request in flight with a limit of 2 gets 429 and the slot frees; adapter-contract.test.ts short and long bodies store nothing (SQLite, Postgres, real MinIO) |
| 20 | A share link served any stored bytes from the operator's domain as application/octet-stream with no nosniff, no Content-Disposition and no CSP (audit2 S4) |
storage/routes/share.ts |
MED (abuse) | An anonymous file host for malware or phishing pages under the operator's domain | Every blob response (blob-body.ts#sendBlob) carries X-Content-Type-Options: nosniff, Content-Disposition: attachment, Content-Security-Policy: sandbox; default-src 'none' and its Content-Length. Residual: the bytes are still served; MAX_TOTAL_BYTES and the per-address limits bound how much |
share.test.ts "serves the bytes as a download that no browser renders or runs" |
| 21 | A blob and its row were two separate writes, and updateMap never checked that the row still existed: a PUT racing a DELETE wrote its blob back and answered 200, leaving bytes that no row named, the size count never saw and the sweep never found (sqlite-fs: one 40 MiB orphan in 16 tries; full stack: 281 MB in MinIO against 9 counted bytes). A crash between the two writes left the same drift (audit2 S2, structural 2) |
storage/adapters/sqlite-fs.ts, postgres-minio.ts |
MED | Unbounded, uncounted storage by anyone; a wrong byte_size after a crash |
Every write streams to a NEW blob key; one transaction checks the row, points it at the new blob and counts the size change; the old blob goes after. No row: the new blob is deleted and the answer is 404. The sweep removes blobs no row names (and stale size reservations) older than one hour | adapter-contract.test.ts "a write that loses the race with a delete…", "a write cut partway…", "sweep removes a blob no row points to…" (SQLite, Postgres, real MinIO); maps.test.ts "a PUT that loses the race with a DELETE answers 404 and leaves no blob" |
| 22 | The size cap was a SUM(byte_size) check followed by a separate write: 20 parallel 900 KB creates against a 1 MiB cap stored 8.1 MB. The default was no cap, and nothing limited new maps per client (audit2 S2, S3, structural 8) |
storage/service/maps.ts |
MED | Disk fill, or a full cap that refuses every owner's next save | Bytes are reserved against MAX_TOTAL_BYTES (writes in flight included) in a transaction before the first byte is written; the reservation becomes counted bytes (storage_usage, migration 004) in the same transaction as the row change; a failure gives it back. Default cap 10 GiB. MAX_NEW_MAPS_PER_IP (60 per NEW_MAPS_WINDOW_MS, 1 h) per address or IPv6 /64; past it, 429. Residual: many addresses can still fill the cap (Accepted risks) |
adapter-contract.test.ts "concurrent creates cannot pass the cap" (20 x 100 B against 1000 B: exactly 10), "a failed write gives its reserved bytes back"; maps.test.ts "concurrent creates cannot pass the cap" (20 x 900 B against 4000 B: 4 created, 16 refused, 3600 B stored), "limits new maps per client address"; migrate.test.ts "starts the size counter at the bytes already stored" |
| 23 | The first start after the write-key upgrade deleted every keyless map with no live token, including the old autosave copies, although the CHANGELOG said "maps and share links stay" (audit2 structural 5) | storage/adapters/*#sweep; CHANGELOG.md |
LOW (integrity) | An operator who expects the volume to keep old copies loses them at upgrade | Keyless maps are kept for LEGACY_MAP_GRACE_DAYS (90) after migration 003 was applied, then swept once no live link reads them; 0 restores the old behaviour. CHANGELOG corrected |
adapter-contract.test.ts "keeps a keyless map until the grace after the upgrade has passed"; app.test.ts start-up sweep inside the grace and with grace 0 |
| 24 | The Pages deploy ran for any successful CI run whose head branch was named main, and a fork's pull request can name its branch main. It then checked out the fork's SHA and deployed it with pages: write and id-token: write (audit2-05 H4) |
.github/workflows/pages.yml |
HIGH (if fork workflows run) | A fork's JavaScript on the owner's Pages origin, which can read every visitor's IndexedDB maps | The build job also requires workflow_run.event == 'push' and workflow_run.head_repository.full_name == github.repository |
Read the if:; it cannot run locally |
| 25 | The storage container ran as root on Node 20 (end of life 2026-04-30); the relay and web images also ran as root; MinIO was latest and the storage server used MinIO's root credentials (audit2 S8) |
code/apps/*/Dockerfile, infra/docker-compose.yml |
LOW (hardening) | A server compromise was root in its container, and held the keys to the whole object store | Storage and relay run as node on node:22-bookworm-slim; web runs on nginxinc/nginx-unprivileged. The stack runs no object store (ADR-0019): the operator supplies the bucket and a key that reaches only BLOB_BUCKET (production.md "Limit the key to one bucket"), and the storage server uses it (ping became HeadBucket, which that key may do). Residual: the key's scope is the operator's to set; the server cannot check it |
Full stack built and run from a scratch copy with the app user (deploy commit); postgres-minio.test.ts "checks S3 with HeadBucket…" |
| 26 | The write key and the maps lived only in one browser's IndexedDB, which the browser may evict (WebKit after 7 days without a visit), and the app never asked to keep it. The server kept only the latest bytes, and two browsers with one key overwrote each other without a word (audit2 P6) | atlas-app/state/remoteMapIdCache.ts, storage/routes/maps.ts |
MEDIUM | A cleared browser lost control of every published map; one bad or stale save reached every link and embed and could not be undone | ADR-0020: a save names its revision in If-Match and a stale one is 412, never a silent overwrite; the server keeps earlier versions that only the write key reads; a restore is a new revision. The app asks for persistent storage after the first save, and Back up my maps writes the maps and keys to a file the owner holds |
adapter-contract.test.ts (revision and version cases, SQLite and real Postgres + S3), routes/versions.test.ts (a share token reads no version), routes/maps.test.ts (412), state/__tests__/backup.test.ts |
- The relay reads every room (ADR-0014). The room token decides who may
connect; it does not hide the content from the operator or from anyone who
reads
rooms.sqlite. A user who must hide a map from the operator runs their own deployment. - People behind one address share the relay's per-address limits (row 14).
POST /mapsis open to anyone who reaches the storage API. The write key protects existing maps, not the disk.MAX_TOTAL_BYTES(10 GiB) andMAX_NEW_MAPS_PER_IP(60 an hour) bound it; many addresses together can still fill the cap and lock out every owner's growing save until the operator acts (row 22). Put the site behind your own authentication if that matters.- The write key lives in the owner's browser (IndexedDB database
atlasdraw-autosave). A script running on the app's origin can read it. A share link holder cannot. Losing it loses write access to that server map, not the map: links still read it, and the owner chooses whether the next save makes a new server copy (row 12, row 26). - A backup file holds write keys (row 26). Anyone who gets the file can change and delete the owner's server maps. The app says so when it writes the file; keeping it private is the owner's job.
- Server versions are copies. A version keeps what the map held, also after the owner removes it from the latest save. Deleting the map deletes every version (
DELETE /maps/:id).
- Client-id validation —
ID_RE(^[A-Za-z0-9_-]{21}$) checked at every id/token entry point inmaps.ts/share.tsand defensively again in both adapters. Share tokens are 21-char nanoids with an optional expiry and orphan checks. - Git-history secret
4b07cca33ff2d2919bc95ff98f148e9e— upstream Excalidraw's public Firebase webapiKey(non-secret by design; enforcement is via Firebase security rules, not key secrecy). Already removed from the working tree in2912fad. Nothing to rotate. Other history hits (tokentokentoken…,abcdefghij…,__PMTILES_PATH__,excalidraw-oai-api-key) are test fixtures / placeholders. - Sentry
beforeSendscrubs Authorization headers and request IPs (storage/src/index.ts). - Graceful shutdown exists on storage and on the relay (SIGTERM/SIGINT: the relay saves every room, then closes).
Rows 1–4, 8 and 9 had one root: managed mode shipped tenant-isolation surface
(workspace_id columns, the X-Workspace-ID header, per-workspace quotas)
but no code path enforced ownership. The maintainer chose self-host only and
removed managed mode (ADR-0013). A future hosted tier is a new decision that
starts from tenant isolation and authentication.