add local ciphertext storage module to backend.
Bind-mount backend/uploads in dev; path helpers with traversal guards and Jest coverage for StorageService.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { join, resolve } from 'node:path';
|
||||
import { getUploadsDir, resolveStoragePath } from './uploadPaths';
|
||||
|
||||
describe('uploadPaths', () => {
|
||||
describe('getUploadsDir', () => {
|
||||
it('returns uploads under process cwd', () => {
|
||||
expect(getUploadsDir()).toBe(join(process.cwd(), 'uploads'));
|
||||
});
|
||||
|
||||
it('returns a stable path on repeated calls', () => {
|
||||
expect(getUploadsDir()).toBe(getUploadsDir());
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStoragePath', () => {
|
||||
it('resolves a storage key under uploads dir', () => {
|
||||
const storageKey = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
expect(resolveStoragePath(storageKey)).toBe(resolve(getUploadsDir(), storageKey));
|
||||
});
|
||||
|
||||
it('resolves a nested storage key under uploads dir', () => {
|
||||
const storageKey = 'nested/a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
expect(resolveStoragePath(storageKey)).toBe(resolve(getUploadsDir(), storageKey));
|
||||
});
|
||||
|
||||
it('rejects path traversal with parent segments', () => {
|
||||
expect(() => resolveStoragePath('../../etc/passwd')).toThrow('Invalid storage key');
|
||||
expect(() => resolveStoragePath('../outside')).toThrow('Invalid storage key');
|
||||
});
|
||||
|
||||
it('rejects absolute storage keys outside uploads dir', () => {
|
||||
expect(() => resolveStoragePath('/etc/passwd')).toThrow('Invalid storage key');
|
||||
});
|
||||
|
||||
it('rejects empty storage key', () => {
|
||||
expect(() => resolveStoragePath('')).toThrow('Invalid storage key');
|
||||
});
|
||||
|
||||
it('rejects traversal hidden after a benign prefix', () => {
|
||||
expect(() => resolveStoragePath('safe/../../etc/passwd')).toThrow('Invalid storage key');
|
||||
});
|
||||
|
||||
it.each(['..', 'foo/../../../etc/passwd'])('rejects suspicious storage key: %s', storageKey => {
|
||||
expect(() => resolveStoragePath(storageKey)).toThrow('Invalid storage key');
|
||||
});
|
||||
|
||||
it('treats encoded traversal literals as opaque file names', () => {
|
||||
const storageKey = '%2e%2e%2fetc/passwd';
|
||||
|
||||
expect(resolveStoragePath(storageKey)).toBe(resolve(getUploadsDir(), storageKey));
|
||||
});
|
||||
|
||||
it('allows dot characters inside a single file name segment', () => {
|
||||
const storageKey = 'my.file.name.bin';
|
||||
|
||||
expect(resolveStoragePath(storageKey)).toBe(resolve(getUploadsDir(), storageKey));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
|
||||
export const getUploadsDir = (): string => join(process.cwd(), 'uploads');
|
||||
|
||||
const validatePathTraversal = (baseDir: string, absolutePath: string): void => {
|
||||
const resolvedBaseDir = resolve(baseDir);
|
||||
|
||||
if (!absolutePath.startsWith(`${resolvedBaseDir}${sep}`)) {
|
||||
throw new Error('Invalid storage key');
|
||||
}
|
||||
};
|
||||
|
||||
const resolvePathWithinDir = (baseDir: string, storageKey: string): string => {
|
||||
const resolvedBaseDir = resolve(baseDir);
|
||||
const absolutePath = resolve(resolvedBaseDir, storageKey);
|
||||
|
||||
validatePathTraversal(resolvedBaseDir, absolutePath);
|
||||
|
||||
return absolutePath;
|
||||
};
|
||||
|
||||
export const resolveStoragePath = (storageKey: string): string => {
|
||||
const uploadsDir = getUploadsDir();
|
||||
|
||||
return resolvePathWithinDir(uploadsDir, storageKey);
|
||||
};
|
||||
Reference in New Issue
Block a user