Files
Codeman/test/webview-capability-revocation.test.ts
T
Codeman maintainer 2ab21c1b32 fix(webview): revoke proxy capabilities on logout and stamp Referrer-Policy
WebviewCapabilityStore.revokeOwner() shipped for two releases with a docstring
claiming logout called it and no caller at all. The capability is a bearer
credential exempt from cookie auth with a rolling TTL refreshed on every use, so
a proxy URL that leaked (browser history, a screenshot, a dashboard with a loose
referrer policy) stayed valid for as long as anything kept polling it.

- POST /api/logout revokes the caller's capabilities (all of them in single-user
  mode), the admin forced logout revokes the target user's, and user deletion
  revokes whatever that user had open. revokeOwner returns the count for the
  admin audit line.
- Proxied responses carry `Referrer-Policy: same-origin` and the upstream's own
  policy is dropped: every URL inside the frame carries the capability, and a
  dashboard on no-referrer-when-downgrade or unsafe-url handed it to any
  third-party host it linked. Verified with Playwright that a sandboxed frame
  under an upstream `unsafe-url` sends no Referer to a third party while the
  root-absolute fetch and the CSS-triggered 404 fallback still reach the
  dashboard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKtW48T1UjAaecHAJxKobE
2026-09-04 15:21:13 +02:00

133 lines
5.3 KiB
TypeScript

/**
* Web-tab proxy capabilities must die with the login that minted them.
*
* `WebviewCapabilityStore.revokeOwner()` shipped for two releases with a docstring
* saying logout called it and NO caller. The capability is a bearer credential
* exempt from cookie auth, with a rolling TTL refreshed on every use, so a leaked
* proxy URL stayed valid indefinitely. These tests pin every call site:
* `POST /api/logout` (own identity), the admin forced logout, and user deletion.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebviewCapabilityStore, webviewCapabilities } from '../src/webview-capabilities.js';
import { createRouteTestHarness, type RouteTestHarness } from './routes/_route-test-utils.js';
import { registerSessionRoutes } from '../src/web/routes/session-routes.js';
import { registerAdminRoutes } from '../src/web/routes/admin-routes.js';
import { createUser, invalidateUsersCache } from '../src/user-store.js';
const PASSWORD = 'correct-horse-battery-staple';
describe('WebviewCapabilityStore.revokeOwner', () => {
it('revokes exactly the identity asked for, the single-user `undefined` identity included', () => {
const store = new WebviewCapabilityStore();
const solo = store.mint('wv-solo', undefined);
const alice = store.mint('wv-alice', 'alice');
const bob = store.mint('wv-bob', 'bob');
expect(store.revokeOwner('alice')).toBe(1);
expect(store.resolve(alice)).toBeUndefined();
expect(store.resolve(bob)).toBeDefined();
expect(store.resolve(solo)).toBeDefined();
expect(store.revokeOwner(undefined)).toBe(1);
expect(store.resolve(solo)).toBeUndefined();
expect(store.resolve(bob)).toBeDefined();
// A later open mints a NEW token rather than resurrecting the revoked one.
expect(store.mint('wv-alice', 'alice')).not.toBe(alice);
expect(store.revokeOwner('nobody')).toBe(0);
store.dispose();
});
});
describe('POST /api/logout', () => {
let harness: RouteTestHarness;
beforeAll(async () => {
harness = await createRouteTestHarness(registerSessionRoutes);
});
afterAll(async () => {
await harness.app.close();
});
it('single-user: every outstanding capability dies with the login', async () => {
const cap = webviewCapabilities.mint('wv-logout-solo', undefined);
expect(webviewCapabilities.resolve(cap)).toBeDefined();
const res = await harness.app.inject({ method: 'POST', url: '/api/logout' });
expect(res.statusCode).toBe(200);
expect(webviewCapabilities.resolve(cap)).toBeUndefined();
});
});
describe('POST /api/logout in multi-user mode', () => {
let harness: RouteTestHarness;
let savedMode: string | undefined;
beforeAll(async () => {
savedMode = process.env.CODEMAN_MULTIUSER;
process.env.CODEMAN_MULTIUSER = '1';
harness = await createRouteTestHarness(registerSessionRoutes, { authUser: { username: 'peon', role: 'user' } });
});
afterAll(async () => {
await harness.app.close();
if (savedMode === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = savedMode;
});
it("revokes only the caller's capabilities, never another user's", async () => {
const mine = webviewCapabilities.mint('wv-peon-own', 'peon');
const theirs = webviewCapabilities.mint('wv-boss-own', 'boss');
const res = await harness.app.inject({ method: 'POST', url: '/api/logout' });
expect(res.statusCode).toBe(200);
expect(webviewCapabilities.resolve(mine)).toBeUndefined();
expect(webviewCapabilities.resolve(theirs)).toBeDefined();
webviewCapabilities.revokeWebview('wv-boss-own');
});
});
describe('admin routes (multi-user)', () => {
let harness: RouteTestHarness;
let savedMode: string | undefined;
// The temp HOME from test/setup.ts is per-FILE, so users.json persists across
// the tests in this block.
beforeAll(async () => {
savedMode = process.env.CODEMAN_MULTIUSER;
process.env.CODEMAN_MULTIUSER = '1';
invalidateUsersCache();
await createUser({ username: 'boss', role: 'admin', password: PASSWORD });
await createUser({ username: 'peon', role: 'user', password: PASSWORD });
harness = await createRouteTestHarness(registerAdminRoutes, { authUser: { username: 'boss', role: 'admin' } });
});
afterAll(async () => {
await harness.app.close();
if (savedMode === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = savedMode;
invalidateUsersCache();
});
it('a forced logout revokes the target user (normalised) and leaves the admin alone', async () => {
const peon = webviewCapabilities.mint('wv-peon-forced', 'peon');
const boss = webviewCapabilities.mint('wv-boss-forced', 'boss');
const res = await harness.app.inject({ method: 'POST', url: '/api/admin/users/PEON/logout' });
expect(res.statusCode).toBe(200);
expect(webviewCapabilities.resolve(peon)).toBeUndefined();
expect(webviewCapabilities.resolve(boss)).toBeDefined();
webviewCapabilities.revokeWebview('wv-boss-forced');
});
it('deleting a user revokes whatever that user had open', async () => {
const peon = webviewCapabilities.mint('wv-peon-deleted', 'peon');
const res = await harness.app.inject({ method: 'DELETE', url: '/api/admin/users/peon' });
expect(res.statusCode).toBe(200);
expect(webviewCapabilities.resolve(peon)).toBeUndefined();
});
});