Merge pull request #547: feat(file-preview): render local HTML files as sandboxed pages

Conflicts resolved:
- CLAUDE.md: kept master's Prompt uploads paragraph and its longer
  File-path links paragraph (XLSX admission limits), and inserted the
  PR's html-view sentence after the download-only rule.
- src/web/public/terminal-ui.js: kept master's tile-aware
  getSessionId() in the shared file-path link provider and added the
  PR's .html routing to the file preview on top of it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-13 17:03:00 +02:00
commit 2797098aa5
18 files changed
+714 -10

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
/**
* @fileoverview Capability tokens for rendering a local HTML file.
*
* `file-raw` never serves HTML as HTML: a page rendered from Codeman's own origin
* could read the Codeman document and drive the agent-spawning API. So an HTML
* file is rendered from `/html-view/:cap/*` instead, with a `Content-Security-
* Policy: sandbox` header that puts the document in an OPAQUE origin no matter how
* it is opened (iframe or top-level tab). That opaque origin is also why the route
* cannot authenticate on the session cookie: its subresource requests are
* cross-site, so the `SameSite=lax` cookie is never attached (the same reason the
* web-tab proxy uses capabilities, see webview-capabilities.ts).
*
* A capability is minted by an authenticated `POST /api/sessions/:id/html-view` and
* grants exactly one thing: GET access to the files in ONE directory (the HTML
* file's own), so a page's relative `style.css` / `chart.js` / images resolve.
*
* - 192 bits of `randomBytes` entropy, base64url.
* - Held in memory only. A restart invalidates every outstanding capability.
* - Rolling TTL, revoked on logout, admin logout and user deletion (`revokeOwner`).
*/
import { randomBytes } from 'node:crypto';
import { StaleExpirationMap } from './utils/index.js';
import { MAX_WEBVIEW_CAPABILITIES, WEBVIEW_CAPABILITY_TTL_MS } from './config/webview-limits.js';
/** URL prefix the rendered files are served under. */
export const HTML_VIEW_PREFIX = '/html-view';
export interface HtmlViewCapabilityRecord {
/** Realpath of the directory the capability serves. */
rootDir: string;
/** Username that minted it (multi-user); undefined in single-user mode. */
owner?: string;
createdAt: number;
}
export class HtmlViewCapabilityStore {
private readonly capabilities: StaleExpirationMap<string, HtmlViewCapabilityRecord>;
/** Reverse index so re-opening a page reuses its capability instead of leaking one per click. */
private readonly byRoot = new Map<string, string>();
constructor(ttlMs: number = WEBVIEW_CAPABILITY_TTL_MS) {
this.capabilities = new StaleExpirationMap<string, HtmlViewCapabilityRecord>({
ttlMs,
refreshOnGet: true,
onExpire: (token, record) => {
const key = rootKey(record.rootDir, record.owner);
if (this.byRoot.get(key) === token) this.byRoot.delete(key);
},
});
}
/** Mint (or reuse) a capability for a directory. Returns the token. */
mint(rootDir: string, owner?: string): string {
const key = rootKey(rootDir, owner);
const existing = this.byRoot.get(key);
if (existing && this.capabilities.get(existing)) return existing;
if (existing) this.byRoot.delete(key);
if (this.capabilities.size >= MAX_WEBVIEW_CAPABILITIES) this.capabilities.cleanup();
const token = randomBytes(24).toString('base64url');
this.capabilities.set(token, { rootDir, owner, createdAt: Date.now() });
this.byRoot.set(key, token);
return token;
}
/** Resolve a capability, refreshing its TTL. Returns undefined when unknown or expired. */
resolve(token: string): HtmlViewCapabilityRecord | undefined {
if (!token) return undefined;
return this.capabilities.get(token);
}
/** Revoke every capability bound to an identity (logout, admin logout, user deletion). */
revokeOwner(owner: string | undefined): number {
let revoked = 0;
for (const [key, token] of [...this.byRoot]) {
const record = this.capabilities.peek(token);
if (!record || record.owner === owner) {
this.capabilities.delete(token);
this.byRoot.delete(key);
if (record) revoked++;
}
}
return revoked;
}
get size(): number {
return this.capabilities.size;
}
dispose(): void {
this.capabilities.dispose();
this.byRoot.clear();
}
}
function rootKey(rootDir: string, owner: string | undefined): string {
return `${owner ?? ''}\0${rootDir}`;
}
/** The capability token in an `/html-view/<cap>/...` path, or null. */
export function capabilityFromHtmlViewPath(pathname: string): string | null {
if (typeof pathname !== 'string') return null;
const prefix = `${HTML_VIEW_PREFIX}/`;
if (!pathname.startsWith(prefix)) return null;
const rest = pathname.slice(prefix.length);
const slash = rest.indexOf('/');
const cap = slash === -1 ? rest : rest.slice(0, slash);
return /^[A-Za-z0-9_-]{16,128}$/.test(cap) ? cap : null;
}
/** Process-wide store, shared by the routes and the auth middleware (see webviewCapabilities). */
export const htmlViewCapabilities = new HtmlViewCapabilityStore();
+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;
+1
View File
@@ -615,6 +615,7 @@
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
<div class="file-preview-actions">
<button class="btn-icon-sm file-preview-pill" id="filePreviewMdBtn" onclick="app.toggleFilePreviewMd()" title="Rendered markdown" aria-label="Rendered markdown" aria-pressed="true" hidden>MD</button>
<button class="btn-icon-sm file-preview-pill" id="filePreviewPageBtn" onclick="app.toggleFilePreviewPage()" title="Rendered page" aria-label="Rendered page" aria-pressed="true" hidden>Page</button>
<button class="btn-icon-sm" id="filePreviewLinesBtn" onclick="app.toggleFilePreviewLines()" title="Line numbers" aria-label="Line numbers" aria-pressed="false" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg></button>
<button class="btn-icon-sm" id="filePreviewWrapBtn" onclick="app.toggleFilePreviewWrap()" title="Wrap lines" aria-label="Wrap lines" aria-pressed="true" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg></button>
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
+61 -3
View File
@@ -24,16 +24,18 @@ const TEXT_PREVIEW_MAX_LINES = 500;
// cap: a rendered README cut mid-way reads as the whole document.
const MARKDOWN_PREVIEW_MAX_LINES = 10000;
const MARKDOWN_EXTS = new Set(['md', 'markdown']);
const HTML_PAGE_EXTS = new Set(['html', 'htm']);
// File Viewer text-view prefs: per-device, in their own localStorage keys for
// the same reason as FILE_BROWSER_SHOW_HIDDEN_KEY (the app-settings object is
// rebuilt from the settings modal on save, so a key toggled from the viewer
// would be dropped on the next save).
const FILE_PREVIEW_PREF_KEYS = {
mdRendered: 'codeman:filePreviewMdRendered',
htmlRendered: 'codeman:filePreviewHtmlRendered',
lineNumbers: 'codeman:filePreviewLineNumbers',
wrap: 'codeman:filePreviewWrap',
};
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, lineNumbers: false, wrap: true };
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, htmlRendered: true, lineNumbers: false, wrap: true };
const AWAY_DIGEST_SECTIONS = [
['needsAttention', 'Needs Attention'],
['completed', 'Completed'],
@@ -4106,6 +4108,8 @@ Object.assign(CodemanApp.prototype, {
// Same for the text-view toggles: they act on the text this load has not
// fetched yet, and an image or PDF has nothing for them to toggle.
this.filePreviewText = null;
this.filePreviewPage = null;
this.filePreviewPageRefused = false;
this._updateFilePreviewToolbar('none');
// Show overlay with loading state
@@ -4116,6 +4120,27 @@ 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.
// The Page pill switches to the source view below (with its Edit button), and
// a refused mint (remote case, broad folder, hidden directory) falls through
// to that source view on its own.
if (!attachmentId && HTML_PAGE_EXTS.has(ext) && this._filePreviewPref('htmlRendered')) {
const pageUrl = await this._mintHtmlViewUrl(filePath, sessionId);
if (pageUrl) {
footerEl.textContent = 'HTML';
this.filePreviewPage = { filePath, sessionId };
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>`;
this._updateFilePreviewToolbar('page');
return;
}
// The pill would only retry a page the server just refused.
this.filePreviewPageRefused = true;
}
// 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"
@@ -4571,8 +4596,9 @@ Object.assign(CodemanApp.prototype, {
/**
* Show the toggles that apply to the current view: MD for a markdown file in
* either view, Lines/Wrap for the plain-text view only; `'none'` (loading,
* image, media, PDF, edit mode) hides all three.
* either view, Page for an HTML file in either view (`'page'` = rendered),
* Lines/Wrap for the plain-text view only; `'none'` (loading, image, media,
* PDF, edit mode) hides them all.
*/
_updateFilePreviewToolbar(view) {
const set = (id, shown, pressed) => {
@@ -4583,10 +4609,42 @@ Object.assign(CodemanApp.prototype, {
};
const isMarkdown = view !== 'none' && MARKDOWN_EXTS.has(this.filePreviewText?.ext || '');
set('filePreviewMdBtn', isMarkdown, view === 'markdown');
// Page: shown on a rendered HTML page, and on its source view when that view
// can be re-rendered (a bare attachment-card name has no path to mint from).
const text = this.filePreviewText;
const htmlSource =
view === 'text' &&
!this.filePreviewPageRefused &&
HTML_PAGE_EXTS.has(text?.ext || '') &&
(!text.attachmentId || text.filePath.startsWith('/'));
set('filePreviewPageBtn', view === 'page' || htmlSource, view === 'page');
set('filePreviewLinesBtn', view === 'text', this._filePreviewPref('lineNumbers'));
set('filePreviewWrapBtn', view === 'text', this._filePreviewPref('wrap'));
},
/** Mint the sandboxed page URL for an HTML file; null when the server refuses. */
async _mintHtmlViewUrl(filePath, sessionId) {
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(() => ({}));
return res.ok && result.success ? CodemanBase.url(result.data.url) : null;
} catch {
return null;
}
},
/** Page pill: flip between the rendered page and the source view of an HTML file. */
toggleFilePreviewPage() {
const target = this.filePreviewPage || this.filePreviewText;
if (!target) return;
this._setFilePreviewPref('htmlRendered', !this._filePreviewPref('htmlRendered'));
this.openFilePreview(target.filePath, target.sessionId);
},
toggleFilePreviewMd() {
this._setFilePreviewPref('mdRendered', !this._filePreviewPref('mdRendered'));
this._renderFilePreviewText();
+3 -1
View File
@@ -2089,7 +2089,9 @@ Object.assign(CodemanApp.prototype, {
// reads those through the guarded attachment routes, so external
// paths route there and the two surfaces agree.
const sessionId = getSessionId();
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) {
// 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, sessionId)) {
self.openFilePreview(text, sessionId);
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, {});
+3 -1
View File
@@ -271,7 +271,9 @@ async function serveRawFile(
// would be stored XSS. SVG was always here; HTML/HTM join it now that the text
// family is servable, so widening what can be READ never widened what can RUN.
// The preview overlay reads these through `fetch()`, which ignores the
// disposition, so a clicked .html still shows its source.
// disposition, so the source view of a clicked .html still works. Rendering it
// as a page is html-view-routes.ts's job (sandboxed, opaque origin), never this
// route's.
const markupOnly = extension === 'svg' || extension === 'html' || extension === 'htm';
if (download || markupOnly) {
reply.header(
+231
View File
@@ -0,0 +1,231 @@
/**
* @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.
* Because that tree is served recursively, minting is refused for an HTML file in a
* hidden directory or directly in a broad root (`/`, home, the temp dir, the cases
* and user-space roots), see {@link isBroadHtmlViewRoot}.
*/
import type { FastifyInstance } from 'fastify';
import { createReadStream, realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
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 { CASES_DIR, findSessionOrFail, ownerFor, validateSessionFilePath } from '../route-helpers.js';
import { getUserSpacesDir } from '../../config/multiuser.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(/^\./, '');
}
/** realpath when the path exists, else the plain resolved path. */
function canonical(path: string): string {
try {
return realpathSync(path);
} catch {
return resolve(path);
}
}
/**
* Whether a directory is too broad to hand to a page as its asset root. The
* capability serves the HTML file's directory RECURSIVELY to the page's own
* scripts, so `/tmp/report.html` would expose every other session's scratch
* output under `/tmp`, and `~/report.html` the home tree. Refuse those roots
* (and the cases / user-space roots, which hold every workspace) outright.
*/
export function isBroadHtmlViewRoot(dir: string): boolean {
const target = canonical(dir);
const userSpaces = canonical(getUserSpacesDir());
const broad = [sep, homedir(), tmpdir(), CASES_DIR, userSpaces].map(canonical);
if (broad.includes(target)) return true;
// A user's own space (`<spaces>/<user>`) and its cases dir hold every workspace of that user.
if (dirname(target) === userSpaces) return true;
return basename(target) === 'cases' && dirname(dirname(target)) === userSpaces;
}
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;
}
// The serving route never descends into a dot segment below the capability
// root; the HTML file's own path gets the same rule, or `.cache/r.html` would
// mint a capability over a hidden directory and serve its `token.json`.
if (resolvedPath.split(sep).some((segment) => segment.startsWith('.'))) {
reply
.code(403)
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'HTML files in hidden directories cannot be rendered'));
return;
}
if (isBroadHtmlViewRoot(dirname(resolvedPath))) {
reply
.code(403)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'This folder is too broad to render a page from; move the HTML file into its own folder'
)
);
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
@@ -41,6 +41,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,
@@ -943,6 +944,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,
@@ -1127,6 +1128,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);