feat(file-preview): render local HTML files as sandboxed pages

Clicking an agent's `.html` path showed source text or downloaded it, so a
generated report could only be viewed by asking the agent to start a web
server. Render it instead, without serving HTML same-origin:

- POST /api/sessions/:id/html-view mints a capability for the file's own
  directory (workspace path, or outside it under the attachment guard).
- GET /html-view/:cap/* serves the page and its relative assets with
  `Content-Security-Policy: sandbox` WITHOUT allow-same-origin, so the page
  runs in an opaque origin (also as a top-level tab) and cannot read the
  Codeman document, its cookies or its API.
- Served files: non-dot, asset-allowlisted, realpath-confined to that
  directory, blocklist applied; GET only. Capabilities are memory-only with a
  rolling TTL and are revoked with the web-tab ones on logout.
- Frontend: the file preview renders HTML in a sandboxed iframe (popout opens
  the same URL); terminal `.html` links go to the preview, not the log viewer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
stefaan van hoofandClaude Opus 5.5 committed 2026-10-07 16:26:39 +02:00
1 parent ffaa5ee80c
commit fe77b5238e
11 files changed
+462 -2

No files matched your search

+20
View File
@@ -23,6 +23,7 @@ import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import { capabilityFromHtmlViewPath, htmlViewCapabilities } from '../../html-view-capabilities.js';
import {
capabilityFromProxyPath,
capabilityFromReferer,
@@ -149,6 +150,18 @@ function isPasswordChangeExempt(req: FastifyRequest): boolean {
* match the prefix at all. The Host allowlist is NOT bypassed, so DNS-rebinding
* protection still applies to these requests.
*/
/**
* Whether a request is a GET for a rendered HTML file (`/html-view/<cap>/...`) with
* a live capability. Same reasoning as the web-tab proxy: the page is sandboxed into
* an opaque origin, so its asset requests carry no session cookie. Read-only by
* construction (safe methods only), and the Host allowlist still applies.
*/
function hasValidHtmlViewCapability(req: FastifyRequest): boolean {
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
const cap = capabilityFromHtmlViewPath((req.url ?? '').split('?')[0]);
return !!cap && htmlViewCapabilities.resolve(cap) !== undefined;
}
function hasValidWebviewCapability(req: FastifyRequest, basePath = ''): boolean {
// req.url is already base-stripped by the server's rewriteUrl, so the path form
// needs no base; the Referer form below is browser-supplied and does.
@@ -368,6 +381,11 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
done();
return;
}
// Rendered HTML file, likewise authenticated by its capability.
if (hasValidHtmlViewCapability(req)) {
done();
return;
}
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
if (serveLostWebviewFrame(req, reply)) return;
@@ -507,6 +525,8 @@ function registerMultiUserAuthHook(
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
// than re-deriving it from a request that carries no credentials.
if (hasValidWebviewCapability(req, basePath)) return;
// Rendered HTML file, likewise authenticated by its capability.
if (hasValidHtmlViewCapability(req)) return;
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
if (serveLostWebviewFrame(req, reply)) return;
+24
View File
@@ -4116,6 +4116,30 @@ Object.assign(CodemanApp.prototype, {
const ext = (filePath.split('.').pop() || '').toLowerCase();
// HTML renders as a page: the server mints a sandboxed /html-view URL for the
// file's directory (html-view-routes.ts), so relative CSS, scripts and images
// resolve, and the page runs in an opaque origin that cannot reach Codeman.
// This branch comes first because the routes below serve HTML download-only.
if (!attachmentId && (ext === 'html' || ext === 'htm')) {
footerEl.textContent = 'HTML';
try {
const res = await fetch(`/api/sessions/${sessionId}/html-view`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: filePath }),
});
const result = await res.json().catch(() => ({}));
if (!res.ok || !result.success) throw new Error(result.error || 'Failed to open page');
const pageUrl = CodemanBase.url(result.data.url);
this.filePreviewDetachUrl = pageUrl;
if (detachBtn) detachBtn.hidden = false;
bodyEl.innerHTML = `<iframe src="${escapeHtml(pageUrl)}" title="${escapeHtml(filePath)}" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-downloads"></iframe>`;
} catch (err) {
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
}
return;
}
// Out-of-workspace path: mint an attachment id up front. Every branch below
// talks to a workspace-confined route, so without this the image/PDF ones
// render a broken frame and the text one reports a bare "File not found"
+7 -1
View File
@@ -1978,7 +1978,13 @@ Object.assign(CodemanApp.prototype, {
// path clicked in the response viewer previewed fine. The preview
// reads those through the guarded attachment routes, so external
// paths route there and the two surfaces agree.
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) {
// An HTML file renders as a page in the preview, never as source
// in the log viewer.
if (
previewsInFileViewer(text) ||
/\.html?$/i.test(text) ||
self._isExternalPreviewPath(text, self.activeSessionId)
) {
self.openFilePreview(text, self.activeSessionId);
return;
}
+3
View File
@@ -36,6 +36,7 @@ import {
} from '../../user-store.js';
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import { htmlViewCapabilities } from '../../html-view-capabilities.js';
import { appendAdminAudit } from '../admin-audit.js';
import { SseEvent } from '../sse-events.js';
import type { AuthPort } from '../ports/auth-port.js';
@@ -183,6 +184,7 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
// Web-tab proxy capabilities are a second credential the cookie purge does not
// touch; a forced logout that left them alive would not be a logout.
const revokedWebviews = webviewCapabilities.revokeOwner(normalizeUsername(username));
htmlViewCapabilities.revokeOwner(normalizeUsername(username));
audit(req, 'user.logout', username, { revoked, revokedWebviews });
return { success: true, data: { revoked } };
});
@@ -206,6 +208,7 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
}
revokeUserSessions(ctx.authSessions, username);
webviewCapabilities.revokeOwner(normalizeUsername(username));
htmlViewCapabilities.revokeOwner(normalizeUsername(username));
if (deleteSpace) await deleteUserSpace(username);
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
ctx.broadcast(SseEvent.AdminUsersChanged, {});
+179
View File
@@ -0,0 +1,179 @@
/**
* @fileoverview Render a local HTML file (an agent's report, a generated page) as a
* page instead of as source text.
*
* Routes:
* - `POST /api/sessions/:id/html-view` (authenticated) — `{ path }` of an `.html` file;
* returns `{ url }`, an `/html-view/<cap>/<file>` URL for that file.
* - `GET /html-view/:cap/*` (capability) — the HTML file and its sibling assets.
*
* ⚠️ Every response carries `Content-Security-Policy: sandbox ...` WITHOUT
* `allow-same-origin`, so the page runs in an opaque origin whether it is framed or
* 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.
*
* 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
* capability then serves ONLY the files in that file's own directory tree, filtered
* by the same blocklist plus an asset-extension allowlist and a no-dotfile rule, so
* a page cannot use its own directory to pull a `.env` out through the browser.
*/
import type { FastifyInstance } from 'fastify';
import { createReadStream, realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import { basename, dirname, extname, isAbsolute, resolve, sep } from 'node:path';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { HTML_VIEW_PREFIX, htmlViewCapabilities } from '../../html-view-capabilities.js';
import { findSessionOrFail, ownerFor, validateSessionFilePath } from '../route-helpers.js';
import { exceedsDownloadLimit } from '../../config/buffer-limits.js';
import type { SessionPort } from '../ports/index.js';
/** Extensions that open as a rendered page. */
export const HTML_VIEW_EXTENSIONS: ReadonlySet<string> = new Set(['html', 'htm']);
/** What a rendered page may load from its own directory, with the type it is served as. */
const ASSET_TYPES: Record<string, string> = {
html: 'text/html; charset=utf-8',
htm: 'text/html; charset=utf-8',
css: 'text/css; charset=utf-8',
js: 'text/javascript; charset=utf-8',
mjs: 'text/javascript; charset=utf-8',
json: 'application/json; charset=utf-8',
map: 'application/json; charset=utf-8',
txt: 'text/plain; charset=utf-8',
csv: 'text/csv; charset=utf-8',
md: 'text/plain; charset=utf-8',
svg: 'image/svg+xml',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
avif: 'image/avif',
ico: 'image/x-icon',
bmp: 'image/bmp',
woff: 'font/woff',
woff2: 'font/woff2',
ttf: 'font/ttf',
otf: 'font/otf',
wasm: 'application/wasm',
mp4: 'video/mp4',
webm: 'video/webm',
mp3: 'audio/mpeg',
wav: 'audio/wav',
pdf: 'application/pdf',
};
/**
* 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".
*/
const SANDBOX_CSP = 'sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads';
function extensionOf(path: string): string {
return extname(path).toLowerCase().replace(/^\./, '');
}
export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort): void {
app.post('/api/sessions/:id/html-view', async (req, reply) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const body = (req.body || {}) as { path?: string };
if (!body.path || typeof body.path !== 'string') {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
return;
}
if (session.remote) {
reply
.code(400)
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'HTML pages in a remote (SSH) case cannot be rendered'));
return;
}
if (!HTML_VIEW_EXTENSIONS.has(extensionOf(body.path))) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not an HTML file'));
return;
}
// Inside the workspace: the same check every file route uses. Outside it: the
// attachment guard, exactly as an external file preview is admitted.
let resolvedPath: string;
const inWorkspace = validateSessionFilePath(session.workingDir, body.path);
if (inWorkspace) {
resolvedPath = inWorkspace.resolvedPath;
} else {
if (!isAbsolute(body.path)) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
try {
resolvedPath = realpathSync(body.path);
} catch {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
const guard = await loadAttachmentGuardConfig();
if (guard.confineToWorkspace || isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
return;
}
}
const stat = await fs.stat(resolvedPath).catch(() => null);
if (!stat?.isFile()) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
const cap = htmlViewCapabilities.mint(dirname(resolvedPath), ownerFor(req));
return {
success: true,
data: { url: `${HTML_VIEW_PREFIX}/${cap}/${encodeURIComponent(basename(resolvedPath))}` },
};
});
app.get(`${HTML_VIEW_PREFIX}/:cap/*`, async (req, reply) => {
const { cap, '*': rest } = req.params as { cap: string; '*': string };
const record = htmlViewCapabilities.resolve(cap);
const notFound = () => reply.code(404).header('Content-Type', 'text/plain; charset=utf-8').send('Not found');
if (!record) return notFound();
// Fastify has already percent-decoded the wildcard. Hidden files and
// directories are never served, whatever their extension.
const segments = (rest || '').split('/').filter(Boolean);
if (segments.length === 0 || segments.some((s) => s.startsWith('.'))) return notFound();
const ext = extensionOf(segments[segments.length - 1]);
const contentType = ASSET_TYPES[ext];
if (!contentType) return notFound();
let resolvedPath: string;
try {
resolvedPath = realpathSync(resolve(record.rootDir, segments.join(sep)));
} catch {
return notFound();
}
// Symlinks are resolved above, so this is the real containment check.
if (!isUnderTree(resolvedPath, record.rootDir)) return notFound();
const guard = await loadAttachmentGuardConfig();
if (isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) return notFound();
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-Type', contentType);
reply.header('Content-Length', String(stat.size));
reply.header('Cache-Control', 'no-store');
reply.header('Referrer-Policy', 'no-referrer');
// The sandboxed page is opaque-origin, so even its fetch() of a sibling
// `data.json` is CORS-checked with `Origin: null`. `*` without credentials is
// enough, and widens nothing: these bytes are already readable by anyone
// holding the capability URL.
reply.header('Access-Control-Allow-Origin', '*');
return reply.send(createReadStream(resolvedPath));
});
}
+1
View File
@@ -6,6 +6,7 @@ export { registerPushRoutes } from './push-routes.js';
export { registerTeamRoutes } from './team-routes.js';
export { registerMuxRoutes } from './mux-routes.js';
export { registerFileRoutes } from './file-routes.js';
export { registerHtmlViewRoutes } from './html-view-routes.js';
export { registerScheduledRoutes } from './scheduled-routes.js';
export { registerCronRoutes } from './cron-routes.js';
export { registerSystemRoutes } from './system-routes.js';
+2
View File
@@ -40,6 +40,7 @@ import {
import type { PaneCaptureOptions } from '../../mux-interface.js';
import { SseEvent } from '../sse-events.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import { htmlViewCapabilities } from '../../html-view-capabilities.js';
import {
CreateSessionSchema,
SessionNameSchema,
@@ -849,6 +850,7 @@ export function registerSessionRoutes(
// logout has to retire them too or every dashboard URL opened during this
// login keeps relaying without one (WebviewCapabilityStore.revokeOwner).
webviewCapabilities.revokeOwner(ownerFor(req));
htmlViewCapabilities.revokeOwner(ownerFor(req));
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
return {};
});
+2
View File
@@ -178,6 +178,7 @@ import {
registerTeamRoutes,
registerMuxRoutes,
registerFileRoutes,
registerHtmlViewRoutes,
registerScheduledRoutes,
registerHookEventRoutes,
registerApprovalRoutes,
@@ -1116,6 +1117,7 @@ export class WebServer extends EventEmitter {
registerTeamRoutes(this.app, ctx);
registerMuxRoutes(this.app, ctx);
registerFileRoutes(this.app, ctx);
registerHtmlViewRoutes(this.app, ctx);
registerScheduledRoutes(this.app, ctx);
registerHookEventRoutes(this.app, ctx);
registerApprovalRoutes(this.app, ctx);