Skip to content

Commit a1fc014

Browse files
Christian Brobergclaude
andcommitted
fix(F163.1): proxy Set-Cookies URL-resolved active site — close cross-tenant desync
/admin/{slug}/... injected cms-active-org/cms-active-site only on the forwarded REQUEST, so client-side /api/* calls (which carry no slug) sent the STALE browser cookie → the site picker + page data resolved a DIFFERENT tenant than the URL. 2026-07-16 incident: /admin/broberg-ai/lighthouse showed the sanneandersen picker + sanneandersen Lighthouse data while URL/sidebar rendered broberg-ai — two tenants on one screen. proxy.ts now persists the URL-resolved site to the RESPONSE cookie jar (same opts as /admin/switch/[slug]), making the URL slug authoritative on every request; cookie drift self-heals on the next slug URL load. The ?site= API override stays request-only (per-call override for token callers, not a UI switch) — scoping preserved. Sealed by src/lib/__tests__/proxy-slug-cookie.test.ts (red→green: fails with the fix disabled; ?site= + reserved routes correctly do NOT persist the cookie). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent df16f2b commit a1fc014

2 files changed

Lines changed: 109 additions & 2 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Regression seal for the 2026-07-16 cross-tenant desync: the F146 URL site
3+
* router (`/admin/{slug}/...`) injected `cms-active-site` only on the forwarded
4+
* REQUEST, never on the RESPONSE. So server-rendered parts of the page resolved
5+
* the URL's site while every CLIENT-side /api/* call (which carries no slug)
6+
* sent the STALE browser cookie → the site picker showed sanneandersen while the
7+
* page (URL broberg-ai) rendered broberg-ai content. Two tenants on one screen.
8+
*
9+
* The fix: when the active site comes from the URL slug, proxy must Set-Cookie
10+
* it on the response so the browser jar follows the URL. This test fails if that
11+
* wiring breaks again. It also pins the deliberate SCOPING: the `?site=` API
12+
* override must NOT mutate the persistent cookie.
13+
*/
14+
import { describe, it, expect, vi, beforeAll } from 'vitest';
15+
import { NextRequest } from 'next/server';
16+
import { SignJWT } from 'jose';
17+
18+
// Mock the registry proxy.ts dynamically imports (./lib/site-registry). Two orgs,
19+
// each owning one site — the exact shape of the real leak.
20+
vi.mock('../site-registry', () => {
21+
const registry = {
22+
orgs: [
23+
{ id: 'org-broberg', name: 'Broberg', sites: [{ id: 'broberg-ai', name: 'Broberg.ai' }] },
24+
{ id: 'org-sanne', name: 'Sanne', sites: [{ id: 'sanneandersen', name: 'Sanne Andersen' }] },
25+
],
26+
defaultOrgId: 'org-broberg',
27+
defaultSiteId: 'broberg-ai',
28+
};
29+
return {
30+
loadRegistry: vi.fn(async () => registry),
31+
findSite: (reg: typeof registry, orgId: string, siteId: string) =>
32+
reg.orgs.find((o) => o.id === orgId)?.sites.find((s) => s.id === siteId) ?? null,
33+
};
34+
});
35+
36+
const SECRET = new TextEncoder().encode('cms-dev-secret-change-me-in-production');
37+
let sessionCookie = '';
38+
39+
beforeAll(async () => {
40+
const jwt = await new SignJWT({ sub: 'u1', email: 'cb@webhouse.dk', name: 'CB', role: 'admin' })
41+
.setProtectedHeader({ alg: 'HS256' })
42+
.setExpirationTime('1h')
43+
.sign(SECRET);
44+
sessionCookie = `cms-session=${jwt}`;
45+
});
46+
47+
async function run(url: string, cookie: string) {
48+
const { proxy } = await import('../../proxy');
49+
const req = new NextRequest(new URL(url), { headers: { cookie } });
50+
return proxy(req);
51+
}
52+
53+
describe('proxy — F146 slug router persists active-site to the browser cookie', () => {
54+
it('navigating to /admin/{slug}/... Set-Cookies the URL site over a stale cookie', async () => {
55+
// Browser jar still says sanneandersen; the URL says broberg-ai. URL wins.
56+
const res = await run(
57+
'https://webhouse.app/admin/broberg-ai/lighthouse',
58+
`${sessionCookie}; cms-active-org=org-sanne; cms-active-site=sanneandersen`,
59+
);
60+
expect(res.cookies.get('cms-active-site')?.value).toBe('broberg-ai');
61+
expect(res.cookies.get('cms-active-org')?.value).toBe('org-broberg');
62+
});
63+
64+
it('sets the cookie even when the browser had no active-site yet', async () => {
65+
const res = await run('https://webhouse.app/admin/broberg-ai/content', sessionCookie);
66+
expect(res.cookies.get('cms-active-site')?.value).toBe('broberg-ai');
67+
});
68+
69+
it('does NOT touch the persistent cookie for a reserved (non-slug) admin route', async () => {
70+
const res = await run(
71+
'https://webhouse.app/admin/lighthouse',
72+
`${sessionCookie}; cms-active-site=sanneandersen`,
73+
);
74+
expect(res.cookies.get('cms-active-site')).toBeUndefined();
75+
});
76+
77+
it('does NOT persist the cookie for a ?site= API override (per-call, not a UI switch)', async () => {
78+
const res = await run(
79+
'https://webhouse.app/api/admin/site-config?site=broberg-ai',
80+
`${sessionCookie}; cms-active-site=sanneandersen`,
81+
);
82+
expect(res.cookies.get('cms-active-site')).toBeUndefined();
83+
});
84+
});

