fix(express,fastify): let a route's dest replace the module's storage

mergeMulterOptions() and mergeMultipartOptions() spread the route's
options over the module's, so a module-level storage and a route-level
dest both survived the merge. multer, like resolveStorage() on Fastify,
takes storage first, so the route's dest was silently ignored.

A route that sets dest but no storage now drops the module's storage.
With a module-level memoryStorage(), a route that sets dest now writes
files to disk, so file.buffer is undefined and file.path is set. With a
module-level disk, S3 or other engine, that route's files now go to its
dest instead.

Discovered by Anthropic's OSS Scanner, as bug ANT-2026-X70NB90Z.
This commit is contained in:
Kamil Myśliwiec committed 2026-10-16 11:48:01 +02:00
1 parent d4aacbc8e6
commit e075cda3c3
4 files changed
+35

No files matched your search

@@ -33,6 +33,11 @@ export function mergeMulterOptions(
if (isPlainObject(options.limits) && isPlainObject(localOptions.limits)) {
merged.limits = { ...options.limits, ...localOptions.limits };
}
// `dest` and `storage` both say where files go, and multer prefers
// `storage`, so a global storage engine would shadow a local `dest`.
if (localOptions.dest && !localOptions.storage) {
delete merged.storage;
}
return merged;
}
@@ -21,6 +21,18 @@ describe('mergeMulterOptions', () => {
const merged = mergeMulterOptions({ dest: '/global' }, { dest: '/local' });
expect(merged.dest).toBe('/local');
});
it('should let a local "dest" override a global "storage"', () => {
const merged = mergeMulterOptions(
{ storage: multer.memoryStorage() },
{ dest: '/local' },
);
expect(merged).toEqual({ dest: '/local' });
});
it('should keep a global "storage" when the route sets no "dest"', () => {
const storage = multer.memoryStorage();
const merged = mergeMulterOptions({ storage }, { preservePath: true });
expect(merged.storage).toBe(storage);
});
it('should merge limits key-by-key instead of replacing the whole object', () => {
const merged = mergeMulterOptions(
{ limits: { files: 2, fieldNameSize: 100 } },
@@ -30,6 +30,11 @@ export function mergeMultipartOptions(
if (isPlainObject(options.limits) && isPlainObject(localOptions.limits)) {
merged.limits = { ...options.limits, ...localOptions.limits };
}
// `dest` and `storage` both say where files go, and `resolveStorage()`
// prefers `storage`, so a global storage engine would shadow a local `dest`.
if (localOptions.dest && !localOptions.storage) {
delete merged.storage;
}
return merged;
}
@@ -9,6 +9,7 @@ import {
mergeMultipartOptions,
transformException,
} from '../../../multipart/multipart/multipart.utils.js';
import { memoryStorage } from '../../../multipart/storage/index.js';
describe('mergeMultipartOptions', () => {
it('should let local options override global options', () => {
@@ -17,6 +18,18 @@ describe('mergeMultipartOptions', () => {
).toEqual({ dest: '/local' });
});
it('should let a local "dest" override a global "storage"', () => {
expect(
mergeMultipartOptions({ storage: memoryStorage() }, { dest: '/local' }),
).toEqual({ dest: '/local' });
});
it('should keep a global "storage" when the route sets no "dest"', () => {
const storage = memoryStorage();
const merged = mergeMultipartOptions({ storage }, { preservePath: true });
expect(merged.storage).toBe(storage);
});
it('should merge limits key-by-key', () => {
const merged = mergeMultipartOptions(
{ limits: { files: 2, fileSize: 1 } },