From 79a865e9b323e443fe8d0f945af86a0eb2f390d6 Mon Sep 17 00:00:00 2001 From: "otoneko." Date: Tue, 15 Sep 2026 01:39:54 +0900 Subject: [PATCH] fix(type): check that UUID values are valid UUIDs The UUID type guard and deserializer only checked the length and the dash positions, so any 36-character string with dashes in the right places passed, including ones with non-hex characters. They now match RFC 9562: hex digits, version 1 to 8, the RFC variant, or the Nil and Max UUIDs. --- packages/type/src/serializer.ts | 14 ++++++++--- packages/type/tests/validation.spec.ts | 35 ++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/type/src/serializer.ts b/packages/type/src/serializer.ts index 7ca053e7d..1cbf7e9e3 100644 --- a/packages/type/src/serializer.ts +++ b/packages/type/src/serializer.ts @@ -1941,6 +1941,12 @@ export class TypeGuardRegistry { } } +/** + * A UUID per RFC 9562: version 1 to 8 with the RFC variant (8, 9, a, or b), + * or the Nil and Max UUIDs (sections 5.9 and 5.10). + */ +const uuidPattern = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|0{8}-0{4}-0{4}-0{4}-0{12}|f{8}-f{4}-f{4}-f{4}-f{12})$/i; + /** * Default serializer that can convert JS data structures to the target type. * It coerces types, converts object literals to class instances, and has type guards for JS types. @@ -2025,8 +2031,8 @@ export class Serializer { }); this.deserializeRegistry.addDecorator(isUUIDType, (type, state) => { - const v = state.accessor; - const check = `${v}.length === 36 && ${v}[23] === '-' && ${v}[18] === '-' && ${v}[13] === '-' && ${v}[8] === '-'`; + state.setContext({ uuidPattern }); + const check = `uuidPattern.test(${state.accessor})`; state.addCode(` if (!(${check})) ${state.throwCode(type, JSON.stringify('Not a UUID'))} `); @@ -2219,8 +2225,8 @@ export class Serializer { state.addSetterAndReportErrorIfInvalid('type', 'Not a string', `'string' === typeof ${state.accessor}`); }); this.typeGuards.getRegistry(1).addDecorator(isUUIDType, (type, state) => { - const v = state.originalAccessor; - const check = `${state.setter} && ${v}.length === 36 && ${v}[23] === '-' && ${v}[18] === '-' && ${v}[13] === '-' && ${v}[8] === '-'`; + state.setContext({ uuidPattern }); + const check = `${state.setter} && uuidPattern.test(${state.originalAccessor})`; state.addSetterAndReportErrorIfInvalid('type', 'Not a UUID', check); }); this.typeGuards.getRegistry(1).addDecorator(isMongoIdType, (type, state) => { diff --git a/packages/type/tests/validation.spec.ts b/packages/type/tests/validation.spec.ts index 357b75185..6f2a8b0f5 100644 --- a/packages/type/tests/validation.spec.ts +++ b/packages/type/tests/validation.spec.ts @@ -1,10 +1,10 @@ import { expect, jest, test } from '@jest/globals'; import { Email, MaxLength, MinLength, Positive, Validate, validate, validates, ValidatorError } from '../src/validator.js'; import { assert, is } from '../src/typeguard.js'; -import { AutoIncrement, Excluded, Group, integer, PrimaryKey, Type, Unique } from '../src/reflection/type.js'; +import { AutoIncrement, Excluded, Group, integer, PrimaryKey, Type, Unique, UUID } from '../src/reflection/type.js'; import { t } from '../src/decorator.js'; import { ReflectionClass, typeOf } from '../src/reflection/reflection.js'; -import { cast, castFunction, validatedDeserialize } from '../src/serializer-facade.js'; +import { cast, castFunction, deserialize, validatedDeserialize } from '../src/serializer-facade.js'; test('primitives', () => { expect(validate('Hello')).toEqual([]); @@ -14,6 +14,37 @@ test('primitives', () => { expect(validate(123)).toEqual([]); }); +test('UUID', () => { + const valid = [ + 'c232ab00-9414-11ec-b3c8-9f6bdeced846', // v1 + '919108f7-52d1-4320-9bac-f847db4148a8', // v4 + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', // v7 + '2489e9ad-2ee2-8e00-8ec9-32d5f69181c0', // v8 + '919108F7-52D1-4320-9BAC-F847DB4148A8', // uppercase + '00000000-0000-0000-0000-000000000000', // Nil + 'ffffffff-ffff-ffff-ffff-ffffffffffff', // Max + ]; + for (const value of valid) { + expect(is(value)).toBe(true); + expect(validate(value)).toEqual([]); + expect(deserialize(value)).toBe(value); + } + + const invalid = [ + 'zzzzzzzz-zzzz-4zzz-8zzz-zzzzzzzzzzzz', // not hex + '919108f7-52d1-0320-9bac-f847db4148a8', // version 0 + '919108f7-52d1-f320-9bac-f847db4148a8', // version f + '919108f7-52d1-4320-0bac-f847db4148a8', // variant 0 + '919108f7-52d1-4320-cbac-f847db4148a8', // variant c + '00000000-0000-0000-0000-000000000001', // almost Nil + ]; + for (const value of invalid) { + expect(is(value)).toBe(false); + expect(validate(value)).toEqual([{ code: 'type', message: 'Not a UUID', path: '', value }]); + expect(() => deserialize(value)).toThrow('Not a UUID'); + } +}); + test('email', () => { expect(is('peter@example.com')).toBe(true); expect(is('nope')).toBe(false);