🧹 fix: Sanitize Artifact Filenames in Code Execution Output (#12222)

* fix: sanitize artifact filenames to prevent path traversal in code output

* test: Mock sanitizeFilename function in process.spec.js to return the original filename

- Added a mock implementation for the `sanitizeFilename` function in the `process.spec.js` test file to return the original filename, ensuring that tests can run without altering the filename during the testing process.

* fix: use path.relative for traversal check, sanitize all filenames, add security logging

- Replace startsWith with path.relative pattern in saveLocalBuffer, consistent
  with deleteLocalFile and getLocalFileStream in the same file
- Hoist sanitizeFilename call before the image/non-image branch so both code
  paths store the sanitized name in MongoDB
- Log a warning when sanitizeFilename mutates a filename (potential traversal)
- Log a specific warning when saveLocalBuffer throws a traversal error, so
  security events are distinguishable from generic network errors in the catch

* test: improve traversal test coverage and remove mock reimplementation

- Remove partial sanitizeFilename reimplementation from process-traversal tests;
  use controlled mock returns to verify processCodeOutput wiring instead
- Add test for image branch sanitization
- Use mkdtempSync for test isolation in crud-traversal to avoid parallel worker
  collisions
- Add prefix-collision bypass test case (../user10/evil vs user1 directory)

* fix: use path.relative in isValidPath to prevent prefix-collision bypass

Pre-existing startsWith check without path separator had the same class
of prefix-collision vulnerability fixed in saveLocalBuffer.
This commit is contained in:
Danny Avila 2026-03-14 03:09:26 -04:00 committed by GitHub
parent 35a35dc2e9
commit f67bbb2bc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 221 additions and 9 deletions

View file

@ -3,7 +3,7 @@ const { v4 } = require('uuid');
const axios = require('axios');
const { logger } = require('@librechat/data-schemas');
const { getCodeBaseURL } = require('@librechat/agents');
const { logAxiosError, getBasePath } = require('@librechat/api');
const { logAxiosError, getBasePath, sanitizeFilename } = require('@librechat/api');
const {
Tools,
megabyte,
@ -146,6 +146,13 @@ const processCodeOutput = async ({
);
}
const safeName = sanitizeFilename(name);
if (safeName !== name) {
logger.warn(
`[processCodeOutput] Filename sanitized: "${name}" -> "${safeName}" | conv=${conversationId}`,
);
}
if (isImage) {
const usage = isUpdate ? (claimed.usage ?? 0) + 1 : 1;
const _file = await convertImage(req, buffer, 'high', `${file_id}${fileExt}`);
@ -156,7 +163,7 @@ const processCodeOutput = async ({
file_id,
messageId,
usage,
filename: name,
filename: safeName,
conversationId,
user: req.user.id,
type: `image/${appConfig.imageOutputType}`,
@ -200,7 +207,7 @@ const processCodeOutput = async ({
);
}
const fileName = `${file_id}__${name}`;
const fileName = `${file_id}__${safeName}`;
const filepath = await saveBuffer({
userId: req.user.id,
buffer,
@ -213,7 +220,7 @@ const processCodeOutput = async ({
filepath,
messageId,
object: 'file',
filename: name,
filename: safeName,
type: mimeType,
conversationId,
user: req.user.id,
@ -229,6 +236,11 @@ const processCodeOutput = async ({
await createFile(file, true);
return Object.assign(file, { messageId, toolCallId });
} catch (error) {
if (error?.message === 'Path traversal detected in filename') {
logger.warn(
`[processCodeOutput] Path traversal blocked for file "${name}" | conv=${conversationId}`,
);
}
logAxiosError({
message: 'Error downloading/processing code environment file',
error,