fix(media): add UUID validation and update tests accordingly

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson
2026-07-05 03:28:45 +02:00
parent 6c5b44eaec
commit 833a2e12f3
4 changed files with 234 additions and 7 deletions
@@ -0,0 +1,183 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, it, expect, beforeAll, beforeEach, jest } from '@jest/globals';
import { MediaBackendType } from '@hedgedoc/commons';
import { promises as fs } from 'fs';
import { Mock } from 'ts-mockery';
import { MediaConfig } from '../../config/media.config';
import { MediaBackendError } from '../../errors/errors';
import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { FilesystemBackend } from './filesystem-backend';
jest.mock('fs', () => ({
promises: {
access: jest.fn(),
mkdir: jest.fn(),
readFile: jest.fn(),
unlink: jest.fn(),
writeFile: jest.fn(),
},
}));
describe('filesystem backend', () => {
const mockedUploadPath = '/tmp/test_uploads';
const mockedUuid = 'cbe87987-8e70-4092-a879-878e70b09245';
const mockedBuffer = Buffer.from('test');
const mockedLoggerService = Mock.of<ConsoleLoggerService>({
setContext: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
});
function mockMediaConfig(): MediaConfig {
return Mock.of<MediaConfig>({
backend: {
type: MediaBackendType.FILESYSTEM,
filesystem: {
uploadPath: mockedUploadPath,
},
},
});
}
let sut: FilesystemBackend;
beforeAll(() => {
sut = new FilesystemBackend(mockedLoggerService, mockMediaConfig());
});
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(fs, 'access').mockResolvedValue(undefined);
});
describe('saveFile', () => {
it('writes the buffer to the expected path and returns the backend data', async () => {
const writeFileSpy = jest.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
const result = await sut.saveFile(mockedUuid, mockedBuffer, {
mime: 'image/png',
ext: 'png',
});
expect(writeFileSpy).toHaveBeenCalledWith(
`${mockedUploadPath}/${mockedUuid}.png`,
mockedBuffer,
null,
);
expect(result).toBe(JSON.stringify({ ext: 'png', mime: 'image/png' }));
});
it('throws a MediaBackendError if the extension is not alphanumeric', async () => {
const writeFileSpy = jest.spyOn(fs, 'writeFile');
await expect(
sut.saveFile(mockedUuid, mockedBuffer, { mime: 'image/png', ext: '../png' as never }),
).rejects.toThrow(new MediaBackendError('Invalid file extension: ../png'));
expect(writeFileSpy).not.toHaveBeenCalled();
});
it('throws a MediaBackendError if the file could not be written', async () => {
jest.spyOn(fs, 'writeFile').mockRejectedValue(new Error('mocked error'));
await expect(
sut.saveFile(mockedUuid, mockedBuffer, { mime: 'image/png', ext: 'png' }),
).rejects.toThrow(`Could not save file '${mockedUploadPath}/${mockedUuid}.png'`);
});
});
describe('deleteFile', () => {
it('unlinks the file at the expected path', async () => {
const unlinkSpy = jest.spyOn(fs, 'unlink').mockResolvedValue(undefined);
await sut.deleteFile(mockedUuid, JSON.stringify({ ext: 'png' }));
expect(unlinkSpy).toHaveBeenCalledWith(`${mockedUploadPath}/${mockedUuid}.png`);
});
it('throws a MediaBackendError if no backend data is provided', async () => {
await expect(sut.deleteFile(mockedUuid, '')).rejects.toThrow('No backend data provided');
});
it('throws a MediaBackendError if the backend data has no extension', async () => {
await expect(sut.deleteFile(mockedUuid, JSON.stringify({ ext: '' }))).rejects.toThrow(
'No file extension in backend data',
);
});
it('throws a MediaBackendError if the extension is not alphanumeric', async () => {
const unlinkSpy = jest.spyOn(fs, 'unlink');
await expect(sut.deleteFile(mockedUuid, JSON.stringify({ ext: '../png' }))).rejects.toThrow(
new MediaBackendError('Invalid file extension: ../png'),
);
expect(unlinkSpy).not.toHaveBeenCalled();
});
it('throws a MediaBackendError if the file could not be deleted', async () => {
jest.spyOn(fs, 'unlink').mockRejectedValue(new Error('mocked error'));
await expect(sut.deleteFile(mockedUuid, JSON.stringify({ ext: 'png' }))).rejects.toThrow(
`Could not delete file '${mockedUploadPath}/${mockedUuid}.png'`,
);
});
});
describe('getFileUrl', () => {
it('returns the public media url for the file', async () => {
await expect(sut.getFileUrl(mockedUuid, '')).resolves.toBe(`/media/${mockedUuid}`);
});
});
describe('getFileResponse', () => {
it('reads the file from disk and returns its content together with the mime type', async () => {
const readFileSpy = jest.spyOn(fs, 'readFile').mockResolvedValue(mockedBuffer);
const result = await sut.getFileResponse(
mockedUuid,
JSON.stringify({ ext: 'png', mime: 'image/png' }),
);
expect(readFileSpy).toHaveBeenCalledWith(`${mockedUploadPath}/${mockedUuid}.png`);
expect(result).toEqual({
buffer: mockedBuffer,
contentType: 'image/png',
fileName: `${mockedUuid}.png`,
});
});
it('falls back to application/octet-stream if no mime type is stored', async () => {
jest.spyOn(fs, 'readFile').mockResolvedValue(mockedBuffer);
const result = await sut.getFileResponse(mockedUuid, JSON.stringify({ ext: 'png' }));
expect(result.contentType).toBe('application/octet-stream');
});
it('throws a MediaBackendError if no backend data is provided', async () => {
await expect(sut.getFileResponse(mockedUuid, null)).rejects.toThrow(
'No backend data provided',
);
});
it('throws a MediaBackendError if the backend data has no extension', async () => {
await expect(
sut.getFileResponse(mockedUuid, JSON.stringify({ ext: '', mime: 'image/png' })),
).rejects.toThrow('No file extension in backend data');
});
it('throws a MediaBackendError if the extension is not alphanumeric', async () => {
const readFileSpy = jest.spyOn(fs, 'readFile');
await expect(
sut.getFileResponse(mockedUuid, JSON.stringify({ ext: '../png', mime: 'image/png' })),
).rejects.toThrow(new MediaBackendError('Invalid file extension: ../png'));
expect(readFileSpy).not.toHaveBeenCalled();
});
});
});
@@ -13,7 +13,7 @@ import mediaConfiguration, { MediaConfig } from '../../config/media.config';
import { MediaBackendError } from '../../errors/errors'; import { MediaBackendError } from '../../errors/errors';
import { ConsoleLoggerService } from '../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { MediaBackend } from '../media-backend.interface'; import { MediaBackend } from '../media-backend.interface';
import { MediaFileResponse } from '../media-response.interface' import { MediaFileResponse } from '../media-response.interface';
@Injectable() @Injectable()
export class FilesystemBackend implements MediaBackend { export class FilesystemBackend implements MediaBackend {
@@ -83,7 +83,7 @@ export class FilesystemBackend implements MediaBackend {
if (!backendData) { if (!backendData) {
throw new MediaBackendError('No backend data provided'); throw new MediaBackendError('No backend data provided');
} }
const { ext, mime } = JSON.parse(backendData) as { ext: string, mime: string }; const { ext, mime } = JSON.parse(backendData) as { ext: string; mime: string };
if (!ext) { if (!ext) {
throw new MediaBackendError('No file extension in backend data'); throw new MediaBackendError('No file extension in backend data');
} }
@@ -94,6 +94,9 @@ export class FilesystemBackend implements MediaBackend {
} }
private getFilePath(fileName: string, extension: string): string { private getFilePath(fileName: string, extension: string): string {
if (!/^[a-zA-Z0-9]+$/.test(extension)) {
throw new MediaBackendError(`Invalid file extension: ${extension}`);
}
return join(this.uploadDirectory, `${fileName}.${extension}`); return join(this.uploadDirectory, `${fileName}.${extension}`);
} }
+30 -3
View File
@@ -3,7 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { describe, it, expect, beforeAll, afterEach, jest } from '@jest/globals'; import { describe, it, expect, beforeAll, beforeEach, afterEach, jest } from '@jest/globals';
import { import {
FieldNameMediaUpload, FieldNameMediaUpload,
FieldNameMediaUploadNote, FieldNameMediaUploadNote,
@@ -38,6 +38,7 @@ describe('MediaService', () => {
const userId = 1; const userId = 1;
const noteId = 2; const noteId = 2;
const uuid = '0198c9b6-117f-7215-93e2-5ca4b718225f'; const uuid = '0198c9b6-117f-7215-93e2-5ca4b718225f';
const invalidUuid = 'not-a-valid-uuid';
const fileName = 'test.png'; const fileName = 'test.png';
const backendType = MediaBackendType.FILESYSTEM; const backendType = MediaBackendType.FILESYSTEM;
const backendData = JSON.stringify({ ext: 'png' }); const backendData = JSON.stringify({ ext: 'png' });
@@ -73,6 +74,10 @@ describe('MediaService', () => {
fileSystemBackend = module.get<FilesystemBackend>(FilesystemBackend); fileSystemBackend = module.get<FilesystemBackend>(FilesystemBackend);
}); });
beforeEach(() => {
jest.spyOn(uuidModule, 'validate').mockReturnValue(true);
});
afterEach(() => { afterEach(() => {
tracker.reset(); tracker.reset();
jest.clearAllMocks(); jest.clearAllMocks();
@@ -223,10 +228,10 @@ describe('MediaService', () => {
); );
const result = await service.getFileResponse(uuid); const result = await service.getFileResponse(uuid);
expect(result).toEqual({ expect(result).toEqual({
kind: 'file', type: 'file',
buffer: fileBuffer, buffer: fileBuffer,
contentType: 'image/png', contentType: 'image/png',
fileName, fileName: `${uuid}.png`,
}); });
expect(tracker.history.select).toHaveLength(1); expect(tracker.history.select).toHaveLength(1);
expect(tracker.history.select[0].bindings).toEqual([uuid, 1]); expect(tracker.history.select[0].bindings).toEqual([uuid, 1]);
@@ -270,6 +275,14 @@ describe('MediaService', () => {
await expect(service.findUploadByUuid(uuid)).rejects.toThrow(NotInDBError); await expect(service.findUploadByUuid(uuid)).rejects.toThrow(NotInDBError);
expectBindings(tracker, 'select', [[uuid]], true); expectBindings(tracker, 'select', [[uuid]], true);
}); });
it('throws NotInDBError when given an invalid uuid', async () => {
jest.spyOn(uuidModule, 'validate').mockReturnValueOnce(false);
await expect(service.findUploadByUuid(invalidUuid)).rejects.toThrow(
new NotInDBError('Invalid media upload id provided', 'MediaService', 'findUploadByUuid'),
);
expect(tracker.history.select).toHaveLength(0);
});
}); });
describe('getMediaUploadUuidsByUserId', () => { describe('getMediaUploadUuidsByUserId', () => {
@@ -346,4 +359,18 @@ describe('MediaService', () => {
]); ]);
}); });
}); });
describe('getMediaUploadDtoByUuid', () => {
it('throws NotInDBError when given an invalid uuid', async () => {
jest.spyOn(uuidModule, 'validate').mockReturnValueOnce(false);
await expect(service.getMediaUploadDtoByUuid(invalidUuid)).rejects.toThrow(
new NotInDBError(
'Invalid media upload id provided',
'MediaService',
'getMediaUploadDtoByUuid',
),
);
expect(tracker.history.select).toHaveLength(0);
});
});
}); });
+16 -2
View File
@@ -21,7 +21,7 @@ import { ModuleRef } from '@nestjs/core';
import * as FileType from 'file-type'; import * as FileType from 'file-type';
import { Knex } from 'knex'; import { Knex } from 'knex';
import { InjectConnection } from 'nest-knexjs'; import { InjectConnection } from 'nest-knexjs';
import { v7 as uuidV7 } from 'uuid'; import { v7 as uuidV7, validate as validateUuid } from 'uuid';
import mediaConfiguration, { MediaConfig } from '../config/media.config'; import mediaConfiguration, { MediaConfig } from '../config/media.config';
import { MediaUploadDto } from '../dtos/media-upload.dto'; import { MediaUploadDto } from '../dtos/media-upload.dto';
@@ -40,7 +40,7 @@ import { ImgurBackend } from './backends/imgur-backend';
import { S3Backend } from './backends/s3-backend'; import { S3Backend } from './backends/s3-backend';
import { WebdavBackend } from './backends/webdav-backend'; import { WebdavBackend } from './backends/webdav-backend';
import { MediaBackend } from './media-backend.interface'; import { MediaBackend } from './media-backend.interface';
import { MediaResponse } from './media-response.interface' import { MediaResponse } from './media-response.interface';
@Injectable() @Injectable()
export class MediaService { export class MediaService {
@@ -213,6 +213,13 @@ export class MediaService {
* @throws NotInDBError if the MediaUpload entity with the provided UUID is not found in the database * @throws NotInDBError if the MediaUpload entity with the provided UUID is not found in the database
*/ */
async findUploadByUuid(uuid: string): Promise<MediaUpload> { async findUploadByUuid(uuid: string): Promise<MediaUpload> {
if (!validateUuid(uuid)) {
throw new NotInDBError(
'Invalid media upload id provided',
this.logger.getContext(),
'findUploadByUuid',
);
}
const mediaUpload = await this.knex(TableMediaUpload) const mediaUpload = await this.knex(TableMediaUpload)
.select() .select()
.where(FieldNameMediaUpload.uuid, uuid) .where(FieldNameMediaUpload.uuid, uuid)
@@ -328,6 +335,13 @@ export class MediaService {
* @returns The {@link MediaUploadDto} * @returns The {@link MediaUploadDto}
*/ */
async getMediaUploadDtoByUuid(uuid: string): Promise<MediaUploadDto> { async getMediaUploadDtoByUuid(uuid: string): Promise<MediaUploadDto> {
if (!validateUuid(uuid)) {
throw new NotInDBError(
'Invalid media upload id provided',
this.logger.getContext(),
'getMediaUploadDtoByUuid',
);
}
const mediaUpload = await this.knex(TableMediaUpload) const mediaUpload = await this.knex(TableMediaUpload)
.join( .join(
TableUser, TableUser,