📎 fix: Alias Shell Script MIME Variants to application/x-sh (#14817)

* 📎 fix: Alias Shell Script MIME Variants to `application/x-sh`

Chrome on Linux reports `.sh` files as `application/x-shellscript`
(freedesktop shared-mime-info) and libmagic reports `text/x-shellscript`.
Neither string appears anywhere in the source, so uploads were rejected
even though `application/x-sh` is in the default allowlist and
`codeTypeMapping` maps `sh` to it — `inferMimeType` only consults the
extension map when the client sends no type at all, so a non-empty
browser value passed straight through to the allowlist check.

Alias both variants to the canonical `application/x-sh`, matching the
existing treatment of `text/x-markdown` and `application/x-zip-compressed`.

Also attach `statusCode`/`body` to multer file-filter rejections. Without
them the error misses the `isCustomError` branch in `ErrorController` and
falls through to a bare `500 An unknown error occurred.`, so the rejection
reason was logged server-side but never reached the client. The upload
hook already surfaces `error.response.data.message`, so a rejected file
now explains itself instead of showing a generic upload failure.

* 🔁 refactor: Move Upload Error Contract Into `packages/api`

Addresses codex P1 on #14817.

The producer of the `statusCode`/`body` pair now sits beside its consumer:
`isCustomError` and `ErrorController` are already in
`packages/api/src/middleware/error.ts`, and `CustomError` is already in
`packages/api/src/types/error.ts` — only the construction of that pair was
stranded in legacy JS. `createCustomError` is exported from the same module
as the guard that recognizes it, and `multer.js` is back to a thin caller.

Also pins the `.sh` back-compat claim with tests: configs from the
documented workarounds (`application/x-sh` per #4660/#5689/#6297, and the
broad patterns from #14804) still accept a `.sh` upload after the alias
rewrites the type. A negative control confirms the endpoint config is
genuinely in play rather than falling back to the default allowlist.
This commit is contained in:
Danny Avila 2026-08-14 01:12:23 -04:00 committed by GitHub
parent 6c46fd1252
commit 5e464bc930
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 139 additions and 5 deletions

View file

@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const multer = require('multer');
const { sanitizeFilename } = require('@librechat/api');
const { sanitizeFilename, createCustomError } = require('@librechat/api');
const {
mergeFileConfig,
inferMimeType,
@ -34,7 +34,7 @@ const importFileFilter = (req, file, cb) => {
} else if (path.extname(file.originalname).toLowerCase() === '.json') {
cb(null, true);
} else {
cb(new Error('Only JSON files are allowed'), false);
cb(createCustomError(415, 'Only JSON files are allowed'), false);
}
};
@ -58,7 +58,7 @@ const createFileFilter = (customFileConfig) => {
*/
const fileFilter = (req, file, cb) => {
if (!file) {
return cb(new Error('No file provided'), false);
return cb(createCustomError(400, 'No file provided'), false);
}
const mimeType = normalizeUploadMimeType(file);
@ -76,7 +76,10 @@ const createFileFilter = (customFileConfig) => {
});
if (!defaultFileConfig.checkType(mimeType, endpointFileConfig.supportedMimeTypes)) {
return cb(new Error('Unsupported file type: ' + (file.mimetype || mimeType)), false);
return cb(
createCustomError(415, 'Unsupported file type: ' + (file.mimetype || mimeType)),
false,
);
}
cb(null, true);

View file

@ -215,6 +215,8 @@ describe('Multer Configuration', () => {
const cb = jest.fn((err, result) => {
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe('Only JSON files are allowed');
expect(err.statusCode).toBe(415);
expect(err.body).toEqual({ message: 'Only JSON files are allowed' });
expect(result).toBe(false);
done();
});
@ -300,6 +302,72 @@ describe('Multer Configuration', () => {
fileFilter(mockReq, zipFile, cb);
});
it.each(['application/x-shellscript', 'text/x-shellscript'])(
'should normalize %s to application/x-sh and accept the upload',
(reportedType) => {
const { mergeFileConfig } = require('librechat-data-provider');
const fileFilter = createFileFilter(mergeFileConfig());
const shellFile = {
...mockFile,
originalname: 'script.sh',
mimetype: reportedType,
};
const cb = jest.fn();
fileFilter(mockReq, shellFile, cb);
expect(cb).toHaveBeenCalledWith(null, true);
expect(shellFile.mimetype).toBe('application/x-sh');
},
);
/** Normalization runs before the allowlist check, so an admin who applied one of the documented
* `.sh` workarounds must not be broken by it. Both recipes target `application/x-sh`, which is
* exactly what the alias now produces. */
it.each([
['the canonical type (#4660, #5689, #6297)', ['application/x-sh']],
['broad patterns (#14804)', ['image/.*', 'text/.*', 'application/.*']],
])(
'should keep accepting .sh for an existing workaround config allowing %s',
(_label, supportedMimeTypes) => {
const { mergeFileConfig } = require('librechat-data-provider');
const fileFilter = createFileFilter(
mergeFileConfig({ endpoints: { agents: { supportedMimeTypes } } }),
);
mockReq.body.endpoint = 'agents';
const shellFile = {
...mockFile,
originalname: 'script.sh',
mimetype: 'application/x-shellscript',
};
const cb = jest.fn();
fileFilter(mockReq, shellFile, cb);
expect(cb).toHaveBeenCalledWith(null, true);
},
);
it('should reject an unsupported type with a 415 the client can surface', () => {
const { mergeFileConfig } = require('librechat-data-provider');
const fileFilter = createFileFilter(mergeFileConfig());
const binaryFile = {
...mockFile,
originalname: 'program.exe',
mimetype: 'application/x-msdownload',
};
const cb = jest.fn();
fileFilter(mockReq, binaryFile, cb);
expect(cb).toHaveBeenCalledTimes(1);
const [error, result] = cb.mock.calls[0];
expect(result).toBe(false);
expect(error).toBeInstanceOf(Error);
expect(error.statusCode).toBe(415);
expect(error.body).toEqual({ message: 'Unsupported file type: application/x-msdownload' });
});
it('should use real mergeFileConfig function', async () => {
const { mergeFileConfig, mbToBytes } = require('librechat-data-provider');