fix(html-view): address review — scope, source view, tests, docs

- Refuse to mint for an HTML file in a hidden directory or directly in a
  broad root (/, home, tmpdir, cases and user-space roots): the capability
  serves the directory tree recursively to the page's own scripts.
- Keep HTML's source view and editor: a Page pill (per-device htmlRendered,
  like MD) toggles to the file-content text path, and a refused mint (remote
  case, broad root, hidden dir) falls through to it automatically.
- Tests: html-view auth exemption edges, revocation on logout / admin logout /
  user deletion, out-of-workspace mint (blocked tree, confinement), hidden
  and broad roots.
- Docs: Working-With-Files, architecture-invariants, security-architecture,
  the file-routes comment and CLAUDE.md.

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-08 09:21:55 +02:00
1 parent 56978f2cc9
commit 91bdaee724
11 files changed
+275 -27

No files matched your search

+1
View File
@@ -587,6 +587,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>
+52 -18
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
@@ -4119,25 +4123,22 @@ Object.assign(CodemanApp.prototype, {
// 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);
// 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>`;
} catch (err) {
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
this._updateFilePreviewToolbar('page');
return;
}
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
@@ -4545,8 +4546,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) => {
@@ -4557,10 +4559,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
@@ -241,7 +241,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(
+53 -1
View File
@@ -18,16 +18,21 @@
* 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 { findSessionOrFail, ownerFor, validateSessionFilePath } from '../route-helpers.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';
@@ -77,6 +82,32 @@ 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 };
@@ -128,6 +159,27 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
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,