mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
The serving route checked the blocklist per request but not attachmentConfineToWorkspace, while the attachment serve path (file-routes.ts) re-checks it every time. A capability minted for a folder outside the workspace before an admin turned confinement on kept serving that folder for the rest of its TTL. The capability record now says whether its folder was admitted from outside the workspace, and the serving route refuses such a record while confinement is on. The flag is part of the reuse key, so an out-of-workspace mint never picks up a capability recorded as in-workspace for the same folder. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
123 lines
5.1 KiB
TypeScript
123 lines
5.1 KiB
TypeScript
/**
|
|
* @fileoverview Capability tokens for rendering a local HTML file.
|
|
*
|
|
* `file-raw` never serves HTML as HTML: a page rendered from Codeman's own origin
|
|
* could read the Codeman document and drive the agent-spawning API. So an HTML
|
|
* file is rendered from `/html-view/:cap/*` instead, with a `Content-Security-
|
|
* Policy: sandbox` header that puts the document in an OPAQUE origin no matter how
|
|
* it is opened (iframe or top-level tab). That opaque origin is also why the route
|
|
* cannot authenticate on the session cookie: its subresource requests are
|
|
* cross-site, so the `SameSite=lax` cookie is never attached (the same reason the
|
|
* web-tab proxy uses capabilities, see webview-capabilities.ts).
|
|
*
|
|
* A capability is minted by an authenticated `POST /api/sessions/:id/html-view` and
|
|
* grants exactly one thing: GET access to the files in ONE directory (the HTML
|
|
* file's own), so a page's relative `style.css` / `chart.js` / images resolve.
|
|
*
|
|
* - 192 bits of `randomBytes` entropy, base64url.
|
|
* - Held in memory only. A restart invalidates every outstanding capability.
|
|
* - Rolling TTL, revoked on logout, admin logout and user deletion (`revokeOwner`).
|
|
*/
|
|
|
|
import { randomBytes } from 'node:crypto';
|
|
import { StaleExpirationMap } from './utils/index.js';
|
|
import { MAX_WEBVIEW_CAPABILITIES, WEBVIEW_CAPABILITY_TTL_MS } from './config/webview-limits.js';
|
|
|
|
/** URL prefix the rendered files are served under. */
|
|
export const HTML_VIEW_PREFIX = '/html-view';
|
|
|
|
export interface HtmlViewCapabilityRecord {
|
|
/** Realpath of the directory the capability serves. */
|
|
rootDir: string;
|
|
/** Username that minted it (multi-user); undefined in single-user mode. */
|
|
owner?: string;
|
|
/**
|
|
* Whether the directory lies outside the minting session's workspace (admitted
|
|
* under the attachment guard). Recorded so the serving route can refuse it the
|
|
* moment workspace confinement is switched on, as the attachment routes do.
|
|
*/
|
|
outsideWorkspace: boolean;
|
|
createdAt: number;
|
|
}
|
|
|
|
export class HtmlViewCapabilityStore {
|
|
private readonly capabilities: StaleExpirationMap<string, HtmlViewCapabilityRecord>;
|
|
/** Reverse index so re-opening a page reuses its capability instead of leaking one per click. */
|
|
private readonly byRoot = new Map<string, string>();
|
|
|
|
constructor(ttlMs: number = WEBVIEW_CAPABILITY_TTL_MS) {
|
|
this.capabilities = new StaleExpirationMap<string, HtmlViewCapabilityRecord>({
|
|
ttlMs,
|
|
refreshOnGet: true,
|
|
onExpire: (token, record) => {
|
|
const key = rootKey(record.rootDir, record.owner, record.outsideWorkspace);
|
|
if (this.byRoot.get(key) === token) this.byRoot.delete(key);
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Mint (or reuse) a capability for a directory. Returns the token. */
|
|
mint(rootDir: string, owner?: string, outsideWorkspace = false): string {
|
|
// The flag is part of the key: an out-of-workspace mint must never reuse a
|
|
// capability recorded as in-workspace, which confinement would not stop.
|
|
const key = rootKey(rootDir, owner, outsideWorkspace);
|
|
const existing = this.byRoot.get(key);
|
|
if (existing && this.capabilities.get(existing)) return existing;
|
|
if (existing) this.byRoot.delete(key);
|
|
|
|
if (this.capabilities.size >= MAX_WEBVIEW_CAPABILITIES) this.capabilities.cleanup();
|
|
|
|
const token = randomBytes(24).toString('base64url');
|
|
this.capabilities.set(token, { rootDir, owner, outsideWorkspace, createdAt: Date.now() });
|
|
this.byRoot.set(key, token);
|
|
return token;
|
|
}
|
|
|
|
/** Resolve a capability, refreshing its TTL. Returns undefined when unknown or expired. */
|
|
resolve(token: string): HtmlViewCapabilityRecord | undefined {
|
|
if (!token) return undefined;
|
|
return this.capabilities.get(token);
|
|
}
|
|
|
|
/** Revoke every capability bound to an identity (logout, admin logout, user deletion). */
|
|
revokeOwner(owner: string | undefined): number {
|
|
let revoked = 0;
|
|
for (const [key, token] of [...this.byRoot]) {
|
|
const record = this.capabilities.peek(token);
|
|
if (!record || record.owner === owner) {
|
|
this.capabilities.delete(token);
|
|
this.byRoot.delete(key);
|
|
if (record) revoked++;
|
|
}
|
|
}
|
|
return revoked;
|
|
}
|
|
|
|
get size(): number {
|
|
return this.capabilities.size;
|
|
}
|
|
|
|
dispose(): void {
|
|
this.capabilities.dispose();
|
|
this.byRoot.clear();
|
|
}
|
|
}
|
|
|
|
function rootKey(rootDir: string, owner: string | undefined, outsideWorkspace: boolean): string {
|
|
return `${owner ?? ''}\0${outsideWorkspace ? 'out' : 'in'}\0${rootDir}`;
|
|
}
|
|
|
|
/** The capability token in an `/html-view/<cap>/...` path, or null. */
|
|
export function capabilityFromHtmlViewPath(pathname: string): string | null {
|
|
if (typeof pathname !== 'string') return null;
|
|
const prefix = `${HTML_VIEW_PREFIX}/`;
|
|
if (!pathname.startsWith(prefix)) return null;
|
|
const rest = pathname.slice(prefix.length);
|
|
const slash = rest.indexOf('/');
|
|
const cap = slash === -1 ? rest : rest.slice(0, slash);
|
|
return /^[A-Za-z0-9_-]{16,128}$/.test(cap) ? cap : null;
|
|
}
|
|
|
|
/** Process-wide store, shared by the routes and the auth middleware (see webviewCapabilities). */
|
|
export const htmlViewCapabilities = new HtmlViewCapabilityStore();
|