/** * @fileoverview Tests for html-view-routes: rendering a local HTML file as a page. * * Real files in a temp dir (no fs mocks): the point of the route is its path * policy, and a mocked realpath would make every containment check pass. * Port: N/A (app.inject doesn't open ports) */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 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 { 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', () => { let harness: RouteTestHarness; let sid: string; let root: string; let workspace: string; beforeEach(async () => { root = mkdtempSync(join(tmpdir(), 'html-view-')); workspace = join(root, 'ws'); mkdirSync(join(workspace, 'assets'), { recursive: true }); writeFileSync(join(workspace, 'index.html'), '
feature
'); harness = await createRouteTestHarness(registerHtmlViewRoutes); sid = harness.ctx._sessionId as string; (harness.ctx.sessions.get(sid) as { workingDir: string }).workingDir = workspace; }); afterEach(async () => { await harness.app.close(); htmlViewCapabilities.revokeOwner(undefined); rmSync(root, { recursive: true, force: true }); }); async function open(path: string) { return harness.app.inject({ method: 'POST', url: `/api/sessions/${sid}/html-view`, payload: { path } }); } async function openUrl(): Promisereport
'); }); afterEach(() => { for (const [key, value] of [ ['CODEMAN_ATTACHMENT_CONFINE', saved.confine], ['CODEMAN_ATTACHMENT_BLOCKED_PATHS', saved.blocked], ] as const) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } }); it('mints for a page under the attachment guard', async () => { expect((await open(join(outsideDir, 'report.html'))).statusCode).toBe(200); }); it('refuses a page in a blocked tree', async () => { process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = outsideDir; expect((await open(join(outsideDir, 'report.html'))).statusCode).toBe(403); }); it('stops serving an out-of-workspace page once confinement is switched on', async () => { const res = await open(join(outsideDir, 'report.html')); expect(res.statusCode).toBe(200); const url = JSON.parse(res.body).data.url as string; expect((await harness.app.inject({ method: 'GET', url })).statusCode).toBe(200); const inside = await openUrl(); process.env.CODEMAN_ATTACHMENT_CONFINE = '1'; expect((await harness.app.inject({ method: 'GET', url })).statusCode).toBe(404); // A page minted from inside the workspace is not affected. expect((await harness.app.inject({ method: 'GET', url: inside })).statusCode).toBe(200); }); it('never reuses an in-workspace capability for the same folder minted from outside', () => { const inside = htmlViewCapabilities.mint(outsideDir, undefined, false); const outside = htmlViewCapabilities.mint(outsideDir, undefined, true); expect(outside).not.toBe(inside); expect(htmlViewCapabilities.resolve(outside)?.outsideWorkspace).toBe(true); }); it('refuses every out-of-workspace page when attachments are confined to the workspace', async () => { process.env.CODEMAN_ATTACHMENT_CONFINE = '1'; expect((await open(join(outsideDir, 'report.html'))).statusCode).toBe(403); // The workspace itself is still fine. expect((await open(join(workspace, 'index.html'))).statusCode).toBe(200); }); }); describe('in a worktree outside the workspace (#765 follow-up)', () => { let sibling: string; const saved = { confine: process.env.CODEMAN_ATTACHMENT_CONFINE, blocked: process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS, }; beforeEach(() => { sibling = realpathSync(join(root, 'sibling-feature')); Object.assign(harness.ctx.sessions.get(sid) as object, { liveWorktree: { name: 'sibling-feature', path: sibling, outside: true }, }); }); afterEach(() => { for (const [key, value] of [ ['CODEMAN_ATTACHMENT_CONFINE', saved.confine], ['CODEMAN_ATTACHMENT_BLOCKED_PATHS', saved.blocked], ] as const) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } }); it('renders a page from the worktree, minted as outside the workspace', async () => { const res = await open(join(sibling, 'report.html')); expect(res.statusCode).toBe(200); const url = JSON.parse(res.body).data.url as string; const page = await harness.app.inject({ method: 'GET', url }); expect(page.statusCode).toBe(200); expect(page.body).toBe('feature
'); // Minted as outside the workspace, so the serve route's per-request confinement check covers it. expect(htmlViewCapabilities.resolve(capabilityFromHtmlViewPath(url) as string)?.outsideWorkspace).toBe(true); }); it('answers to the attachment guard: confinement on, or a blocked tree', async () => { const res = await open(join(sibling, 'report.html')); const url = JSON.parse(res.body).data.url as string; process.env.CODEMAN_ATTACHMENT_CONFINE = '1'; expect((await open(join(sibling, 'report.html'))).statusCode).toBe(403); // A page minted before confinement was switched on stops serving too. expect((await harness.app.inject({ method: 'GET', url })).statusCode).toBe(404); delete process.env.CODEMAN_ATTACHMENT_CONFINE; process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = sibling; expect((await open(join(sibling, 'report.html'))).statusCode).toBe(403); }); it('leaves a path outside both roots as it was', async () => { mkdirSync(join(root, 'reports')); writeFileSync(join(root, 'reports', 'report.html'), 'report
'); expect((await open(join(root, 'reports', 'report.html'))).statusCode).toBe(200); process.env.CODEMAN_ATTACHMENT_CONFINE = '1'; expect((await open(join(root, 'reports', 'report.html'))).statusCode).toBe(403); }); }); it('refuses an HTML file inside a hidden directory, in or out of the workspace', async () => { mkdirSync(join(workspace, '.cache')); writeFileSync(join(workspace, '.cache', 'r.html'), 'r
'); writeFileSync(join(workspace, '.cache', 'token.json'), '{}'); expect((await open(join(workspace, '.cache', 'r.html'))).statusCode).toBe(403); mkdirSync(join(root, '.hidden')); writeFileSync(join(root, '.hidden', 'r.html'), 'r
'); expect((await open(join(root, '.hidden', 'r.html'))).statusCode).toBe(403); }); it('renders a page in a workspace that itself lives under a hidden folder', async () => { const hiddenWorkspace = join(root, '.local', 'src', 'app'); mkdirSync(join(hiddenWorkspace, 'report'), { recursive: true }); writeFileSync(join(hiddenWorkspace, 'report', 'index.html'), 'r
'); mkdirSync(join(hiddenWorkspace, '.cache')); writeFileSync(join(hiddenWorkspace, '.cache', 'r.html'), 'r
'); (harness.ctx.sessions.get(sid) as { workingDir: string }).workingDir = hiddenWorkspace; const res = await open(join(hiddenWorkspace, 'report', 'index.html')); expect(res.statusCode).toBe(200); expect((await harness.app.inject({ method: 'GET', url: JSON.parse(res.body).data.url })).statusCode).toBe(200); // A hidden directory BELOW the workspace root is still refused. expect((await open(join(hiddenWorkspace, '.cache', 'r.html'))).statusCode).toBe(403); }); it('treats the filesystem root, home and the temp dir as too broad to serve', () => { expect(isBroadHtmlViewRoot('/')).toBe(true); expect(isBroadHtmlViewRoot(homedir())).toBe(true); expect(isBroadHtmlViewRoot(tmpdir())).toBe(true); expect(isBroadHtmlViewRoot(workspace)).toBe(false); }); it('treats /tmp, /var/tmp and every directory above a broad root as broad, whatever TMPDIR says', async () => { // macOS sets TMPDIR to a per-user /var/folders dir in every terminal, which // os.tmpdir() returns, so the literal shared temp dirs must not depend on it. const savedTmpdir = process.env.TMPDIR; process.env.TMPDIR = join(root, 'private-tmp'); mkdirSync(process.env.TMPDIR); const page = join('/tmp', `html-view-broad-literal-${process.pid}.html`); writeFileSync(page, 'broad
'); try { expect(tmpdir()).toBe(process.env.TMPDIR); expect(isBroadHtmlViewRoot('/tmp')).toBe(true); expect(isBroadHtmlViewRoot('/var/tmp')).toBe(true); expect((await open(page)).statusCode).toBe(403); } finally { rmSync(page, { force: true }); if (savedTmpdir === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = savedTmpdir; } // Above the home directory (`/home`, `/Users`): serving it would serve home too. expect(isBroadHtmlViewRoot(join(homedir(), '..'))).toBe(true); // A drive's mount point and the mount parent itself. expect(isBroadHtmlViewRoot('/mnt')).toBe(true); expect(isBroadHtmlViewRoot('/mnt/d')).toBe(true); expect(isBroadHtmlViewRoot('/mnt/d/projects/report')).toBe(false); }); it('refuses a page sitting directly in the temp dir', async () => { const page = join(tmpdir(), `html-view-broad-${process.pid}.html`); writeFileSync(page, 'broad
'); try { expect((await open(page)).statusCode).toBe(403); } finally { rmSync(page, { force: true }); } }); });