packages/cms-admin/src/proxy.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export async function proxy(request: NextRequest) {
125125
// keeps the pretty `/admin/{slug}/` URL. Reserved segments (content,
126126
// settings, …) and unknown slugs fall through untouched.
127127
let slugRewriteUrl: URL | null = null;
128+
let slugActive: { orgId: string; siteId: string } | null = null;
128129
if (isAdminPath) {
129130
const parsed = parseSiteSlugPath(pathname);
130131
if (parsed) {
@@ -140,6 +141,14 @@ export async function proxy(request: NextRequest) {
140141
const cleaned = stripActiveSiteCookies(requestHeaders.get("cookie") ?? "");
141142
const injected = `cms-active-org=${org.id}; cms-active-site=${parsed.slug}`;
142143
requestHeaders.set("cookie", cleaned ? `${cleaned}; ${injected}` : injected);
144+
// Also persist to the browser's cookie jar on the RESPONSE (below).
145+
// Injecting only on the forwarded request fixes server-rendered
146+
// components, but every CLIENT-side /api/* call carries no slug and
147+
// sends the STALE browser cookie → the picker + page data resolve a
148+
// DIFFERENT tenant than the URL (cross-tenant desync: the 2026-07-16
149+
// broberg-ai/sanneandersen leak). The URL is authoritative, so the
150+
// browser cookie must follow it.
151+
slugActive = { orgId: org.id, siteId: parsed.slug };
143152
// Rewrite to the slug-stripped path so existing routes render.
144153
slugRewriteUrl = new URL(parsed.rest, request.url);
145154
slugRewriteUrl.search = request.nextUrl.search;
@@ -155,10 +164,24 @@ export async function proxy(request: NextRequest) {
155164
// resolved above we rewrite to the slug-stripped path; otherwise pass
156165
// through unchanged. Both carry the augmented requestHeaders (cookies +
157166
// x-pathname). All success-paths below go through this.
158-
const forwardOk = () =>
159-
slugRewriteUrl
167+
const forwardOk = () => {
168+
const res = slugRewriteUrl
160169
? NextResponse.rewrite(slugRewriteUrl, { request: { headers: requestHeaders } })
161170
: NextResponse.next({ request: { headers: requestHeaders } });
171+
// When the active site was resolved from the URL slug, write it to the
172+
// browser's cookie jar too (same opts as /admin/switch/[slug]) so later
173+
// CLIENT-side /api/* calls — which carry no slug — resolve the SAME tenant
174+
// as the URL. Without this the cookie drifts from the URL and two tenants
175+
// render on one screen. Scoped to the slug path only: the ?site= API
176+
// override deliberately does NOT mutate the persistent cookie (it's a
177+
// per-call override for token callers, not a UI site switch).
178+
if (slugActive) {
179+
const opts = { path: "/", maxAge: 60 * 60 * 24 * 365, sameSite: "lax" as const };
180+
res.cookies.set("cms-active-org", slugActive.orgId, opts);
181+
res.cookies.set("cms-active-site", slugActive.siteId, opts);
182+
}
183+
return res;
184+
};
162185

163186
// `?site=<id>` URL override for /api/* routes.
164187
//

0 commit comments

Comments
 (0)