Make the Control Plane embeddable in a cross-site iframe - #2698
Make the Control Plane embeddable in a cross-site iframe#2698logical-and wants to merge 1 commit into
Conversation
|
thanks @logical-and — this is a genuinely substantial and well-documented contribution (constant-time token compare, the routing it to @nicoloboschi to own rather than reviewing-to-merge here, because this is squarely a maintainer call on two axes, not something to fast-track:
no code objection from me on a first read — it's clearly thoughtfully built. just want the direction + the security review to be Nicolo's. cc @nicoloboschi. |
nicoloboschi
left a comment
There was a problem hiding this comment.
The architecture here is sound — one bankAllowed predicate reused across all three enforcement layers so they can't drift, HMAC-signed prefix in the cookie, constant-time compares, frame-ancestors defaulting to 'self' and never *. Nice work on the layering.
That said, there are a few security issues, and one is an exploitable scope bypass. Flagging before merge.
Must fix
1. Prefix scoping can be bypassed via path traversal in bank_id (high)
Four routes interpolate bankId into the dataplane URL without encoding:
src/app/api/graph/route.ts:45src/app/api/documents/[documentId]/chunks/route.ts:28src/app/api/documents/[documentId]/reprocess/route.ts:25(a write)src/app/api/banks/[bankId]/observations/scopes/route.ts:20
A session scoped to u2 requests GET /api/graph?bank_id=u2--x/../victim. Middleware calls bankAllowed("u2", "u2--x/../victim"), which passes the startsWith("u2--") check. The route then builds the dataplane URL and WHATWG URL normalization collapses the dot segment:
http://dataplane/v1/default/banks/u2--x/../victim/graph → .../banks/victim/graph
Result: full cross-tenant read of another user's graph and document chunks, plus a reprocess write. dataplaneBankUrl() correctly uses encodeURIComponent — these four just bypass it.
Suggested fix in two places:
- Route all four through
dataplaneBankUrl(). - Also reject bank ids containing
/,\, or..insidebankAllowed()itself, so the predicate is safe by construction no matter how a caller builds its URL. Part 2 is what prevents the next route from silently reintroducing this.
2. SameSite=None removes the only CSRF defense (high)
There's no CSRF token and no Origin / Sec-Fetch-Site check anywhere in the control plane today — sameSite: "lax" was carrying that load implicitly. Once HINDSIGHT_CP_COOKIE_SAMESITE=none is set, any site the victim visits can issue authenticated state-changing requests with their cookie: create banks, retain, PATCH memories, reprocess documents, import transfers. Admin sessions included.
The embedding use case genuinely needs SameSite=None, so the fix isn't to drop it — it's to add an Origin check on all non-GET /api/* requests in middleware, allowing same-origin plus the origins already configured in HINDSIGHT_CP_FRAME_ANCESTORS. The allowlist exists; it just needs to gate requests as well as framing.
3. /api/auth/embed-login is unauthenticated login-CSRF (medium-high)
It matches /api/auth/ in PUBLIC_PATTERNS, accepts a form-encoded POST, and force-recreates the session cookie. Any cross-origin page can therefore silently overwrite a visitor's session with an attacker-supplied token. Combined with SameSite=None this sticks: an admin who browses a hostile page gets swapped into the attacker's scope, and anything they subsequently retain or upload lands in the attacker's bank.
Same Origin check applies here. The 302 response should also carry Cache-Control: no-store.
Separately, neither /login nor /embed-login is rate-limited, so both are brute-force oracles against HINDSIGHT_CP_TOKENS.
Should fix
4. An empty prefix in HINDSIGHT_CP_TOKENS silently grants admin
tokens.ts:74 accepts any string for prefix, and bankAllowed("", …) returns true unconditionally. So a config typo — {"token":"x","prefix":""} — hands out full cross-bank access with no warning at all. Worth rejecting empty prefixes at parse time, alongside the existing missing-field validation.
5. /api/chunks/[chunkId]/route.ts has no bank scoping
No path-collection match, no query param, no body guard. Any scoped session can read any chunk by id. UUIDs make it non-enumerable in practice, but it's an exception to the PR's "every route that takes a bank_id" claim — either scope it or note why it's exempt.
6. No revocation path for scoped tokens
Sessions are HMAC'd with the admin key, so removing a token from HINDSIGHT_CP_TOKENS leaves its outstanding cookies valid for up to 24h. Rotating the admin key is the only kill switch and it logs everyone out. At minimum worth calling out in the .env.example comment.
Minor
constantTimeEqualearly-returns on length mismatch, leaking token length. Standard tradeoff, but the comment inresolveTokenclaims timing doesn't leak which token matched — that only holds for equal-length tokens.bank-selector.tsx:.catch(() => setIsAdmin(true))fails open. Cosmetic only since the server enforces, but it'll flash admin chrome to scoped users on a transientwhoamifailure.- Middleware overwrites
Content-Security-Policywholesale rather than merging. Fine today since nothing else sets one, but it will silently clobber a future policy.
Test coverage
The tests only cover session.ts round-trips. Nothing exercises the layers that actually carry the security guarantee: bankAllowed (especially the u2/u20 boundary and traversal-shaped inputs), apiTargetBankId, or the middleware 403 paths. The PR verified those by hand on a live deployment — that verification should be tests. Issue #1 above is exactly the case a bankAllowed unit test would have caught.
…o#2698) - Reject path-traversal bank ids in bankAllowed (/, \, .. segments) and route graph/chunks/reprocess/observation-scopes through dataplaneBankUrl, closing the u2--x/../victim cross-tenant bypass - Add an Origin/CSRF guard on non-GET /api/* (allowlist = same-origin + HINDSIGHT_CP_FRAME_ANCESTORS), restoring the defense SameSite=None removes; covers embed-login login-CSRF and adds Cache-Control: no-store to its 302 - Reject empty/non-string token prefixes so a config typo can't grant admin - Scope /api/chunks/[chunkId] to the session prefix via the response bank_id - Fail closed on the bank-selector admin chrome; document token revocation and the Origin gate in .env.example - Extract apiTargetBankId/isCrossSiteWrite to lib/auth/request-guard and add unit tests for the u2/u20 boundary, traversal inputs, and the CSRF logic
|
Thanks @nicoloboschi, this is a great review — especially catching the traversal bypass. Pushed Must fix
Should fix
Minor — corrected the Tests — added |
3afe7d2 to
d80ef72
Compare
…o#2698) - Reject path-traversal bank ids in bankAllowed (/, \, .. segments) and route graph/chunks/reprocess/observation-scopes through dataplaneBankUrl, closing the u2--x/../victim cross-tenant bypass - Add an Origin/CSRF guard on non-GET /api/* (allowlist = same-origin + HINDSIGHT_CP_FRAME_ANCESTORS), restoring the defense SameSite=None removes; covers embed-login login-CSRF and adds Cache-Control: no-store to its 302 - Reject empty/non-string token prefixes so a config typo can't grant admin - Scope /api/chunks/[chunkId] to the session prefix via the response bank_id - Fail closed on the bank-selector admin chrome; document token revocation and the Origin gate in .env.example - Extract apiTargetBankId/isCrossSiteWrite to lib/auth/request-guard and add unit tests for the u2/u20 boundary, traversal inputs, and the CSRF logic
|
Rebased onto current Only one real conflict, in Re-verified on the new base: full CP suite green (130 tests, up from 102 as it now includes @nicoloboschi no rush, but this is ready whenever you have time for a re-review — the |
|
Thanks for this — the security-sensitive parts are noticeably careful (HMAC over the prefix, constant-time compares, traversal baked into the predicate, the open-redirect and CSRF handling). But I'd like to split it, and take only half. Happy to take: the embeddability piecesThese stand on their own and don't create a durable security boundary we have to keep correct forever:
Would rather not carry: the prefix-based authzThe reason is architectural, not about the code quality. The scoping is a security boundary enforced at the Control Plane proxy layer over a bank-blind dataplane — everything still lives in the single Suggested alternative: enforce scoping in the deployment layerFor per-user embedding, put a pinned reverse proxy in front of the CP, one route/instance per embedded user, hardwired to that user's bank(s), rejecting any request that references a different bank (including the global-id routes). Because it's pinned it doesn't need to reimplement the prefix cleverness or parse bodies for reads — it enforces a fixed The trade-off to be explicit about: without the authz, the embedded iframe is full-admin (the single access key sees all banks). So the embed is only safe when the outer app/proxy controls who can load it and the key never reaches the browser — which is exactly "the proxy owns authz." That's the right division of responsibility here: we ship an embeddable admin console; the embedder scopes it. Would you be up for reworking this into an embeddability-only PR (CSP + cookie + embed-login + CSRF gate), and we drop |
- Add HINDSIGHT_CP_FRAME_ANCESTORS, applied as a runtime frame-ancestors CSP in middleware (next.config headers() bakes at build time, so a container-time env would be ignored). Falls back to 'self', never '*' - Add HINDSIGHT_CP_COOKIE_SAMESITE=none to emit the session cookie as SameSite=None; Secure; Partitioned (CHIPS) so it survives in a cross-site iframe; default stays SameSite=Lax - Add /api/auth/embed-login so an embedding page can POST the access key and have the frame land on the dashboard in one round trip (302 + Set-Cookie, Cache-Control: no-store) - Gate cross-site state-changing /api/* on an Origin check (same-origin plus the frame-ancestors allowlist), replacing the CSRF defense SameSite=None removes; applied before the public-path bypass so it also covers login and embed-login - Fix post-login redirects leaking the internal upstream origin behind a proxy by building the Location from x-forwarded-proto/host - Reject path separators and '..' segments in dataplaneBankUrl and route the four remaining hand-built bank URLs (graph, document chunks, reprocess, observation scopes) through it, so a bank id cannot normalize into a different bank's path
d80ef72 to
d656cf5
Compare
|
That's a fair call, and the reasoning lands — "allowlist-by-enumeration with no default-deny" is exactly the right characterization, and an OSS admin console shouldn't own a boundary that has to be re-proven on every new route. Reworked to embeddability-only in Kept: runtime Dropped: I documented the trade-off you named directly in One thing I did keep, and want to flag explicitly — the bank-id traversal guard, because it's not part of the authz story and it's a live bug on Four routes hand-build their dataplane URL and interpolate So those four now go through The rate-limiting on |
Reworked per review: this is now embeddability-only. The prefix-based authz (
tokens.ts,bank-guard.ts, session prefix, middleware prefix checks,whoami, bank-selectorisAdmin) has been dropped — see the discussion below. Went from 37 files / ~1050 added lines to 19 changed + 4 new.What this adds
Lets another app embed the Control Plane in an iframe.
HINDSIGHT_CP_FRAME_ANCESTORS— applied as a runtimeframe-ancestorsCSP in middleware. It has to be runtime: Next bakesnext.configheaders()at build time, so a container-time env would be silently ignored. Falls back to'self', never*.HINDSIGHT_CP_COOKIE_SAMESITE=none— emits the session cookie asSameSite=None; Secure; Partitioned(CHIPS) so it survives in a cross-site frame. Default staysSameSite=Lax, so nothing changes unless you opt in./api/auth/embed-login— the embedding page POSTs the access key (form-encoded or JSON, optionalreturnTo) and the frame lands on the dashboard in one round trip via302+Set-Cookie, withCache-Control: no-store.SameSite=Nonedrops the browser's implicit CSRF defense, so this replaces it: cross-site state-changing/api/*requests are rejected unless theOriginis same-origin or in theframe-ancestorsallowlist. Falls back toSec-Fetch-Site; header-less non-browser clients still work. Deliberately coupled to the cookie change, and applied before the public-path bypass so it also coversloginandembed-login(login-CSRF).Locationfromrequest.url, which behind a reverse proxy is the internal upstream (0.0.0.0:9999), sending the frame somewhere dead. Now built fromx-forwarded-proto/x-forwarded-host.Also included: bank-id traversal guard
Independent of the authz discussion, and a live bug on
maintoday. Four routes hand-build their dataplane URL and interpolatebank_idunencoded:api/graph/route.tsapi/documents/[documentId]/chunks/route.tsapi/documents/[documentId]/reprocess/route.tsapi/banks/[bankId]/observations/scopes/route.tsA
bank_idofx/../ynormalizes to a different bank's path before it ever reaches the dataplane. These now go throughdataplaneBankUrl(), which additionally throws on a path separator or..segment rather than encoding it — a bank id is a single path segment, so those shapes are malformed by definition, and failing loudly stops the next hand-built URL from quietly reintroducing it.Security model
Worth being explicit, since it drives the division of responsibility: the access key is all-or-nothing, so an embedded iframe is a full admin console over every bank. The embedding app decides who may load it, and the key must stay server-side and never reach the browser. For per-user scoping, put a reverse proxy in front that pins each embed to its own bank(s). This is documented in
.env.example.Testing
tests/lib/auth/request-guard.test.ts— Origin/CSRF logic: safe methods, same-origin (incl.x-forwarded-host), allowlisted embed origin, scheme/port mismatch, CSP-keyword filtering,Sec-Fetch-Sitefallback, unparseable origintests/lib/hindsight-client.test.ts— traversal rejection + encoding, incl. asserting a normalized URL keeps its intended pathtests/lib/auth/session.test.ts— extended for theSameSite=None; Secure; Partitionedopt-in and theLaxdefault138 tests pass;
i18n:checkclean (added theforbiddenkey across all 10 locales);next build+ standalone OK.