Enterprise Edition: CTF read-attempt against ee1.botical.com
Scope and rules. Target https://ee1.botical.com, a research instance Eric set up and authorized for probing. Engine branch fix/ee-first-run at commit 935f5ff8 (from /hm/api/version; PRs #1228/#1229/#1230 on top of feat/enterprise-edition). I worked only from the public internet plus a reading of the source in ~/Code/Seed-worktrees/ee-fixes and ~/Code/SeedHost. No SSH to any host, no API keys, no stored vault or owner keys, no admin secrets: the goal was to read someone else's private content the way a real outsider would. Everything below is from a live request or cited code. The only writes I sent were two harmless unauthenticated probes described under F1 (one subscribe to my own address, one empty-body blob post); nothing was modified on the box otherwise.
This is a follow-up to the earlier security review (commit 2d7aeb384, instance seed.botical.com). It does not repeat that review's full finding list; it says what the newer branch changed, what it did not, and what a fresh read attempt found.
Verdict. The core content gate holds. I could not read a single document body, nor the document tree, nor forge a member session. The session cookie, the daemon's Authenticate, capability-blob signature checking, the admin-secret compares and the invitation codes are all sound, and every encoding, header-injection and path-confusion trick I threw at the proxy gate was correctly refused. What an anonymous outsider can still do on this branch: learn the organization's identity and home-document version, enumerate which accounts the daemon knows, write junk blobs and comments through the open pre-session path (unchanged from the prior review), and — new here — subscribe any email to the "private" site's notifications without any authentication.
What I could read without a session
Organization identity and home version. GET /hm/api/config returns registeredAccountUid (z6Mktz3F…TEi), signerAccountUid, peerId, protocolId (/hypermedia/0.9.2-ent-ee1-botical-com), the notify host and requireSignIn:true. GET /api/Account?id=<uid> and GET /vault/api/accounts/:id then return that account's display name (ee-test-1.hyper.media), siteUrl, icon, and the current home-document version CID (bafyreidso…), all with no session. This is the prior review's L1, still present.
Account-existence oracle. GET /api/Account?id=<uid> answers account for a registered home space and account-not-found otherwise, so any uid can be tested against this daemon. The organization's signer key, for instance, returns account-not-found (it signs but has no home document).
Organization name to anonymous browsers. Every open-prefixed path (/assets/*, /build/*, /favicon*, /.well-known/*, /__manifest*) is served to seed-web with no gate and runs the splat/root loaders, whose server-rendered HTML echoes homeMetadata (name, siteUrl). So even a stranger who loads https://ee1.botical.com/assets/x gets the org name back in window.__remixContext.
None of this is a document body. The bodies, the block text, the comments and the navigation tree stayed closed.
What I could not do (the gate held)
The private-content gate is a Caddy forward_auth to the web app's /hm/api/session, which returns 2xx only for an open path (re-checked from X-Forwarded-Uri) or a valid session cookie; everything else is 401/302. Attacks tried and refused:
Forge the session cookie. It is payload.HMAC-SHA256(SEED_SESSION_SECRET), verified with a length check and timingSafeEqual. No secret, no cookie.
Mint a session through /hm/api/auth. The daemon's Authenticate requires a fresh (±5 min) Ed25519 self-signed capability assertion whose audience is the daemon's own peer id, and the principal must already be known. Without a member's private key this cannot be produced.
Forge a delegation so resolveMemberAccount adopts me. Membership resolves a key to a member if a member granted it an AGENT capability. Capability blobs are signature-verified at index time (blob_capability.go line 109, Verify(v.Signer, v, v.Sig)), so a capability "from" a member that I sign with my own key is rejected and never indexed. Publishing it through the open /api/PublishBlobs does not help.
Confuse the two path parsers. The gate's allow decision uses new URL(X-Forwarded-Uri).pathname (keeps %2f, resolves literal .., case-sensitive); Caddy's matchers keep %2f literal, clean .., and are case-insensitive; seed-web then decodes %2f into path segments but does not resolve ... I probed %2f, %2e%2e, literal .., ..%2f traversal to the empty-path home, double slashes, trailing dots, case variants, the .data single-fetch suffix, and X-Forwarded-Uri/X-Forwarded-Method header injection. Each was correctly gated or resolved to a non-existent document path (assets/../..), never to a real document.
Read blobs or media. GET /ipfs/<cid>, /hm/api/file/<cid>, /hm/api/image/<cid>, /hm/api/content-image, /api/Resource all answer 401. POST /ipfs/<cid> is an upload that hash-checks the body (CID mismatch), not a read.
Register as a member. The vault is inviteOnly:true; registration requires a 256-bit random invitation code bound to a specific email (requireInvitation). No weak path (empty code, wrong email, used code) is accepted.
Findings
F1 — New: unauthenticated notification subscription and email send (Medium)
POST https://notify.ee1.botical.com/hm/api/public-subscribe/* accepts {action:"subscribe", email, accountId} from anyone, with no session, no membership and no proof of email ownership, and returns {"success":true} (confirmed live, HTTP 200). It writes the subscription and triggers a welcome email to the supplied address. The whole notify.<site> subdomain is a bare reverse_proxy with no import gate in the enterprise Caddyfile, so every notify route is public.
Because registeredAccountUid is handed out by /hm/api/config, an anonymous attacker can:
send welcome/notification email to arbitrary third-party addresses as the site (unsolicited mail / bombing); no rate limit is present on this route; and
subscribe their own address to the private site with notifySiteDiscussions:true (the default), turning future discussion activity on a sign-in-required site into an email side channel to a non-member.
The main app reports ENABLE_EMAIL_NOTIFICATIONS:false while the notify service reports true, so actual delivery depends on the notify pipeline, but the write and the welcome-send are unauthenticated regardless. Fix: require proof of membership (or at least a verified-email challenge) before accepting a subscription on an invite-only instance, and gate notify.<site> the same way the main site is gated, or move subscription auth into the notify service.
F2 — Still open on this branch: the pre-session write path (High, carried over)
The prior review's H1 is unchanged here. POST /api/PublishBlobs reaches the daemon's StoreBlobs (CBOR-decode error on an empty body, HTTP 500 — i.e. ungated), and POST /ipfs/file-upload and POST /ipfs/<cid> reach the daemon's file manager (multipart / CID-mismatch errors), all with no session, because the enterprise Caddyfile opens method POST path /ipfs/* and POST /api/PublishBlobs by design (the sign-in flow needs them before a cookie exists). A self-signed dag-cbor comment on the org's home document is still fully indexed and shown to members, and uploads are still unbounded. The clean fix remains the one the security page proposes: publish the delegation and profile blobs server-side inside /hm/api/auth and then move /ipfs/* and PublishBlobs behind the gate.
F3 — Carried over: privacy rests entirely on the one proxy gate (Medium)
Nothing inside the box re-authorizes a document read. The HTML splat loader (routes/$.tsx) resolves a path to a document id and calls loadSiteResource with no session check of its own; only the JSON API route (api.$.tsx) has a signInRequired defense-in-depth layer. The daemon runs without -public-only and serves any document to any caller on the docker network. So the Caddy forward_auth is the single thing standing between the internet and every document.
That makes the three-parser divergence in the section above a real latent risk rather than a curiosity: the @open prefixes already reach the splat loader unauthenticated, and the only reason they render "Not Found" instead of content is that seed-web does not resolve .. in a document path. If the daemon's document-path resolver ever normalizes .. (a plausible, innocent-looking change), GET /assets/..%2f..%2f would render the home document to anonymous callers. Recommend (a) normalizing the path once, in Caddy, before both the match and the proxy, so the gate and the backend can never disagree; and (b) giving the HTML document loader the same signInRequired check api.$.tsx already has, so a proxy gap is not automatically a content breach.
What held, and is worth keeping
HMAC session cookie with constant-time compare and fail-closed on a missing secret; daemon Authenticate bound to a signed, time-boxed, audience-checked assertion; signature verification on every structured blob at index time, including capabilities; constant-time hashed compares for the vault admin secret and the enterprise-bootstrap/enterprise-site-url bearer; 256-bit email-bound single-use invitations; media and IPFS reads behind the gate; and an IPFS upload that hash-checks its body. The gate also correctly refused case, %2f, dot-segment, double-slash, .data and header-injection variants — the app-level isOpenPath is exact and fails closed, which is what saved it.
Bottom line
On fix/ee-first-run, an outsider with no credentials cannot read the private content of ee1.botical.com. What leaks is organizational metadata (name, home version, account existence) and the ability to write junk and to subscribe arbitrary emails to the private site's notifications. The highest-value hardening is to close the pre-session write path (F2), gate or authenticate the notify subdomain (F1), and remove the single-gate fragility by normalizing paths in one place and re-checking sign-in in the HTML document loader (F3).
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime