aicodeman 0.6.9 → 0.6.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/web/paste-image-gc.d.ts +7 -0
  2. package/dist/web/paste-image-gc.d.ts.map +1 -0
  3. package/dist/web/paste-image-gc.js +69 -0
  4. package/dist/web/paste-image-gc.js.map +1 -0
  5. package/dist/web/public/api-client.3adebdc2.js.gz +0 -0
  6. package/dist/web/public/{app.2353feb8.js → app.6a96cf81.js} +8 -10
  7. package/dist/web/public/app.6a96cf81.js.br +0 -0
  8. package/dist/web/public/app.6a96cf81.js.gz +0 -0
  9. package/dist/web/public/{constants.35154472.js → constants.cb6426c4.js} +46 -0
  10. package/dist/web/public/constants.cb6426c4.js.br +0 -0
  11. package/dist/web/public/constants.cb6426c4.js.gz +0 -0
  12. package/dist/web/public/{image-input.2d862e9c.js → image-input.926911b4.js} +7 -2
  13. package/dist/web/public/image-input.926911b4.js.br +0 -0
  14. package/dist/web/public/image-input.926911b4.js.gz +0 -0
  15. package/dist/web/public/index.html +7 -7
  16. package/dist/web/public/index.html.br +0 -0
  17. package/dist/web/public/index.html.gz +0 -0
  18. package/dist/web/public/input-cjk.88082175.js.gz +0 -0
  19. package/dist/web/public/keyboard-accessory.29aebd9c.js.gz +0 -0
  20. package/dist/web/public/mobile-handlers.1e2a8ef8.js.gz +0 -0
  21. package/dist/web/public/mobile.37d62c06.css.gz +0 -0
  22. package/dist/web/public/notification-manager.9c984ac2.js.gz +0 -0
  23. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  24. package/dist/web/public/panels-ui.cf998835.js.gz +0 -0
  25. package/dist/web/public/ralph-panel.61076370.js.gz +0 -0
  26. package/dist/web/public/ralph-wizard.6b0f0be7.js.gz +0 -0
  27. package/dist/web/public/respawn-ui.5377f958.js.gz +0 -0
  28. package/dist/web/public/session-ui.f1555cd1.js.gz +0 -0
  29. package/dist/web/public/settings-ui.25a18120.js.gz +0 -0
  30. package/dist/web/public/styles.d160ad58.css +1 -0
  31. package/dist/web/public/styles.d160ad58.css.br +0 -0
  32. package/dist/web/public/styles.d160ad58.css.gz +0 -0
  33. package/dist/web/public/subagent-windows.a366a4ad.js.gz +0 -0
  34. package/dist/web/public/sw.js.gz +0 -0
  35. package/dist/web/public/terminal-ui.5d29101f.js +3 -0
  36. package/dist/web/public/terminal-ui.5d29101f.js.br +0 -0
  37. package/dist/web/public/terminal-ui.5d29101f.js.gz +0 -0
  38. package/dist/web/public/upload.html.gz +0 -0
  39. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  40. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  41. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  42. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  43. package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
  44. package/dist/web/public/vendor/xterm.css.gz +0 -0
  45. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  46. package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
  47. package/dist/web/routes/session-routes.d.ts +12 -0
  48. package/dist/web/routes/session-routes.d.ts.map +1 -1
  49. package/dist/web/routes/session-routes.js +312 -139
  50. package/dist/web/routes/session-routes.js.map +1 -1
  51. package/dist/web/server.d.ts +1 -0
  52. package/dist/web/server.d.ts.map +1 -1
  53. package/dist/web/server.js +27 -5
  54. package/dist/web/server.js.map +1 -1
  55. package/package.json +2 -1
  56. package/dist/web/public/app.2353feb8.js.br +0 -0
  57. package/dist/web/public/app.2353feb8.js.gz +0 -0
  58. package/dist/web/public/constants.35154472.js.br +0 -0
  59. package/dist/web/public/constants.35154472.js.gz +0 -0
  60. package/dist/web/public/image-input.2d862e9c.js.br +0 -0
  61. package/dist/web/public/image-input.2d862e9c.js.gz +0 -0
  62. package/dist/web/public/styles.1f5114f6.css +0 -1
  63. package/dist/web/public/styles.1f5114f6.css.br +0 -0
  64. package/dist/web/public/styles.1f5114f6.css.gz +0 -0
  65. package/dist/web/public/terminal-ui.d069d610.js +0 -3
  66. package/dist/web/public/terminal-ui.d069d610.js.br +0 -0
  67. package/dist/web/public/terminal-ui.d069d610.js.gz +0 -0
@@ -8,6 +8,7 @@ import { homedir } from 'node:os';
8
8
  import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
9
9
  import { execFile } from 'node:child_process';
10
10
  import fs from 'node:fs/promises';
11
+ import { randomBytes } from 'node:crypto';
11
12
  import { ApiErrorCode, createErrorResponse, getErrorMessage, } from '../../types.js';
12
13
  import { Session } from '../../session.js';
13
14
  import { SseEvent } from '../sse-events.js';
@@ -86,6 +87,73 @@ export function stripInkRedrawBloat(buffer) {
86
87
  parts.push(buffer.slice(cursor));
87
88
  return parts.join('');
88
89
  }
90
+ /**
91
+ * Validate image bytes against a declared extension. Sniffs the first ~12 bytes
92
+ * for a known magic-number signature. Defends against polyglots (e.g. HTML or
93
+ * SVG disguised under a `Content-Type: image/png` header) and against simple
94
+ * extension-only spoofing — both the multipart filename and the Content-Type
95
+ * are attacker-controlled, the raw bytes are not.
96
+ *
97
+ * Signatures: https://en.wikipedia.org/wiki/List_of_file_signatures
98
+ */
99
+ export function imageMagicMatchesExt(data, ext) {
100
+ if (data.length < 12)
101
+ return false;
102
+ const u32be = (off) => data.readUInt32BE(off);
103
+ switch (ext) {
104
+ case '.png':
105
+ return u32be(0) === 0x89504e47 && u32be(4) === 0x0d0a1a0a;
106
+ case '.jpg':
107
+ case '.jpeg':
108
+ return data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
109
+ case '.gif':
110
+ return (data[0] === 0x47 &&
111
+ data[1] === 0x49 &&
112
+ data[2] === 0x46 &&
113
+ data[3] === 0x38 &&
114
+ (data[4] === 0x37 || data[4] === 0x39) &&
115
+ data[5] === 0x61);
116
+ case '.webp':
117
+ // RIFF....WEBP
118
+ return u32be(0) === 0x52494646 && u32be(8) === 0x57454250;
119
+ case '.bmp':
120
+ return data[0] === 0x42 && data[1] === 0x4d;
121
+ default:
122
+ return false;
123
+ }
124
+ }
125
+ // Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
126
+ // Bucket map entries are pruned when they drift > 1h stale to bound memory
127
+ // against a flood of unique IP keys.
128
+ const PASTE_RATE_TOKENS = 30;
129
+ const PASTE_RATE_REFILL_PER_MS = PASTE_RATE_TOKENS / 60_000;
130
+ const PASTE_BUCKET_TTL_MS = 60 * 60 * 1000;
131
+ const PASTE_BUCKET_GC_THRESHOLD = 1000;
132
+ const pasteRateBuckets = new Map();
133
+ export function consumePasteToken(key, now = Date.now()) {
134
+ if (pasteRateBuckets.size > PASTE_BUCKET_GC_THRESHOLD) {
135
+ for (const [k, b] of pasteRateBuckets) {
136
+ if (now - b.lastRefill > PASTE_BUCKET_TTL_MS)
137
+ pasteRateBuckets.delete(k);
138
+ }
139
+ }
140
+ let b = pasteRateBuckets.get(key);
141
+ if (!b) {
142
+ b = { tokens: PASTE_RATE_TOKENS, lastRefill: now };
143
+ pasteRateBuckets.set(key, b);
144
+ }
145
+ const delta = (now - b.lastRefill) * PASTE_RATE_REFILL_PER_MS;
146
+ b.tokens = Math.min(PASTE_RATE_TOKENS, b.tokens + delta);
147
+ b.lastRefill = now;
148
+ if (b.tokens < 1)
149
+ return false;
150
+ b.tokens -= 1;
151
+ return true;
152
+ }
153
+ // Test hook: reset between runs.
154
+ export function _resetPasteRateBuckets() {
155
+ pasteRateBuckets.clear();
156
+ }
89
157
  export function registerSessionRoutes(app, ctx) {
90
158
  // ═══════════════════════════════════════════════════════════════
91
159
  // Auth
@@ -1077,36 +1145,76 @@ export function registerSessionRoutes(app, ctx) {
1077
1145
  * Claude CLI encodes both '/' and '_' as '-', so each '-' in the key could be
1078
1146
  * any of: '/' (path separator), '_' (underscore), or '-' (literal dash).
1079
1147
  *
1080
- * Strategy: look-ahead matching. At each '-', try consuming multiple segments
1081
- * joined by '_' or '-' to find an existing child directory, then recurse.
1082
- * E.g. for segments [AI, project, Mirror] inside /Workspace:
1083
- * try /Workspace/AI (no) -> /Workspace/AI_project (yes!) -> continue with [Mirror]
1148
+ * Strategy: recursive backtracking with longest-match-first preference.
1149
+ * At each segment boundary, try joining as many segments as possible (with '_'
1150
+ * or '-') into a single existing directory name. If a shorter match leads to a
1151
+ * dead end, backtrack and try the next-shorter candidate.
1152
+ *
1153
+ * Why backtracking: when both `diary/` and `diary-app/` exist as siblings, the
1154
+ * naive shortest-match would pick `diary` and then fail to find `app` inside,
1155
+ * leaving the rest of the key unresolved. Longest-first picks `diary-app`.
1084
1156
  */
1085
1157
  async function decodeProjectKey(projKey) {
1086
1158
  const encoded = projKey.startsWith('-') ? projKey.slice(1) : projKey;
1087
1159
  const segments = encoded.split('-');
1088
- const isDir = async (p) => fs
1089
- .stat(p)
1090
- .then((s) => s.isDirectory())
1091
- .catch(() => false);
1160
+ const isDirCache = new Map();
1161
+ const isDir = async (p) => {
1162
+ const cached = isDirCache.get(p);
1163
+ if (cached !== undefined)
1164
+ return cached;
1165
+ const result = await fs
1166
+ .stat(p)
1167
+ .then((s) => s.isDirectory())
1168
+ .catch(() => false);
1169
+ isDirCache.set(p, result);
1170
+ return result;
1171
+ };
1172
+ // Recursive backtracking: returns the deepest valid path that consumes all
1173
+ // segments. Tries the longest segment-join first at each step so that
1174
+ // dash-containing directory names win over shorter same-prefix siblings.
1175
+ async function tryDecode(idx, current) {
1176
+ if (idx >= segments.length)
1177
+ return current;
1178
+ const maxLook = Math.min(idx + 4, segments.length);
1179
+ // Longest first: end = maxLook-1 down to idx
1180
+ for (let end = maxLook - 1; end >= idx; end--) {
1181
+ const candidates = [];
1182
+ if (end === idx) {
1183
+ candidates.push(segments[idx]);
1184
+ }
1185
+ else {
1186
+ candidates.push(segments.slice(idx, end + 1).join('-'));
1187
+ candidates.push(segments.slice(idx, end + 1).join('_'));
1188
+ }
1189
+ for (const child of candidates) {
1190
+ const candidate = current + '/' + child;
1191
+ if (await isDir(candidate)) {
1192
+ const result = await tryDecode(end + 1, candidate);
1193
+ if (result)
1194
+ return result;
1195
+ }
1196
+ }
1197
+ }
1198
+ return null;
1199
+ }
1200
+ const decoded = await tryDecode(0, '');
1201
+ if (decoded)
1202
+ return decoded;
1203
+ // Fallback: greedy shortest-match (original behavior) — best effort when
1204
+ // no fully-valid path exists (e.g. directory was deleted after the
1205
+ // conversation was recorded).
1092
1206
  let current = '';
1093
1207
  let i = 0;
1094
1208
  while (i < segments.length) {
1095
- // Try progressively longer child names by joining segments with '_' or '-'
1096
1209
  let matched = false;
1097
- // Limit look-ahead to avoid excessive fs checks (max 4 segments per component)
1098
1210
  const maxLook = Math.min(i + 4, segments.length);
1099
1211
  for (let end = i; end < maxLook; end++) {
1100
- // Build candidate child name from segments[i..end]
1101
- // Try all separator combinations: for 2+ segments, try '_' first then '-'
1102
1212
  const candidates = [];
1103
1213
  if (end === i) {
1104
1214
  candidates.push(segments[i]);
1105
1215
  }
1106
1216
  else {
1107
- // Build with underscores between joined segments
1108
1217
  candidates.push(segments.slice(i, end + 1).join('_'));
1109
- // Build with dashes (literal)
1110
1218
  candidates.push(segments.slice(i, end + 1).join('-'));
1111
1219
  }
1112
1220
  for (const child of candidates) {
@@ -1122,7 +1230,6 @@ export function registerSessionRoutes(app, ctx) {
1122
1230
  break;
1123
1231
  }
1124
1232
  if (!matched) {
1125
- // No directory match found — append as-is and move on
1126
1233
  current = current + '/' + segments[i];
1127
1234
  i++;
1128
1235
  }
@@ -1164,156 +1271,190 @@ export function registerSessionRoutes(app, ctx) {
1164
1271
  return null;
1165
1272
  }
1166
1273
  }
1167
- app.get('/api/history/sessions', async () => {
1274
+ // Scan a single project directory and return all valid history sessions in it.
1275
+ // Reused by both the global overview and the single-folder drill-down.
1276
+ async function scanProjectDir(projPath, projDir, headBuf) {
1277
+ const out = [];
1278
+ const stat = await fs.stat(projPath).catch(() => null);
1279
+ if (!stat?.isDirectory())
1280
+ return out;
1281
+ const workingDir = await decodeProjectKey(projDir);
1282
+ const entries = await fs.readdir(projPath).catch(() => []);
1283
+ for (const entry of entries) {
1284
+ if (!entry.endsWith('.jsonl'))
1285
+ continue;
1286
+ const sessionId = entry.replace('.jsonl', '');
1287
+ if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId))
1288
+ continue;
1289
+ const filePath = join(projPath, entry);
1290
+ const fileStat = await fs.stat(filePath).catch(() => null);
1291
+ if (!fileStat)
1292
+ continue;
1293
+ if (fileStat.size < 4000)
1294
+ continue;
1295
+ let firstPrompt;
1296
+ const head = await readFileHead(filePath, headBuf);
1297
+ const hasConversation = (text) => text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
1298
+ let foundContent = head ? hasConversation(head) : false;
1299
+ let tail = null;
1300
+ if (!foundContent && fileStat.size > 16384) {
1301
+ const tailBuf = Buffer.alloc(32768);
1302
+ tail = await readFileTail(filePath, tailBuf, fileStat.size);
1303
+ if (tail)
1304
+ foundContent = hasConversation(tail);
1305
+ }
1306
+ if (!foundContent)
1307
+ continue;
1308
+ if (head)
1309
+ firstPrompt = extractFirstUserPrompt(head);
1310
+ if (!firstPrompt && fileStat.size > 65536) {
1311
+ if (!tail) {
1312
+ const tailBuf = Buffer.alloc(32768);
1313
+ tail = await readFileTail(filePath, tailBuf, fileStat.size);
1314
+ }
1315
+ if (tail)
1316
+ firstPrompt = extractFirstUserPrompt(tail);
1317
+ }
1318
+ out.push({
1319
+ sessionId,
1320
+ workingDir,
1321
+ projectKey: projDir,
1322
+ sizeBytes: fileStat.size,
1323
+ lastModified: fileStat.mtime.toISOString(),
1324
+ firstPrompt,
1325
+ });
1326
+ }
1327
+ return out;
1328
+ }
1329
+ app.get('/api/history/sessions', async (req) => {
1330
+ const query = req.query;
1168
1331
  const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
1169
- const results = [];
1170
1332
  const headBuf = Buffer.alloc(16384);
1333
+ // Single-folder drill-down: when projectKey is provided, scan only that
1334
+ // directory, bypass the 50-cap, and honor offset/limit pagination.
1335
+ if (query.projectKey) {
1336
+ // Validate projectKey format to prevent path traversal
1337
+ if (!/^[A-Za-z0-9_-]+$/.test(query.projectKey)) {
1338
+ return { sessions: [], total: 0 };
1339
+ }
1340
+ const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
1341
+ const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
1342
+ const projPath = join(projectsDir, query.projectKey);
1343
+ const all = await scanProjectDir(projPath, query.projectKey, headBuf);
1344
+ all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
1345
+ return { sessions: all.slice(offset, offset + limit), total: all.length };
1346
+ }
1347
+ // Global overview: scan all projects, return up to 50 most-recent sessions.
1348
+ const results = [];
1171
1349
  try {
1172
1350
  const projectDirs = await fs.readdir(projectsDir);
1173
1351
  for (const projDir of projectDirs) {
1174
1352
  const projPath = join(projectsDir, projDir);
1175
- const stat = await fs.stat(projPath).catch(() => null);
1176
- if (!stat?.isDirectory())
1177
- continue;
1178
- // Decode project key to working dir. Claude CLI encodes '/' as '-',
1179
- // but path components may also contain '-' (e.g. "AI_project" vs "AI-project").
1180
- // Use recursive backtracking: try each '-' as either '/' or literal '-',
1181
- // verify which decoded path actually exists on disk.
1182
- const workingDir = await decodeProjectKey(projDir);
1183
- const entries = await fs.readdir(projPath);
1184
- for (const entry of entries) {
1185
- if (!entry.endsWith('.jsonl'))
1186
- continue;
1187
- const sessionId = entry.replace('.jsonl', '');
1188
- // Only valid UUIDs
1189
- if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId))
1190
- continue;
1191
- const filePath = join(projPath, entry);
1192
- const fileStat = await fs.stat(filePath).catch(() => null);
1193
- if (!fileStat)
1194
- continue;
1195
- // Skip files too small to contain real conversation (metadata-only sessions
1196
- // like file-history-snapshot entries are typically < 4KB)
1197
- if (fileStat.size < 4000)
1198
- continue;
1199
- // Quick content check: verify actual conversation data exists.
1200
- // Sessions with only file-history-snapshot or hook_progress entries have
1201
- // no "user"/"assistant" messages and will fail claude --resume.
1202
- // Read first 16KB to check content and extract first user prompt.
1203
- let firstPrompt;
1204
- const head = await readFileHead(filePath, headBuf);
1205
- const hasConversation = (text) => text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
1206
- let foundContent = head ? hasConversation(head) : false;
1207
- // For large files, head may not contain user messages (e.g. /init followed
1208
- // by large system entries). Check the tail as well.
1209
- let tail = null;
1210
- if (!foundContent && fileStat.size > 16384) {
1211
- const tailBuf = Buffer.alloc(32768);
1212
- tail = await readFileTail(filePath, tailBuf, fileStat.size);
1213
- if (tail)
1214
- foundContent = hasConversation(tail);
1215
- }
1216
- if (!foundContent)
1217
- continue; // No conversation content — skip
1218
- if (head)
1219
- firstPrompt = extractFirstUserPrompt(head);
1220
- // If head scan found no usable prompt (e.g. session started with /init),
1221
- // try reading the tail for a recent user message.
1222
- if (!firstPrompt && fileStat.size > 65536) {
1223
- if (!tail) {
1224
- const tailBuf = Buffer.alloc(32768);
1225
- tail = await readFileTail(filePath, tailBuf, fileStat.size);
1226
- }
1227
- if (tail)
1228
- firstPrompt = extractFirstUserPrompt(tail);
1229
- }
1230
- results.push({
1231
- sessionId,
1232
- workingDir,
1233
- projectKey: projDir,
1234
- sizeBytes: fileStat.size,
1235
- lastModified: fileStat.mtime.toISOString(),
1236
- firstPrompt,
1237
- });
1238
- }
1353
+ const list = await scanProjectDir(projPath, projDir, headBuf);
1354
+ results.push(...list);
1239
1355
  }
1240
1356
  }
1241
1357
  catch {
1242
1358
  // Projects dir may not exist
1243
1359
  }
1244
- // Sort by lastModified descending
1245
1360
  results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
1246
1361
  return { sessions: results.slice(0, 50) };
1247
1362
  });
1248
1363
  // ═══════════════════════════════════════════════════════════════
1249
1364
  // Paste Image (clipboard / drag-drop upload)
1250
1365
  // ═══════════════════════════════════════════════════════════════
1251
- const MAX_PASTE_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
1252
1366
  const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
1367
+ // The 10MB size cap is enforced by @fastify/multipart (registered in server.ts).
1253
1368
  app.post('/api/sessions/:id/paste-image', async (req, reply) => {
1369
+ // CSRF defense: state-changing routes must come from same origin.
1370
+ // Cookies are SameSite=lax, multipart/form-data is a "simple" CORS request
1371
+ // (no preflight), so a cross-origin <form enctype="multipart/form-data">
1372
+ // submit attaches the session cookie unimpeded. Reject unless Origin/Referer
1373
+ // matches req.host. Non-browser clients (no Origin AND no Referer) must
1374
+ // supply X-Codeman-CSRF — a header browsers cannot add cross-origin without
1375
+ // a preflight, which our CORS config does not allow from other origins.
1376
+ const reqHost = req.headers.host;
1377
+ const origin = req.headers.origin;
1378
+ const referer = req.headers.referer;
1379
+ let csrfOk = false;
1380
+ if (origin) {
1381
+ try {
1382
+ csrfOk = new URL(origin).host === reqHost;
1383
+ }
1384
+ catch {
1385
+ /* invalid Origin → not ok */
1386
+ }
1387
+ }
1388
+ else if (referer) {
1389
+ try {
1390
+ csrfOk = new URL(referer).host === reqHost;
1391
+ }
1392
+ catch {
1393
+ /* invalid Referer → not ok */
1394
+ }
1395
+ }
1396
+ else {
1397
+ csrfOk = !!req.headers['x-codeman-csrf'];
1398
+ }
1399
+ if (!csrfOk) {
1400
+ reply.code(403);
1401
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CSRF check failed');
1402
+ }
1254
1403
  const { id } = req.params;
1404
+ // Rate limit per (IP, sessionId): 30/min. Defends against disk-fill DoS
1405
+ // — even an authenticated attacker can otherwise loop 10MB POSTs.
1406
+ if (!consumePasteToken(`${req.ip}:${id}`)) {
1407
+ reply.code(429);
1408
+ reply.header('Retry-After', '60');
1409
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
1410
+ }
1255
1411
  const session = findSessionOrFail(ctx, id);
1256
- const contentType = req.headers['content-type'] ?? '';
1257
- if (!contentType.includes('multipart/form-data')) {
1412
+ if (!req.isMultipart()) {
1258
1413
  reply.code(400);
1259
1414
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
1260
1415
  }
1261
- // Parse multipart boundary
1262
- const boundaryMatch = contentType.match(/boundary=(.+?)(?:;|$)/);
1263
- if (!boundaryMatch) {
1264
- reply.code(400);
1265
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing boundary');
1266
- }
1267
- // Collect raw body with size limit
1268
- const chunks = [];
1269
- let totalSize = 0;
1270
- for await (const chunk of req.raw) {
1271
- totalSize += chunk.length;
1272
- if (totalSize > MAX_PASTE_IMAGE_SIZE) {
1273
- reply.code(413);
1274
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)');
1275
- }
1276
- chunks.push(chunk);
1277
- }
1278
- const body = Buffer.concat(chunks);
1279
- // Extract image from multipart body
1280
- const boundary = '--' + boundaryMatch[1];
1281
- const boundaryBuf = Buffer.from(boundary);
1282
- const parts = [];
1283
- let pos = 0;
1284
- while (pos < body.length) {
1285
- const start = body.indexOf(boundaryBuf, pos);
1286
- if (start === -1)
1287
- break;
1288
- const afterBoundary = start + boundaryBuf.length;
1289
- if (body[afterBoundary] === 0x2d && body[afterBoundary + 1] === 0x2d)
1290
- break;
1291
- const headerStart = afterBoundary + 2;
1292
- const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'), headerStart);
1293
- if (headerEnd === -1)
1294
- break;
1295
- const headers = body.subarray(headerStart, headerEnd).toString();
1296
- const dataStart = headerEnd + 4;
1297
- const nextBoundary = body.indexOf(boundaryBuf, dataStart);
1298
- const dataEnd = nextBoundary === -1 ? body.length : nextBoundary - 2;
1299
- parts.push({ headers, data: body.subarray(dataStart, dataEnd) });
1300
- pos = nextBoundary === -1 ? body.length : nextBoundary;
1301
- }
1302
- const imagePart = parts.find((p) => p.headers.includes('name="image"'));
1303
- if (!imagePart || imagePart.data.length === 0) {
1416
+ // Read the single file part. @fastify/multipart enforces the 10MB size cap
1417
+ // and the 1-file/4-field count limits (server.ts), replacing a hand-rolled
1418
+ // boundary scanner with several bugs: literal boundary matches anywhere in
1419
+ // body, LF-only clients silently corrupted the last byte (hard-coded \r\n
1420
+ // offsets), no part-count cap.
1421
+ let part;
1422
+ try {
1423
+ part = await req.file();
1424
+ }
1425
+ catch (err) {
1426
+ reply.code(413);
1427
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'Invalid multipart payload');
1428
+ }
1429
+ if (!part) {
1304
1430
  reply.code(400);
1305
1431
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No image uploaded');
1306
1432
  }
1307
- // Determine extension from filename or Content-Type
1433
+ if (part.fieldname !== 'image') {
1434
+ reply.code(400);
1435
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unexpected field "${part.fieldname}", expected "image"`);
1436
+ }
1437
+ let imageBytes;
1438
+ try {
1439
+ imageBytes = await part.toBuffer();
1440
+ }
1441
+ catch (err) {
1442
+ reply.code(413);
1443
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'File too large (max 10MB)');
1444
+ }
1445
+ if (imageBytes.length === 0) {
1446
+ reply.code(400);
1447
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Empty file');
1448
+ }
1449
+ // Determine extension from filename or Content-Type.
1308
1450
  let ext = '.png';
1309
- const filenameMatch = imagePart.headers.match(/filename="(.+?)"/);
1310
- if (filenameMatch) {
1311
- const origExt = extname(filenameMatch[1]).toLowerCase();
1451
+ if (part.filename) {
1452
+ const origExt = extname(part.filename).toLowerCase();
1312
1453
  if (ALLOWED_IMAGE_EXTS.has(origExt))
1313
1454
  ext = origExt;
1314
1455
  }
1315
- const ctMatch = imagePart.headers.match(/Content-Type:\s*image\/(png|jpeg|jpg|webp|gif|bmp)/i);
1316
- if (ctMatch) {
1456
+ const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp)$/);
1457
+ if (mimeMatch) {
1317
1458
  const map = {
1318
1459
  png: '.png',
1319
1460
  jpeg: '.jpg',
@@ -1322,20 +1463,52 @@ export function registerSessionRoutes(app, ctx) {
1322
1463
  gif: '.gif',
1323
1464
  bmp: '.bmp',
1324
1465
  };
1325
- ext = map[ctMatch[1].toLowerCase()] ?? ext;
1466
+ ext = map[mimeMatch[1]] ?? ext;
1326
1467
  }
1327
1468
  if (!ALLOWED_IMAGE_EXTS.has(ext)) {
1328
1469
  reply.code(400);
1329
1470
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported image type: ${ext}. Allowed: ${[...ALLOWED_IMAGE_EXTS].join(', ')}`);
1330
1471
  }
1472
+ // Sniff actual bytes — filename and Content-Type are both attacker-supplied.
1473
+ // Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME.
1474
+ if (!imageMagicMatchesExt(imageBytes, ext)) {
1475
+ reply.code(415);
1476
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
1477
+ }
1331
1478
  // Save to {workingDir}/.claude-images/
1479
+ // Refuse symlinks at imageDir — an agent or postinstall script could plant
1480
+ // `.claude-images -> ~/.ssh/` and redirect future writes outside workingDir.
1481
+ // We lstat (not stat) so we see the symlink itself. Use mkdir without
1482
+ // `recursive` so the leaf creation does not follow a symlink either, and
1483
+ // O_EXCL|O_NOFOLLOW on the file open so the write itself is symlink-safe.
1332
1484
  const imageDir = join(session.workingDir, '.claude-images');
1333
- if (!existsSync(imageDir)) {
1334
- mkdirSync(imageDir, { recursive: true });
1485
+ try {
1486
+ const dirStat = await fs.lstat(imageDir);
1487
+ if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
1488
+ reply.code(403);
1489
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
1490
+ }
1335
1491
  }
1336
- const filename = `paste-${Date.now()}${ext}`;
1492
+ catch (err) {
1493
+ if (err.code !== 'ENOENT')
1494
+ throw err;
1495
+ // Non-recursive mkdir: errors on EEXIST and does not follow symlinks for
1496
+ // the leaf. session.workingDir is guaranteed to exist (live session).
1497
+ await fs.mkdir(imageDir);
1498
+ }
1499
+ // Date.now() collides on same-ms uploads from two tabs (last-write wins
1500
+ // silently). Append 8 hex chars so concurrent pastes get distinct names.
1501
+ const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
1337
1502
  const filepath = join(imageDir, filename);
1338
- await fs.writeFile(filepath, imagePart.data);
1503
+ // O_EXCL: refuse to overwrite (collision is impossible with random suffix,
1504
+ // but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
1505
+ const fh = await fs.open(filepath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW);
1506
+ try {
1507
+ await fh.writeFile(imageBytes);
1508
+ }
1509
+ finally {
1510
+ await fh.close();
1511
+ }
1339
1512
  return { success: true, path: filepath, filename };
1340
1513
  });
1341
1514
  }