mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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:
1 parent
c1fde8e639
commit
ba5649bcd4
7 files changed
+70
-12
No files matched your search
@@ -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$/, '');
|
||||
|
||||
Reference in new issue
Block a user