From bf36b1d2aa2a48e51b6e6d5893fed2f5336175ed Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 26 Oct 2026 23:06:23 +0100 Subject: [PATCH] Restrict hotlink mimeType to image/* and harden image responses. --- .../controllers/HotlinkImageController.ts | 5 ++- .../upload/dto/CreateHotlinkUploadDto.ts | 5 +-- backend/src/utils/isImageMimeType.spec.ts | 11 ++++++ backend/src/utils/isImageMimeType.ts | 7 ++++ .../decorators/isImageMimeType.spec.ts | 36 +++++++++++++++++++ .../validation/decorators/isImageMimeType.ts | 21 +++++++++++ 6 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 backend/src/utils/isImageMimeType.spec.ts create mode 100644 backend/src/utils/isImageMimeType.ts create mode 100644 backend/src/validation/decorators/isImageMimeType.spec.ts create mode 100644 backend/src/validation/decorators/isImageMimeType.ts diff --git a/backend/src/modules/upload/controllers/HotlinkImageController.ts b/backend/src/modules/upload/controllers/HotlinkImageController.ts index f63d11a..a54475f 100644 --- a/backend/src/modules/upload/controllers/HotlinkImageController.ts +++ b/backend/src/modules/upload/controllers/HotlinkImageController.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, StreamableFile } from '@nestjs/common'; +import { Controller, Get, Header, Param, StreamableFile } from '@nestjs/common'; import { HotlinkImageParamsDto } from '../dto/HotlinkImageParamsDto'; import { UploadService } from '../services/UploadService'; @@ -7,6 +7,9 @@ export class HotlinkImageController { constructor(private readonly uploadService: UploadService) {} @Get(':id/:secret') + @Header('X-Content-Type-Options', 'nosniff') + @Header('Content-Security-Policy', 'sandbox') + @Header('Referrer-Policy', 'no-referrer') async getImage(@Param() params: HotlinkImageParamsDto): Promise { const { body, mimeType } = await this.uploadService.getHotlinkImage(params.id, params.secret); diff --git a/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts b/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts index 9bc16de..12da3ae 100644 --- a/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts +++ b/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts @@ -1,9 +1,10 @@ -import { IsMimeType, IsNotEmpty, IsString } from 'class-validator'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { IsImageMimeType } from '../../../validation/decorators/isImageMimeType'; import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto'; export class CreateHotlinkUploadDto extends UploadCiphertextOptionsDto { @IsNotEmpty() @IsString() - @IsMimeType() + @IsImageMimeType() mimeType: string; } diff --git a/backend/src/utils/isImageMimeType.spec.ts b/backend/src/utils/isImageMimeType.spec.ts new file mode 100644 index 0000000..fb06307 --- /dev/null +++ b/backend/src/utils/isImageMimeType.spec.ts @@ -0,0 +1,11 @@ +import { isImageMimeType } from './isImageMimeType'; + +describe('isImageMimeType', () => { + it.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'IMAGE/PNG'])('accepts %s', mime => { + expect(isImageMimeType(mime)).toBe(true); + }); + + it.each(['text/html', 'application/javascript', 'text/plain', 'image', 'image/'])('rejects %s', mime => { + expect(isImageMimeType(mime)).toBe(false); + }); +}); diff --git a/backend/src/utils/isImageMimeType.ts b/backend/src/utils/isImageMimeType.ts new file mode 100644 index 0000000..81b63dd --- /dev/null +++ b/backend/src/utils/isImageMimeType.ts @@ -0,0 +1,7 @@ +const IMAGE_MIME_PREFIX = 'image/'; + +export const isImageMimeType = (mimeType: string): boolean => { + const lower = mimeType.toLowerCase(); + + return lower.startsWith(IMAGE_MIME_PREFIX) && lower.length > IMAGE_MIME_PREFIX.length; +}; diff --git a/backend/src/validation/decorators/isImageMimeType.spec.ts b/backend/src/validation/decorators/isImageMimeType.spec.ts new file mode 100644 index 0000000..4e8d96e --- /dev/null +++ b/backend/src/validation/decorators/isImageMimeType.spec.ts @@ -0,0 +1,36 @@ +import 'reflect-metadata'; +import { validateSync, type ValidationError } from 'class-validator'; +import { IsImageMimeType } from './isImageMimeType'; + +class ImageMimeDto { + @IsImageMimeType() + mimeType: string; +} + +const validateField = (value: unknown) => { + const dto = Object.assign(new ImageMimeDto(), { mimeType: value }); + + return validateSync(dto); +}; + +const constraintMessage = (errors: ValidationError[]): string | undefined => errors[0]?.constraints?.isImageMimeType; + +describe('IsImageMimeType', () => { + it.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'IMAGE/PNG'])('accepts %s', mime => { + expect(validateField(mime)).toHaveLength(0); + }); + + it.each(['text/html', 'application/javascript', 'text/plain', 'image', 'image/'])('rejects %s', mime => { + expect(validateField(mime).length).toBeGreaterThan(0); + }); + + it.each([null, undefined, 0, false, {}, []])('rejects non-string value %p', value => { + expect(validateField(value).length).toBeGreaterThan(0); + }); + + it('returns the image mime error message', () => { + const errors = validateField('text/html'); + + expect(constraintMessage(errors)).toBe('mimeType must be an image/* type'); + }); +}); diff --git a/backend/src/validation/decorators/isImageMimeType.ts b/backend/src/validation/decorators/isImageMimeType.ts new file mode 100644 index 0000000..1f9ad71 --- /dev/null +++ b/backend/src/validation/decorators/isImageMimeType.ts @@ -0,0 +1,21 @@ +import { + Validate, + ValidatorConstraint, + type ValidationOptions, + type ValidatorConstraintInterface +} from 'class-validator'; +import { isImageMimeType } from '../../utils/isImageMimeType'; + +@ValidatorConstraint({ name: 'isImageMimeType' }) +class IsImageMimeTypeConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + return typeof value === 'string' && isImageMimeType(value); + } + + defaultMessage(): string { + return '$property must be an image/* type'; + } +} + +export const IsImageMimeType = (validationOptions?: ValidationOptions) => + Validate(IsImageMimeTypeConstraint, validationOptions);