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
This commit is contained in:
Codeman maintainer
2026-09-04 15:21:13 +02:00
parent 550e08a791
commit 2ab21c1b32
6 changed files with 209 additions and 4 deletions
+31
View File
@@ -653,3 +653,34 @@ describe('misc helpers', () => {
expect(proxyPrefixFor(CAP)).toBe(PREFIX);
});
});
describe('referrer policy on proxied responses', () => {
const CAP = 'c'.repeat(32);
const requestUrl = new URL('http://127.0.0.1:4000/');
it('stamps same-origin and drops the upstream policy, so the capability in the URL never reaches a third party', () => {
const { headers } = buildDownstreamResponseHeaders(
[
['referrer-policy', 'unsafe-url'],
['content-type', 'text/html'],
],
[],
CAP,
requestUrl,
false
);
expect(headers['referrer-policy']).toBe('same-origin');
expect(headers['content-type']).toBe('text/html');
});
it('stamps it even when the upstream sent none (the browser default would still leak on a downgrade-style policy)', () => {
const { headers } = buildDownstreamResponseHeaders(
[['content-type', 'application/json']],
[],
CAP,
requestUrl,
false
);
expect(headers['referrer-policy']).toBe('same-origin');
});
});