Files
claudecodeui/server/modules/assets/assets.routes.ts
Haileyesus 138371525a fix: address code review findings
- validate chat image attachments server-side: only files inside the
  ~/.cloudcli/assets upload store may reach provider file reads
- harden asset serving with nosniff and attachment disposition for
  SVGs to prevent stored XSS
- show the timestamp on image-only user messages
- serialize git stage/unstage calls and defer the status re-sync so
  rapid toggles can't interleave or flicker
- use a literal hex fallback for commit ref badges (alpha suffix on a
  var() string produced invalid CSS)
- stop binding a URL session to a guening
  project instead
- add the missing attentionRequiredIndicator key to all sidebar locales
- clean up dangling conjunctions left the
  de/ru/tr/ja/zh-CN/zh-TW READMEs
2026-07-06 21:08:22 +03:00

104 lines
3.2 KiB
TypeScript

import fsSync, { promises as fs } from 'node:fs';
import express from 'express';
import mime from 'mime-types';
import multer from 'multer';
import {
buildStoredImageRecords,
ensureImageAssetsDir,
isAllowedImageMimeType,
resolveImageAssetFile,
} from '@/modules/assets/services/image-assets.service.js';
const router = express.Router();
// Multer writes uploads straight into the global assets folder; the service
// owns the folder location and the response record shape.
const storage = multer.diskStorage({
destination: (req, file, cb) => {
ensureImageAssetsDir()
.then((assetsDir) => cb(null, assetsDir))
.catch((error) => cb(error as Error, ''));
},
filename: (req, file, cb) => {
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
cb(null, `${uniqueSuffix}-${sanitizedName}`);
},
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
if (isAllowedImageMimeType(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
}
},
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
files: 5,
},
});
/**
* Stores chat image attachments in the global `~/.cloudcli/assets` folder and
* returns their absolute paths for use in provider prompts and chat history.
*/
router.post('/images', (req, res) => {
upload.array('images', 5)(req, res, (err: unknown) => {
if (err) {
const message = err instanceof Error ? err.message : 'Upload failed';
return res.status(400).json({ error: message });
}
const files = Array.isArray(req.files) ? req.files : [];
if (files.length === 0) {
return res.status(400).json({ error: 'No image files provided' });
}
res.json({ images: buildStoredImageRecords(files) });
});
});
/**
* Serves one stored image asset by filename. Only files directly inside the
* global assets folder are reachable; traversal attempts resolve to null.
*/
router.get('/images/:filename', async (req, res) => {
const resolved = resolveImageAssetFile(req.params.filename);
if (!resolved) {
return res.status(400).json({ error: 'Invalid asset filename' });
}
try {
await fs.access(resolved);
} catch {
return res.status(404).json({ error: 'Asset not found' });
}
const contentType = mime.lookup(resolved) || 'application/octet-stream';
res.setHeader('Content-Type', contentType);
// Stored-XSS hardening: never let the browser sniff a different type, and
// force SVGs (which can carry scripts when rendered as a document) to
// download instead of rendering inline. The chat UI is unaffected — it
// fetches assets as blobs and shows them through <img>, where SVG scripts
// never execute.
res.setHeader('X-Content-Type-Options', 'nosniff');
if (contentType === 'image/svg+xml') {
res.setHeader('Content-Disposition', 'attachment');
}
const fileStream = fsSync.createReadStream(resolved);
fileStream.pipe(res);
fileStream.on('error', (error) => {
console.error('Error streaming image asset:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Error reading asset' });
}
});
});
export default router;