diff --git a/backend/src/types/validation/IsBase64Options.ts b/backend/src/types/validation/IsBase64Options.ts new file mode 100644 index 0000000..e008f27 --- /dev/null +++ b/backend/src/types/validation/IsBase64Options.ts @@ -0,0 +1,3 @@ +export interface IsBase64Options { + byteLength?: number; +} diff --git a/backend/src/types/validation/NullOrIntOptions.ts b/backend/src/types/validation/NullOrIntOptions.ts new file mode 100644 index 0000000..baded07 --- /dev/null +++ b/backend/src/types/validation/NullOrIntOptions.ts @@ -0,0 +1,4 @@ +export type NullOrIntOptions = { + min?: number; + max?: number; +}; diff --git a/backend/src/utils/toBase64.spec.ts b/backend/src/utils/toBase64.spec.ts new file mode 100644 index 0000000..73684bf --- /dev/null +++ b/backend/src/utils/toBase64.spec.ts @@ -0,0 +1,39 @@ +import { toBase64 } from './toBase64'; + +describe('toBase64', () => { + it('encodes utf8 strings', () => { + expect(toBase64('hello')).toBe('aGVsbG8='); + }); + + it('encodes empty strings', () => { + expect(toBase64('')).toBe(''); + }); + + it('encodes buffers', () => { + const bytes = Buffer.from([0x01, 0x02, 0x03]); + + expect(toBase64(bytes)).toBe(bytes.toString('base64')); + }); + + it('encodes empty buffers', () => { + expect(toBase64(Buffer.alloc(0))).toBe(''); + }); + + it('encodes binary buffers without utf8 interpretation', () => { + const bytes = Buffer.from([0xff, 0xfe, 0xfd]); + + expect(toBase64(bytes)).toBe('//79'); + }); + + it('round-trips utf8 strings through base64 decode', () => { + const original = 'upload-test-🔒'; + + expect(Buffer.from(toBase64(original), 'base64').toString('utf8')).toBe(original); + }); + + it('round-trips buffers through base64 decode', () => { + const original = Buffer.alloc(12, 0xab); + + expect(Buffer.from(toBase64(original), 'base64')).toEqual(original); + }); +}); diff --git a/backend/src/utils/toBase64.ts b/backend/src/utils/toBase64.ts new file mode 100644 index 0000000..697154b --- /dev/null +++ b/backend/src/utils/toBase64.ts @@ -0,0 +1,2 @@ +export const toBase64 = (input: Buffer | string): string => + typeof input === 'string' ? Buffer.from(input, 'utf8').toString('base64') : input.toString('base64'); diff --git a/backend/src/validation/decorators/isBase64.spec.ts b/backend/src/validation/decorators/isBase64.spec.ts new file mode 100644 index 0000000..cdc6b6b --- /dev/null +++ b/backend/src/validation/decorators/isBase64.spec.ts @@ -0,0 +1,253 @@ +import 'reflect-metadata'; +import { validateSync, type ValidationError } from 'class-validator'; +import { toBase64 } from '../../utils/toBase64'; +import { IsBase64 } from './isBase64'; + +class UnboundedBase64Dto { + @IsBase64() + payload: string; +} + +class TwelveByteBase64Dto { + @IsBase64({ byteLength: 12 }) + iv: string; +} + +class ThirtyTwoByteBase64Dto { + @IsBase64({ byteLength: 32 }) + secretKey: string; +} + +class DualFieldDto { + @IsBase64({ byteLength: 12 }) + iv: string; + + @IsBase64() + ciphertext: string; +} + +const validateField = (DtoClass: new () => T, property: keyof T & string, value: unknown) => { + const dto = Object.assign(new DtoClass(), { [property]: value }); + + return validateSync(dto); +}; + +const constraintMessage = (errors: ValidationError[], property: string): string | undefined => { + const error = errors.find(e => e.property === property); + + return error?.constraints?.isBase64; +}; + +const accepts = (DtoClass: new () => T, property: keyof T & string, value: unknown) => { + expect(validateField(DtoClass, property, value)).toHaveLength(0); +}; + +const rejects = (DtoClass: new () => T, property: keyof T & string, value: unknown) => { + expect(validateField(DtoClass, property, value).length).toBeGreaterThan(0); +}; + +describe('IsBase64', () => { + describe('without byteLength', () => { + it('accepts standard base64 for short utf8 payloads', () => { + accepts(UnboundedBase64Dto, 'payload', toBase64('hello')); + }); + + it('accepts base64 without padding when length allows', () => { + accepts(UnboundedBase64Dto, 'payload', 'Zm9v'); + }); + + it('accepts base64 with single padding character', () => { + accepts(UnboundedBase64Dto, 'payload', 'YQ=='); + }); + + it('accepts base64 with double padding', () => { + accepts(UnboundedBase64Dto, 'payload', 'YWI='); + }); + + it('accepts uppercase and lowercase alphabet characters', () => { + accepts(UnboundedBase64Dto, 'payload', toBase64('Ab+/Zz09')); + }); + + it('accepts plus and slash in the alphabet portion', () => { + const raw = Buffer.from([0xff, 0xfe, 0xfd, 0xfc, 0xfb, 0xfa]); + + accepts(UnboundedBase64Dto, 'payload', raw.toString('base64')); + }); + + it('accepts large binary payloads', () => { + accepts(UnboundedBase64Dto, 'payload', toBase64(Buffer.alloc(4096, 0xab))); + }); + + it('accepts a one-byte decoded payload', () => { + accepts(UnboundedBase64Dto, 'payload', Buffer.from([0x00]).toString('base64')); + }); + + it.each([null, undefined, 0, 1, false, true, {}, [], Symbol('x')])( + 'rejects non-string value %p', + value => { + rejects(UnboundedBase64Dto, 'payload', value); + } + ); + + it('rejects empty string', () => { + rejects(UnboundedBase64Dto, 'payload', ''); + }); + + it.each(['@', '#', '$', '%', '-', '_', '.', ',', '!', '?', '\\', '"', "'"])( + 'rejects disallowed character %s', + char => { + rejects(UnboundedBase64Dto, 'payload', `YQ==${char}`); + } + ); + + it('rejects whitespace before valid base64', () => { + rejects(UnboundedBase64Dto, 'payload', ` ${toBase64('a')}`); + }); + + it('rejects whitespace after valid base64', () => { + rejects(UnboundedBase64Dto, 'payload', `${toBase64('a')} `); + }); + + it('rejects embedded newline', () => { + rejects(UnboundedBase64Dto, 'payload', 'YQ==\n'); + }); + + it('rejects embedded tab', () => { + rejects(UnboundedBase64Dto, 'payload', 'YQ\t=='); + }); + + it('rejects unicode outside base64 alphabet', () => { + rejects(UnboundedBase64Dto, 'payload', 'YQé=='); + }); + + it('rejects more than two padding characters', () => { + rejects(UnboundedBase64Dto, 'payload', 'YQ==='); + }); + + it('rejects padding characters in the middle of the string', () => { + rejects(UnboundedBase64Dto, 'payload', 'Y=Q='); + }); + + it('rejects strings that are only padding', () => { + rejects(UnboundedBase64Dto, 'payload', '=='); + }); + + it('returns the generic error message', () => { + const errors = validateField(UnboundedBase64Dto, 'payload', 'not!!!valid'); + + expect(constraintMessage(errors, 'payload')).toBe('payload must be a valid base64 string'); + }); + }); + + describe('with byteLength', () => { + const twelveBytes = Buffer.alloc(12, 0x11); + const twelveBytesB64 = twelveBytes.toString('base64'); + + it('accepts base64 that decodes to exactly 12 bytes', () => { + accepts(TwelveByteBase64Dto, 'iv', twelveBytesB64); + }); + + it('accepts 12 random bytes', () => { + const iv = Buffer.from([ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c + ]); + + accepts(TwelveByteBase64Dto, 'iv', iv.toString('base64')); + }); + + it('accepts 32-byte secrets when byteLength is 32', () => { + accepts(ThirtyTwoByteBase64Dto, 'secretKey', Buffer.alloc(32, 0x42).toString('base64')); + }); + + it('rejects valid base64 that decodes to 11 bytes when 12 are required', () => { + rejects(TwelveByteBase64Dto, 'iv', Buffer.alloc(11, 0x01).toString('base64')); + }); + + it('rejects valid base64 that decodes to 13 bytes when 12 are required', () => { + rejects(TwelveByteBase64Dto, 'iv', Buffer.alloc(13, 0x01).toString('base64')); + }); + + it('rejects valid base64 that decodes to 1 byte when 32 are required', () => { + rejects(ThirtyTwoByteBase64Dto, 'secretKey', toBase64('a')); + }); + + it('rejects valid base64 that decodes to 31 bytes when 32 are required', () => { + rejects(ThirtyTwoByteBase64Dto, 'secretKey', Buffer.alloc(31, 0x01).toString('base64')); + }); + + it('rejects invalid base64 even when length would have matched', () => { + rejects(TwelveByteBase64Dto, 'iv', `${twelveBytesB64}!`); + }); + + it('rejects empty string when byteLength is set', () => { + rejects(TwelveByteBase64Dto, 'iv', ''); + }); + + it('returns byteLength-specific error message', () => { + const errors = validateField(TwelveByteBase64Dto, 'iv', toBase64('short')); + + expect(constraintMessage(errors, 'iv')).toBe('iv must be a base64-encoded 12-byte value'); + }); + + it('returns byteLength-specific error message for 32-byte constraint', () => { + const errors = validateField(ThirtyTwoByteBase64Dto, 'secretKey', toBase64('x')); + + expect(constraintMessage(errors, 'secretKey')).toBe( + 'secretKey must be a base64-encoded 32-byte value' + ); + }); + }); + + describe('multiple decorated properties on one DTO', () => { + const iv = Buffer.alloc(12, 0x99).toString('base64'); + const ciphertext = toBase64(Buffer.alloc(64, 0xcd)); + + it('accepts when both iv and ciphertext are valid', () => { + const dto = Object.assign(new DualFieldDto(), { iv, ciphertext }); + + expect(validateSync(dto)).toHaveLength(0); + }); + + it('rejects when only iv is invalid', () => { + const dto = Object.assign(new DualFieldDto(), { iv: 'bad', ciphertext }); + + const errors = validateSync(dto); + + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('iv'); + }); + + it('rejects when only ciphertext is invalid', () => { + const dto = Object.assign(new DualFieldDto(), { iv, ciphertext: '###' }); + + const errors = validateSync(dto); + + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('ciphertext'); + }); + + it('reports both properties when both are invalid', () => { + const dto = Object.assign(new DualFieldDto(), { iv: '', ciphertext: '' }); + + const errors = validateSync(dto); + + expect(errors.map(e => e.property).sort()).toEqual(['ciphertext', 'iv']); + }); + }); + + describe('default options object', () => { + class DefaultOptionsDto { + @IsBase64({}) + payload: string; + } + + it('behaves like unbounded base64 when options is an empty object', () => { + accepts(DefaultOptionsDto, 'payload', toBase64('defaults')); + }); + + it('rejects wrong byte length only when byteLength is explicitly set', () => { + rejects(TwelveByteBase64Dto, 'iv', toBase64('x')); + accepts(UnboundedBase64Dto, 'payload', toBase64('x')); + }); + }); +}); diff --git a/backend/src/validation/decorators/isBase64.ts b/backend/src/validation/decorators/isBase64.ts new file mode 100644 index 0000000..b2cdecf --- /dev/null +++ b/backend/src/validation/decorators/isBase64.ts @@ -0,0 +1,48 @@ +import { + Validate, + ValidatorConstraint, + type ValidationArguments, + type ValidatorConstraintInterface +} from 'class-validator'; +import type { IsBase64Options } from '../../types/validation/IsBase64Options'; + +const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/; + +@ValidatorConstraint({ name: 'isBase64' }) +class IsBase64Constraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments): boolean { + if (typeof value !== 'string' || !value) { + return false; + } + + if (!BASE64_PATTERN.test(value)) { + return false; + } + + const decoded = Buffer.from(value, 'base64'); + + if (decoded.length === 0) { + return false; + } + + const [{ byteLength }] = args.constraints as [IsBase64Options]; + + if (byteLength !== undefined) { + return decoded.length === byteLength; + } + + return true; + } + + defaultMessage(args: ValidationArguments): string { + const [{ byteLength }] = args.constraints as [IsBase64Options]; + + if (byteLength !== undefined) { + return `$property must be a base64-encoded ${byteLength}-byte value`; + } + + return '$property must be a valid base64 string'; + } +} + +export const IsBase64 = (options: IsBase64Options = {}) => Validate(IsBase64Constraint, [options]); diff --git a/backend/src/validation/decorators/nullOr.spec.ts b/backend/src/validation/decorators/nullOr.spec.ts new file mode 100644 index 0000000..31037df --- /dev/null +++ b/backend/src/validation/decorators/nullOr.spec.ts @@ -0,0 +1,57 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import { NullOrDate, NullOrInt } from './nullOr'; + +class DateFieldDto { + @NullOrDate() + expiresAt: Date | null; +} + +class IntFieldDto { + @NullOrInt({ min: 1, max: 100 }) + maxViews: number | null; +} + +describe('NullOrDate', () => { + it('accepts null', () => { + const dto = plainToInstance(DateFieldDto, { expiresAt: null }); + + expect(validateSync(dto)).toHaveLength(0); + }); + + it('accepts an ISO string transformed to Date', () => { + const dto = plainToInstance(DateFieldDto, { expiresAt: '2026-06-01T00:00:00.000Z' }); + + expect(validateSync(dto)).toHaveLength(0); + expect(dto.expiresAt).toBeInstanceOf(Date); + }); + + it('rejects missing property', () => { + expect(validateSync(new DateFieldDto()).length).toBeGreaterThan(0); + }); +}); + +describe('NullOrInt', () => { + it('accepts null', () => { + const dto = Object.assign(new IntFieldDto(), { maxViews: null }); + + expect(validateSync(dto)).toHaveLength(0); + }); + + it('accepts an integer within bounds', () => { + const dto = Object.assign(new IntFieldDto(), { maxViews: 50 }); + + expect(validateSync(dto)).toHaveLength(0); + }); + + it('rejects out-of-range integers', () => { + const dto = Object.assign(new IntFieldDto(), { maxViews: 0 }); + + expect(validateSync(dto).length).toBeGreaterThan(0); + }); + + it('rejects missing property', () => { + expect(validateSync(new IntFieldDto()).length).toBeGreaterThan(0); + }); +}); diff --git a/backend/src/validation/decorators/nullOr.ts b/backend/src/validation/decorators/nullOr.ts new file mode 100644 index 0000000..d544290 --- /dev/null +++ b/backend/src/validation/decorators/nullOr.ts @@ -0,0 +1,77 @@ +import { applyDecorators } from '@nestjs/common'; +import { Type } from 'class-transformer'; +import { + Validate, + ValidatorConstraint, + type ValidationArguments, + type ValidationOptions, + type ValidatorConstraintInterface +} from 'class-validator'; +import type { NullOrIntOptions } from '../../types/validation/NullOrIntOptions'; + +@ValidatorConstraint({ name: 'nullOrDate' }) +class NullOrDateConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + if (value === null) { + return true; + } + + return value instanceof Date && !Number.isNaN(value.getTime()); + } + + defaultMessage(): string { + return '$property must be null or a valid date'; + } +} + +@ValidatorConstraint({ name: 'nullOrInt' }) +class NullOrIntConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments): boolean { + if (value === null) { + return true; + } + + const [{ min, max }] = args.constraints as [NullOrIntOptions]; + + if (typeof value !== 'number' || !Number.isInteger(value)) { + return false; + } + + if (min !== undefined && value < min) { + return false; + } + + if (max !== undefined && value > max) { + return false; + } + + return true; + } + + defaultMessage(args: ValidationArguments): string { + const [{ min, max }] = args.constraints as [NullOrIntOptions]; + + if (min !== undefined && max !== undefined) { + return `$property must be null or an integer between ${min} and ${max}`; + } + + if (min !== undefined) { + return `$property must be null or an integer >= ${min}`; + } + + if (max !== undefined) { + return `$property must be null or an integer <= ${max}`; + } + + return '$property must be null or an integer'; + } +} + +export const NullOrDate = (validationOptions?: ValidationOptions) => + applyDecorators( + Type(() => Date), + Validate(NullOrDateConstraint, validationOptions) + ); + +export const NullOrInt = (options?: NullOrIntOptions, validationOptions?: ValidationOptions) => + applyDecorators(Validate(NullOrIntConstraint, [options ?? {}], validationOptions));