EP

Security

Alpha audit for Elastic Projects. First run 2026-08-23 against main. Re-audited the same day. Every finding that code can fix is now fixed. The table below shows where each one landed.

This is not a penetration test. It is a code review against the current alpha scope.

Scope

This audit does not cover Bluesky PDS security, PLC, third-party OAuth providers, or the hardening of your Docker host.

Where each finding stands

ID Finding Status
H1 No confidentiality on Spaces data Protocol design. Documented, not fixable here
H2 Open demo mode on a public host Fixed. Production rejects demo enter
H3 Share links are bearer secrets Accepted design. Link reset shipped
H4 Guests can write Accepted design. That is what a share link grants
M1 No CSRF tokens Fixed. Cross-site writes are rejected at the network boundary
M2 No rate limits Fixed. Join, login, demo enter, and all writes are throttled
M3 Sessions in SQLite on Vercel Fixed. Production runs on Neon Postgres
M4 Live create uses the person DID, not the org Open. Product work, tracked in limits.md
M5 Actor search open to the public Fixed. The route was dead code and is removed
L1 Demo cookie is a plain flag Accepted. Demo mode is off in production
L2 No Content-Security-Policy Fixed. CSP and security headers ship on every response
L3 File content rendered as text Sound today. Watch if markdown preview lands
L4 Path rules on file paths Sound. All file writes go through path normalization

Accepted design risks

These are properties of the alpha design, not bugs. They stay in this file so nobody is surprised.

H1. No confidentiality on Spaces data

Anyone with a space credential can read permissioned repos. The protocol does not encrypt project files for outsiders.

Treat every project as internal-only. Do not put secrets in files. See limits.md.

H3 and H4. A share link is a key that grants writing

Join tokens are 16 random bytes, so guessing one is impractical. Whoever holds the URL can join as a guest and then read and edit files. Guests cannot invite or remove members.

Share links only with people you trust to write. If a link escapes, open the share dialog and press Reset link. The old token stops working immediately. Members keep their access.

M4. Live create uses the person DID, not the org

Live projects are minted under the signed-in account, not the organization ODS. Live create does not use the organization ODS yet. Use local preview for organization-owned work until this lands.

L1. Demo cookie is a plain flag

elastic-demo=1 is not bound to a server-side nonce. This only matters where demo mode is allowed at all, which is local next dev or an explicit ALLOW_DEMO=1. The cookie is httpOnly and sameSite: lax.

What was fixed since the first audit

Cross-site writes are rejected (was M1)

Every state-changing request to /api/* and /oauth/* passes a same-origin check at the network boundary. A cross-site Sec-Fetch-Site or a mismatched Origin header gets a 403. This sits on top of the existing sameSite: lax, httpOnly cookies. A future cookie change cannot silently reopen CSRF.

Rate limits exist (was M2)

The same boundary throttles by client IP. Guest joins are 30 per 10 minutes. OAuth logins are 20 per 5 minutes. Demo enter is 30 per 5 minutes. Every other write is 120 per minute. Counters are in-memory, so on serverless they apply per instance. That is a real cap for this app traffic. If abuse shows up at scale, move the counters to a shared store or the Vercel firewall.

Production state lives in Postgres (was M3)

Production runs on Neon Postgres, so sessions, members, and checkouts survive cold starts. SQLite remains for local development. Session tokens are still 32 random bytes stored as SHA-256 hashes. Protect DATABASE_URL like any credential.

Demo enter is off in production (was H2)

POST /api/demo/enter returns 403 unless the app runs in development or you set ALLOW_DEMO=1 on purpose for a shared sandbox. Production shows Spaces sign-in only.

The public actor search route is gone (was M5)

GET /api/actors/search proxied handle search without a session. Nothing in the app called it anymore, so the route and its helpers are deleted.

Security headers ship on every response (was L2)

The app config now sets a Content-Security-Policy. It blocks external scripts and framing. Forms post to self. It also sets X-Content-Type-Options: nosniff and X-Frame-Options: DENY. A strict referrer policy applies. A permissions policy turns off camera, microphone, and geolocation.

What was already sound

Area Detail
SQL Parameterized queries. No raw string concat in routes reviewed
Session tokens 32-byte random, hashed at rest, expiry enforced
Join tokens 128-bit random, format validated, resettable
Guest DIDs 152-bit random suffix
OAuth callback Old web session deleted on new login
Member removal Guests cannot remove. Org authority cannot be removed
Space credential DPoP on credential fetch. redirect: error on fetch
OAuth HTTP allowHttp only when UI_PUBLIC_URL is loopback
File paths Path normalization rejects .., .git, and odd characters
Rendering File bodies stay text in textareas. No HTML from user content

The hosted data server surface

The atproto sync endpoints under /xrpc/ and the well-known identity routes shipped after the audit. Their security posture is simple.

Endpoint Exposure
com.atproto.sync.getRepo, getLatestCommit, getBlob, repo.getRecord Requires the project share token, the same capability as /join and git clone
/api/projects/…/blobs Session plus project membership. Blob bytes are served only for a hash published in that project, and uploads cap at 8 MB
com.atproto.repo.putRecord, deleteRecord, applyWrites Requires the share token as a bearer token. Same validation as the app editor: normalized paths, text only, 100 KB cap
com.atproto.server.describeServer, identity.resolveHandle Public. Identity only, no content
/.well-known/did.json, /.well-known/atproto-did Public. Identity only, no content

Repo signing keys live as private objects in the blob store. They never live in the database and never live in a public bucket. Write endpoints go through the same proxy checks as the rest of the app. Cross-site browser POSTs are rejected. /xrpc/ writes are rate limited per IP (60 per minute) because each one costs a signed commit. Writes authenticate with the share token, not cookies, so there is no CSRF surface to ride. There is no firehose, so nothing is broadcast.

Operating notes

  1. Do not upload secrets to any project tree. Nothing encrypts them from other members.
  2. Review share links after every external collaborator. Reset a link when someone must lose access.
  3. Set ALLOW_DEMO=1 only on a host you accept strangers writing to.
  4. If you self-host with Docker, protect the database file or DATABASE_URL the way you protect a password store.

Report changes

Update this file when auth, storage, or join flows change.