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

+37 -2
View File
@@ -7,11 +7,13 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import Fastify from 'fastify';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { isBroadHtmlViewRoot, registerHtmlViewRoutes } from '../../src/web/routes/html-view-routes.js';
import { HTML_VIEW_CSP, isBroadHtmlViewRoot, registerHtmlViewRoutes } from '../../src/web/routes/html-view-routes.js';
import { registerSecurityHeaders } from '../../src/web/middleware/auth.js';
import { capabilityFromHtmlViewPath, htmlViewCapabilities } from '../../src/html-view-capabilities.js';
describe('html-view routes', () => {
@@ -94,6 +96,39 @@ describe('html-view routes', () => {
expect(res.body).toBe('<h1>hi</h1>');
});
it("serves exactly the sandbox CSP with open network, replacing Codeman's own policy (owner decision)", async () => {
// Owner decision 2026-10-13 (#547): a rendered page reaches the network like a
// file opened from disk, so the policy is the `sandbox` directive alone. Built
// with Codeman's own security headers registered first, as in production, to
// prove their `default-src 'self'` policy does not survive onto the page.
const app = Fastify({ logger: false });
registerSecurityHeaders(app, false);
registerHtmlViewRoutes(app, harness.ctx as unknown as Parameters<typeof registerHtmlViewRoutes>[1]);
await app.ready();
try {
const cap = htmlViewCapabilities.mint(realpathSync(workspace));
for (const file of ['index.html', 'data.json']) {
const res = await app.inject({ method: 'GET', url: `/html-view/${cap}/${file}` });
expect(res.statusCode, file).toBe(200);
const csp = res.headers['content-security-policy'];
// One header, this exact value.
expect(csp, file).toBe(HTML_VIEW_CSP);
expect(HTML_VIEW_CSP).toBe('sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads');
const directives = String(csp)
.split(';')
.map((d) => d.trim().split(/\s+/)[0])
.filter(Boolean);
// Sandboxed, never same-origin...
expect(directives).toEqual(['sandbox']);
expect(String(csp)).not.toContain('allow-same-origin');
// ...and no fetch directive anywhere: the network is not restricted.
expect(String(csp)).not.toMatch(/-src\b/);
}
} finally {
await app.close();
}
});
it('answers Range requests, so a page can seek its own media', async () => {
writeFileSync(join(workspace, 'clip.mp4'), '0123456789');
const base = (await openUrl()).replace(/index\.html$/, '');