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
- Next.js app and API routes
- Database replica (SQLite locally, Neon Postgres in production)
- Local preview and demo mode
- OAuth and web sessions
- Share links and guest join
- Live Spaces sync (where implemented)
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
- Do not upload secrets to any project tree. Nothing encrypts them from other members.
- Review share links after every external collaborator. Reset a link when someone must lose access.
- Set
ALLOW_DEMO=1only on a host you accept strangers writing to. - If you self-host with Docker, protect the database file or
DATABASE_URLthe way you protect a password store.
Report changes
Update this file when auth, storage, or join flows change.