Restrict hotlink mimeType to image/* and harden image responses.
This commit is contained in:
@@ -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 { HotlinkImageParamsDto } from '../dto/HotlinkImageParamsDto';
|
||||||
import { UploadService } from '../services/UploadService';
|
import { UploadService } from '../services/UploadService';
|
||||||
|
|
||||||
@@ -7,6 +7,9 @@ export class HotlinkImageController {
|
|||||||
constructor(private readonly uploadService: UploadService) {}
|
constructor(private readonly uploadService: UploadService) {}
|
||||||
|
|
||||||
@Get(':id/:secret')
|
@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<StreamableFile> {
|
async getImage(@Param() params: HotlinkImageParamsDto): Promise<StreamableFile> {
|
||||||
const { body, mimeType } = await this.uploadService.getHotlinkImage(params.id, params.secret);
|
const { body, mimeType } = await this.uploadService.getHotlinkImage(params.id, params.secret);
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto';
|
||||||
|
|
||||||
export class CreateHotlinkUploadDto extends UploadCiphertextOptionsDto {
|
export class CreateHotlinkUploadDto extends UploadCiphertextOptionsDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsMimeType()
|
@IsImageMimeType()
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user