mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
fix: security hardening and cleanup from community PR cherry-picks
- Add HTML sanitizer for markdown rendering (XSS prevention) - Switch service worker to network-first caching (deploys take effect immediately) - Sanitize Content-Disposition filenames (header injection prevention) - Expose session.muxName getter, replace unsafe `as any` cast - Static import for execFile, update CLAUDE.md keyboard shortcuts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -530,6 +530,11 @@ export class Session extends EventEmitter {
|
||||
return this._claudeSessionId;
|
||||
}
|
||||
|
||||
/** The tmux session name, if the session is running inside a mux */
|
||||
get muxName(): string | null {
|
||||
return this._muxSession?.muxName ?? null;
|
||||
}
|
||||
|
||||
get totalCost(): number {
|
||||
return this._totalCost;
|
||||
}
|
||||
|
||||
+30
-1
@@ -898,11 +898,40 @@ class CodemanApp {
|
||||
// Response Viewer — native-scroll panel for reading full Claude responses
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Strip dangerous elements and attributes from HTML (XSS prevention) */
|
||||
_sanitizeHtml(html) {
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = html;
|
||||
const frag = tpl.content;
|
||||
// Remove dangerous elements
|
||||
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
|
||||
el.remove();
|
||||
}
|
||||
// Strip dangerous attributes from all elements
|
||||
for (const el of frag.querySelectorAll('*')) {
|
||||
for (const attr of [...el.attributes]) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (name.startsWith('on')) {
|
||||
el.removeAttribute(attr.name);
|
||||
} else if (['href', 'src', 'action', 'xlink:href', 'formaction'].includes(name)) {
|
||||
const val = attr.value.replace(/\s/g, '').toLowerCase();
|
||||
if (val.startsWith('javascript:') || val.startsWith('vbscript:') || val.startsWith('data:text/html')) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Serialize back via a container
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(frag);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
|
||||
_renderMarkdown(text) {
|
||||
if (typeof marked !== 'undefined' && marked.parse) {
|
||||
try {
|
||||
return marked.parse(text, { breaks: true, gfm: true });
|
||||
return this._sanitizeHtml(marked.parse(text, { breaks: true, gfm: true }));
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
// Fallback: escape HTML and preserve whitespace
|
||||
|
||||
@@ -72,7 +72,9 @@ self.addEventListener('activate', (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
// --- Fetch: network-first for API/navigation, cache-first for static ---
|
||||
// --- Fetch: network-first with cache fallback ---
|
||||
// Network-first ensures deploys take effect immediately when online.
|
||||
// Cache is only used when the network is unavailable (offline/flaky).
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
@@ -84,18 +86,15 @@ self.addEventListener('fetch', (event) => {
|
||||
if (request.url.includes('/api/')) return;
|
||||
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
// Return cache immediately, refresh in background (stale-while-revalidate)
|
||||
const fetchPromise = fetch(request).then((response) => {
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response && response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
}
|
||||
return response;
|
||||
}).catch(() => cached);
|
||||
|
||||
return cached || fetchPromise;
|
||||
})
|
||||
})
|
||||
.catch(() => caches.match(request))
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -293,7 +293,9 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
|
||||
const content = await fs.readFile(resolvedPath);
|
||||
if (download === 'true') {
|
||||
const basename = filePath!.split('/').pop() || 'download';
|
||||
const rawBasename = filePath!.split('/').pop() || 'download';
|
||||
// Sanitize filename for Content-Disposition header (prevent header injection)
|
||||
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${basename}"`,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
@@ -545,13 +546,12 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const muxName = (session as any)._muxSession?.muxName;
|
||||
const muxName = session.muxName;
|
||||
if (!muxName) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
|
||||
}
|
||||
|
||||
try {
|
||||
const { execFile } = await import('child_process');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile('tmux', ['send-keys', '-H', '-t', muxName, ...hex], { timeout: 5000 }, (err) => {
|
||||
if (err) reject(err);
|
||||
|
||||
Reference in New Issue
Block a user