mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
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:
commit
2797098aa5
18 files changed
+714
-10
No files matched your search
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, {});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {};
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user