Enterprise Edition: security review (2026-10-13)
Scope. This reviews the security and privacy model against what the code does: Seed branch feat/enterprise-edition at 2d7aeb384 (the commit the test instance reports from /hm/api/version), the hosting repo's enterprise/ CLI at 5d33ba4, and the live instance seed.botical.com probed from outside plus a read-only look at the droplet over SSH. Four reviewers covered the web edge gate and Caddy, the vault, the daemon, and the hosting tooling; everything below was verified by reading the cited code or by a live request, and nothing was modified on the instance.
Verdict. The core gate holds: no way was found to read content without a member session, the session cookie is sound (HMAC-SHA256, constant-time compare, fails closed when the secret is unset), the membership check fails closed when the vault is down, membership claims are signature-verified, invitations are genuinely single-use, and the daemon really is isolated from the network. The page is honest about its biggest gap (the open pre-session write path) and that gap is confirmed and worse than stated: an outsider can inject comments and accounts that members see, not just fill the disk. Beyond that, several claims on the page are wrong or stale, and the password sign-in has no brute-force protection at all. The fix list at the end is in the order it should be done.
Corrections to the security page
These statements on the page do not match the deployed code.
Page says | Code does | Where |
|---|---|---|
"the daemon runs in public-only mode" | The compose command line passes no |
|
| Written with the default mode; on the host it is |
|
cookie is | In production it is |
|
invitations are "bound to that email" | Binding is optional. |
|
"there is no size or rate limit" on uploads | Per-request caps exist: 150 MiB on |
|
the hosting repo's workflows hold a root SSH key for every instance; | Stale. Commit "#10" removed every workflow but |
|
firewall allows "22, 80, 443 TCP and UDP" | 22 and 80 are TCP only, 443 is TCP+UDP, and the cloud firewall also allows ICMP. Only sshd and Caddy listen externally (confirmed by port scan). |
|
open-path table | The generated Caddy allowlist is broader: it also opens |
|
|
|
|
Cloudflare token scoped to the base domain zone | The token lists seven zones (botical.com, vicenti.net, three zerve. zones, verse.link, 8bmedical.com). | Cloudflare API, read-only |
"signatures verified" on stored blobs | True for structured dag-cbor blobs (change, ref, comment, capability, profile, contact). |
|
Live probe of seed.botical.com (no session)
Confirmed from outside:
Every content path answers 401, including /hm/<uid>, .md, ?_data=, /api/Resource, /api/ListDocuments, GET /ipfs/<cid>, /hm/api/search, /debug/pprof/, /metrics, HEAD and OPTIONS. Case variants, // prefixes and dot segments all end up gated.
POST /ipfs/file-upload with no account stored a file and returned its CID. This is the open write path, confirmed working for an anonymous caller.
POST /api/PublishBlobs reaches the daemon (CBOR decode error on an empty body).
GET /api/Account?id=<uid> returns the organization profile without a session.
/hm/api/config and /hm/api/version are open as documented (peer id, space id, exact commit).
No Strict-Transport-Security, Content-Security-Policy (beyond frame-ancestors on documents), X-Content-Type-Options or Referrer-Policy headers.
Only ports 22, 80, 443 open; sshd accepts public keys only.
The next parameter on sign-in is validated server-side and client-side: no open redirect.
The test instance currently has no email relay configured, so the vault is in its console-sender mode (see M4).
Findings, ranked
High
H1. Anonymous content injection and disk fill through the pre-session write path. Caddy forwards POST /ipfs/* straight to the daemon and POST /api/PublishBlobs to the web app's StoreBlobs with no session. PutBlob writes through the indexer, so a self-signed dag-cbor blob is fully indexed. Comment indexing has no writer or capability check (unlike refs, which call isValidWriter), and a Ref or Change into a new space is always valid for that space's own key. So an outsider can sign a comment on the organization's home document and POST it to /ipfs/<cid>: it appears in members' discussions, and the attacker's key gets a public_keys row that ListAccounts and search surface as a stray account. The same routes allow unbounded 150 MiB uploads in a loop until the volume is full, at which point SQLite fails for everyone. The page lists this as hardening item 1 but describes it as disk fill and "junk content"; it is also a way to put words in front of every member. Fix: the page's own third option, publish the delegation and profile blobs server-side inside /hm/api/auth so the browser never needs the open endpoint, then put /ipfs/* and PublishBlobs behind the gate. Independently, wrap UploadFile in http.MaxBytesReader. (ops/deploy.ts ~1808-1823; backend/daemon/daemon.go ~252; backend/blob/blob_comment.go; backend/hmnet/filemanager.go ~314)
H2. Password sign-in has no rate limit, lockout or attempt counter. The vault's login handler counts nothing, main.ts has no middleware, and Caddy rate limiting is explicitly unconfigured. preLogin hands anyone the salt and credential types for an email. The server-side argon2id is deliberately cheap (1 iteration, 16 MiB); the expensive derivation is client-side, which an attacker runs on their own hardware. There is no server-enforced password policy. /vault/* sits outside the member gate, so this is reachable by the whole internet. Repro: loop POST /vault/api/login {email, authKey=derive(candidate, salt)} over a wordlist. The page lists this as "verify"; the answer is that the controls are absent. (vault/src/api-service.ts ~613-648, 1078-1114, 115-123)
H3. Unpinned installers run as root on every configure, and the engine is fetched by branch name with no hash. get.docker.com | sh and bun.sh/install | bash run on install and re-run on configure; the deploy engine comes from a raw GitHub branch URL with no checksum; the compose file is re-fetched from that URL on every deploy; images are pulled by mutable tag. A compromised branch, GitHub account or upstream installer is root on every instance at the next configure. The page acknowledges tag-based pulls (hardening item 7) but not the curl | sh installers. (SeedHost/enterprise/lib/engine.ts ~32-36; ops/deploy.ts ~2452-2461)
H4. The vault admin secret has more power than the page states and weak custody. The same 40-character secret authorizes: minting invitations and listing every unused invitation link in plaintext; the membership answer; the two internet-open web routes enterprise-bootstrap and enterprise-site-url, the second of which signs a change with the daemon-held organization root key. It sits in a 0644 config.json, inside every backup, in docker inspect output, and on the docker compose command line (visible in ps during deploys), and the admin API is reachable from the internet. Compare is constant-time and 404-when-unset, as the page says. (vault/src/api-service.ts ~388-398, 504-513; frontend/apps/web/app/routes/hm.api.enterprise-site-url.tsx ~40-82; ops/deploy.ts ~541, 2254)
Medium
M1. Nothing inside the box enforces anything. Without -public-only, the daemon serves every document to every caller on the docker network; the web app's entry.server gate runs only for document requests, and most resource routes (hm.api.resource.$, hm.api.file.$, hm.api.image.$, hm.download, hm.notifications, and others) have no session check of their own; only api.$ does. Any container on internal_network (seed-agents or seed-notify when enabled, Grafana or Prometheus with the metrics profile) can read everything from seed-web:3000 with no cookie, and can call the daemon's unauthenticated gRPC-web API, including signData with the organization root key, which bootstrap registers in the keystore as enterprise-owner and never removes. The vault's getVault already calls signData with the first key sorted by account id over a payload containing the user's chosen email. The page describes this correctly as "the daemon's API has no authentication of its own" but then says the daemon is in public-only mode, which it is not. (frontend/apps/web/app/entry.server.tsx ~216-226; hm.api.enterprise-bootstrap.tsx ~42-76; vault/src/api-service.ts ~1141-1163)
M2. Removing a member does not log them out, and nothing can remove a member. The session cookie is valid for 30 days and is never re-checked against the vault; the only revocation is rotating the session secret, which logs everyone out. The vault admin API has no DELETE routes for members, account claims or unused invitations, so an operator edits SQLite by hand. A vault password change does not revoke other vault sessions either. (web-session.server.ts; vault/src/main.ts ~351-374; vault/src/api-service.ts ~936-969)
M3. The 4-digit email code is guessable in about a day. Three wrong attempts delete the challenge, but every resend (60 s cooldown per email) mints a new code and resets the counter, so an attacker gets 3 guesses a minute per target, roughly 4,300 a day against 10,000 codes, each try emailing the victim. It needs a valid invite (or a member session for change-email), so the practical attack is claiming an address you do not own with an unbound invitation. (vault/src/api-service.ts ~143-157, 765-789, 841-850, 1367-1376)
M4. Verification codes go to container stdout when no email is configured, and the test instance is in that state now. With neither relay nor SMTP set, the vault silently picks ConsoleSender and prints the code; the enterprise wizard only asks for SMTP when notify is on, and seed.botical.com currently has emailRelayUrl: null. Anyone with docker logs reads codes; nobody else gets email at all. Config should refuse to start in production without a sender. (vault/src/email.ts ~117-147; ops/deploy.ts ~1412-1416, 1620-1651)
M5. The email relay token is an unrestricted send capability with no per-instance revocation. The vault and notify post {to, subject, text, html} with the token; the relay sends any content to any address as "<name> (Seed)", limited to 60 per 10 minutes and 500 per day. The token is <name>.<HMAC(secret, name)>, so it cannot be forged or reused across instances, but revoking one means rotating the global secret for every instance. (vault/src/email.ts ~95-105; SeedHost/server/models/enterprise-email-relay.ts ~19-27, 140-183; SeedHost/enterprise/lib/email-token.ts)
M6. Operator credentials are broader than the page says. The Cloudflare token spans seven unrelated zones; the DigitalOcean token's scope cannot be verified via the API. A leaked enterprise/.env reaches DNS for domains that have nothing to do with EE. ~/Code/.secrets.txt on the operator laptop held copies of both tokens world-readable; it is now 0600.
M7. Secrets on disk and on the process list. config.json, vault.sqlite and backups are 0644 on an unencrypted ext4 volume; every compose invocation puts the session secret, vault admin secret, SMTP password and relay token on the command line; --owner-password lands in remote argv and shell history (SEED_OWNER_PASSWORD avoids the history). Mitigated today only because the droplet has no non-root users. (ops/deploy.ts ~541-552, 2254-2256, 4205-4234)
Low
L1. Unauthenticated account-profile reads. GET /api/Account?id= and GET /vault/api/accounts/:id return any account's name, icon and site URL to anonymous callers. Needs a uid; ListAccounts is gated.
L2. Registration link secret. 10 characters from a 62-character alphabet with modulo bias, compared with !==, with the request body (including the secret) logged to stdout, on an open endpoint with CORS *; success re-points the whole site at another space. It is consumed at bootstrap so the exposure window is the first deploy, but the log line should go. (frontend/apps/web/app/routes/hm.api.register.tsx ~29-39; ops/deploy.ts ~546-552)
L3. No security headers. No HSTS, CSP, X-Content-Type-Options or Referrer-Policy from Caddy (the page lists this as hardening item 9). With the metrics profile, /.metrics* exposes Grafana to every member.
L4. Invite-status oracle. registerVerify checks the invitation before the challenge, and the email-challenge cookie is unsigned base64 JSON, so anyone holding a code learns whether it is invalid, used or bound to a different email.
L5. Delegated routing is not gated on the isolated flag. The HTTP delegated-routing client is still wired as the host's peer routing and bitswap content routing; it is harmless only because -p2p.delegated-dht defaults to empty. The chunked-upload cap bypass (H1) also belongs here as a daemon fix. (backend/ipfs/libp2p.go ~110-136)
L6. Information. /hm/api/config publishes the daemon peer id and loopback multiaddr; copy_headers X-Forwarded-Uri in the Caddyfile is a no-op (Caddy sets the forwarded headers itself and overwrites client values); the membership fetch has no timeout, so a hung vault stalls sign-in but still fails closed; StrictHostKeyChecking=accept-new is trust-on-first-use per IP; generateSecret has modulo bias.
What held up
Worth stating so the page can say it with confidence:
Caddy route order is vault, open POSTs, open GET, open pages, then gated /.metrics*, gated /ipfs/*, gated catch-all (verified by running caddy adapt on the emitted text). Caddy overwrites X-Forwarded-Uri and X-Forwarded-Method, so the session check cannot be steered by the client.
The daemon's Authenticate verifies the signed assertion before minting; the web app checks membership before setting either cookie and returns 403 otherwise, so a non-member never receives a daemon token through the exposed route. The daemon port is unreachable from outside.
Membership claims are Ed25519-verified over seed-vault-account:<origin>:<userId>:<uid> with the uid's own key; a uid already owned by another user is refused.
Invitation redemption is one transaction with a conditional UPDATE and a rollback when nothing changed; every server-side account creation funnels through it.
-p2p.isolated does what the page says: deny-all gater on every intercept hook, no bootstrap, no peer-exchange tick, relay, AutoNAT, hole punching and NAT service off, domain poller and site-config fetches cut at one choke point, lndhub check skipped, offline bitswap exchange, no QUIC listener. No telemetry exporter is configured.
No SQL injection in the vault (every query parameterised); no command injection through the CLI (args shell-quoted, names regex-validated); nothing secret is committed in either repo across all refs.
Fix order
Close the pre-session write path by publishing the sign-in blobs server-side, then gate POST /ipfs/* and PublishBlobs; add MaxBytesReader to UploadFile. (H1)
Rate limiting and lockout on vault login and code entry, and stop resend from resetting the attempt counter; consider 6-digit codes. (H2, M3)
Refuse to start the vault in production without a configured email sender; give the test instance its relay. (M4)
Chmod config.json, vault.sqlite and backups to 0600; pass compose secrets through an env file rather than the command line; drop the register-secret log line. (M7, H4, L2)
Pin the engine by commit and hash, pin images by digest, and replace curl | sh with the distro's docker package and a pinned bun release. (H3)
Add admin DELETE routes (member, uid claim, invitation) and re-check membership on a short interval or on a vault "revoked" list, so removal takes effect within minutes. (M2)
Decide on -public-only and fix the page: either run it (and then the daemon token means something) or state that EE has a single trust level and the daemon is wide open on the docker network. Put seed-agents and seed-notify on their own network. (M1)
Per-instance relay token revocation; scope the Cloudflare token to the EE zone. (M5, M6)
Headers in Caddy: HSTS, CSP, X-Content-Type-Options, Referrer-Policy. (L3)
Update the page: public-only, file modes, cookie name, invitation binding, upload caps, the hosting-repo credential story, firewall detail, the open-path list, the vault admin surface.
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime