feat(html-view): open network access for rendered pages (owner decision)

Owner decision 2026-10-13 (#547): a rendered .html page behaves like a
file opened from disk in a browser. It may load scripts, styles, fonts,
images and media from any host and make fetch, XHR, WebSocket, beacon
and form requests to any host.

The /html-view Content-Security-Policy is therefore the `sandbox`
directive alone (HTML_VIEW_CSP, now exported and documented): the same
sandbox flags as before, never `allow-same-origin`, and no fetch
directive. It replaces Codeman's own `default-src 'self'` policy on these
responses. Everything that confines a page stays: the opaque origin (no
Codeman cookies or document, API writes with `Origin: null` refused, API
reads not readable cross-origin), and the capability serving only the
page's own folder under every path, symlink, dot, extension and
confinement rule.

A new test builds the route behind registerSecurityHeaders, as in
production, and pins the exact header: one `sandbox` directive with the
current flags, no `allow-same-origin`, no `-src` directive. The security
architecture, architecture invariants, API reference, CLAUDE.md and the
Working With Files wiki page now say what a page can do (reach the
network, read its own folder) and cannot do (Codeman's origin, cookies,
API, other folders), and that this is the owner's decision.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-13 17:39:36 +02:00
1 parent c1fde8e639
commit ba5649bcd4
7 files changed
+70 -12

No files matched your search

+18 -4
View File
@@ -12,6 +12,7 @@
* opened as a top-level tab: it can run its own scripts but cannot read the Codeman
* document, its cookies or its API. That is what makes rendering safe at all; see
* html-view-capabilities.ts for why the route authenticates on a capability.
* Network access is deliberately OPEN (owner decision, see {@link HTML_VIEW_CSP}).
*
* Path policy: the HTML file itself is admitted like the file preview admits a file
* (inside the session workspace, or outside it under the attachment guard), and the
@@ -76,10 +77,23 @@ const ASSET_TYPES: Record<string, string> = {
};
/**
* The sandbox every response is served under. No `allow-same-origin`: that one
* token is the difference between "a page" and "a page that can drive Codeman".
* The whole Content-Security-Policy every response is served under: the `sandbox`
* directive and nothing else. It REPLACES Codeman's own policy (`default-src 'self'`
* and the rest, set earlier by registerSecurityHeaders). No `allow-same-origin`:
* that one token is the difference between "a page" and "a page that can drive
* Codeman".
*
* ⚠️ Network access is OPEN, by the owner's decision (2026-10-13, #547): a rendered
* page behaves like a file opened from disk in a browser, so there is deliberately
* no fetch directive (`default-src`, `script-src`, `connect-src`, ...). It loads
* scripts, styles, fonts, images and media from anywhere (an agent's dashboard
* pulling Chart.js from a CDN) and may fetch, XHR, open WebSockets, send beacons and
* post forms anywhere. What confines it is the opaque origin (no Codeman cookies, no
* Codeman API) and the capability serving only its own folder. Do not add a fetch
* directive, or `allow-same-origin`, without the owner. test/routes/html-view-routes.test.ts
* pins the exact value.
*/
const SANDBOX_CSP = 'sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads';
export const HTML_VIEW_CSP = 'sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads';
function extensionOf(path: string): string {
return extname(path).toLowerCase().replace(/^\./, '');
@@ -254,7 +268,7 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
const stat = await fs.stat(resolvedPath).catch(() => null);
if (!stat?.isFile() || exceedsDownloadLimit(stat.size)) return notFound();
reply.header('Content-Security-Policy', SANDBOX_CSP);
reply.header('Content-Security-Policy', HTML_VIEW_CSP);
reply.header('Content-Type', contentType);
reply.header('Cache-Control', 'no-store');
reply.header('Referrer-Policy', 'no-referrer');