mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 16:09:43 +02:00
feat(files): preview text files from outside the workspace, and stop routing them at a viewer that cannot read them
A .json/.log/.yaml/code path outside the session workspace was refused as an unsupported type, and clicking one in the terminal made it worse: text goes to the log viewer, which spawns `tail -f` and allows only the workspace, /var/log and ~/logs, so it answered "Path must be within working directory or allowed log directories" while the same path clicked in the response viewer previewed fine. Two surfaces, two answers, for a file the session can already cat. - TEXT_ATTACHMENT_EXTENSIONS IS EDITABLE_EXTENSIONS (config/file-editing.ts), not a second curated list that would drift from it. The rule reads: if the viewer would open a file for editing inside the workspace, the same file outside it can be read. The suffix was never the confidentiality gate here, the path guard is (sensitive-file blocklist, /root and /etc trees, realpath before the check), and it still runs on every registration. - Widening what can be READ must not widen what can RUN. html/htm join svg in serveRawFile's download-only branch, so markup is never served with a renderable type on our own origin; other text goes out as inert text/plain; charset=utf-8 with nosniff, matching what the path picker does. The preview reads through fetch(), which ignores the disposition, so a clicked .html still shows its source. - ~/.codeman*/state.json joins isSensitivePath. It persists SessionState.envOverrides and the env allowlist admits key-shaped names (GEMINI_API_KEY, CLAUDE_CODE_*), so it can hold a live credential. Same treatment as hook-secret and users.json, and the rest of the tree stays attachable. - The terminal sends an out-of-workspace path to the preview instead of the log viewer. In-workspace text keeps the tail viewer, which is the point of it, and file-stream-manager's allowlist is untouched: no `tail -f` on arbitrary host paths. - The by-id text preview is bounded like the workspace one: a Range request for the first 512KB (a real partial read, not a discarded 50MB download) plus a 500-line cap, with the footer saying so. Verified on an isolated instance: a 1.1MB external log opens in ~1.8s showing 500 lines with "showing first 500 lines" in the footer; json, yaml and code preview; an .html carrying a script tag renders as source and does not execute; .svg is still refused; a terminal click on an external .yaml opens the preview with no log viewer and no attachment card; an in-workspace .log still opens the streaming tail viewer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
buildFileThumbnailRoute,
|
||||
isSupportedAttachmentExtension,
|
||||
registerExternalAttachment,
|
||||
TEXT_ATTACHMENT_EXTENSIONS,
|
||||
VIDEO_ATTACHMENT_EXTENSIONS,
|
||||
type AttachmentRecord,
|
||||
} from '../../attachment-registry.js';
|
||||
@@ -193,10 +194,16 @@ async function serveRawFile(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (download || extension === 'svg') {
|
||||
// Markup is download-only: served with a renderable type on our own origin it
|
||||
// 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.
|
||||
const markupOnly = extension === 'svg' || extension === 'html' || extension === 'htm';
|
||||
if (download || markupOnly) {
|
||||
reply.header(
|
||||
'Content-Type',
|
||||
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
||||
markupOnly ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
||||
);
|
||||
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
@@ -204,6 +211,17 @@ async function serveRawFile(
|
||||
return;
|
||||
}
|
||||
|
||||
// Plain text with no dedicated MIME entry (code, config, logs, csv, xml) goes
|
||||
// out as inert text/plain rather than the octet-stream fallback, matching what
|
||||
// the path picker already does. Never a type the browser would execute.
|
||||
if (!MIME_TYPES[extension] && TEXT_ATTACHMENT_EXTENSIONS.has(extension)) {
|
||||
reply.header('Content-Type', 'text/plain; charset=utf-8');
|
||||
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendFileBody(reply, resolvedPath, stat.size, rangeHeader);
|
||||
return;
|
||||
}
|
||||
|
||||
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
||||
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
Reference in New Issue
Block a user