Skip to content

Commit b4a808a

Browse files
Ark0Nclaude
andcommitted
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>
1 parent f3cbe9b commit b4a808a

File tree

6 files changed

+49
-14
lines changed

6 files changed

+49
-14
lines changed

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
110110
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
111111
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
112112
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
113-
| **Frontend** | `src/web/public/app.js` (~2.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
113+
| **Frontend** | `src/web/public/app.js` (~2.8K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
114114
| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
115115
116116
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
@@ -150,7 +150,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
150150
151151
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
152152
153-
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+Enter (quick start), Ctrl+W (kill), Ctrl+Tab (next), Ctrl+K (kill all), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font).
153+
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font).
154154
155155
### Security
156156

src/session.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,11 @@ export class Session extends EventEmitter {
530530
return this._claudeSessionId;
531531
}
532532

533+
/** The tmux session name, if the session is running inside a mux */
534+
get muxName(): string | null {
535+
return this._muxSession?.muxName ?? null;
536+
}
537+
533538
get totalCost(): number {
534539
return this._totalCost;
535540
}

src/web/public/app.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -898,11 +898,40 @@ class CodemanApp {
898898
// Response Viewer — native-scroll panel for reading full Claude responses
899899
// ═══════════════════════════════════════════════════════════════
900900

901+
/** Strip dangerous elements and attributes from HTML (XSS prevention) */
902+
_sanitizeHtml(html) {
903+
const tpl = document.createElement('template');
904+
tpl.innerHTML = html;
905+
const frag = tpl.content;
906+
// Remove dangerous elements
907+
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
908+
el.remove();
909+
}
910+
// Strip dangerous attributes from all elements
911+
for (const el of frag.querySelectorAll('*')) {
912+
for (const attr of [...el.attributes]) {
913+
const name = attr.name.toLowerCase();
914+
if (name.startsWith('on')) {
915+
el.removeAttribute(attr.name);
916+
} else if (['href', 'src', 'action', 'xlink:href', 'formaction'].includes(name)) {
917+
const val = attr.value.replace(/\s/g, '').toLowerCase();
918+
if (val.startsWith('javascript:') || val.startsWith('vbscript:') || val.startsWith('data:text/html')) {
919+
el.removeAttribute(attr.name);
920+
}
921+
}
922+
}
923+
}
924+
// Serialize back via a container
925+
const div = document.createElement('div');
926+
div.appendChild(frag);
927+
return div.innerHTML;
928+
}
929+
901930
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
902931
_renderMarkdown(text) {
903932
if (typeof marked !== 'undefined' && marked.parse) {
904933
try {
905-
return marked.parse(text, { breaks: true, gfm: true });
934+
return this._sanitizeHtml(marked.parse(text, { breaks: true, gfm: true }));
906935
} catch { /* fall through */ }
907936
}
908937
// Fallback: escape HTML and preserve whitespace

src/web/public/sw.js

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ self.addEventListener('activate', (event) => {
7272
);
7373
});
7474

75-
// --- Fetch: network-first for API/navigation, cache-first for static ---
75+
// --- Fetch: network-first with cache fallback ---
76+
// Network-first ensures deploys take effect immediately when online.
77+
// Cache is only used when the network is unavailable (offline/flaky).
7678

7779
self.addEventListener('fetch', (event) => {
7880
const { request } = event;
@@ -84,18 +86,15 @@ self.addEventListener('fetch', (event) => {
8486
if (request.url.includes('/api/')) return;
8587

8688
event.respondWith(
87-
caches.match(request).then((cached) => {
88-
// Return cache immediately, refresh in background (stale-while-revalidate)
89-
const fetchPromise = fetch(request).then((response) => {
89+
fetch(request)
90+
.then((response) => {
9091
if (response && response.ok) {
9192
const clone = response.clone();
9293
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
9394
}
9495
return response;
95-
}).catch(() => cached);
96-
97-
return cached || fetchPromise;
98-
})
96+
})
97+
.catch(() => caches.match(request))
9998
);
10099
});
101100

src/web/routes/file-routes.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,9 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
293293

294294
const content = await fs.readFile(resolvedPath);
295295
if (download === 'true') {
296-
const basename = filePath!.split('/').pop() || 'download';
296+
const rawBasename = filePath!.split('/').pop() || 'download';
297+
// Sanitize filename for Content-Disposition header (prevent header injection)
298+
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
297299
reply.raw.writeHead(200, {
298300
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
299301
'Content-Disposition': `attachment; filename="${basename}"`,

src/web/routes/session-routes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { FastifyInstance } from 'fastify';
88
import { join, dirname } from 'node:path';
99
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
10+
import { execFile } from 'node:child_process';
1011
import fs from 'node:fs/promises';
1112
import {
1213
ApiErrorCode,
@@ -545,13 +546,12 @@ export function registerSessionRoutes(
545546
}
546547

547548
const session = findSessionOrFail(ctx, id);
548-
const muxName = (session as any)._muxSession?.muxName;
549+
const muxName = session.muxName;
549550
if (!muxName) {
550551
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
551552
}
552553

553554
try {
554-
const { execFile } = await import('child_process');
555555
await new Promise<void>((resolve, reject) => {
556556
execFile('tmux', ['send-keys', '-H', '-t', muxName, ...hex], { timeout: 5000 }, (err) => {
557557
if (err) reject(err);

0 commit comments

Comments
 (0)