From 6467eaccd369caadb1465a04beb87bcd4f14fc17 Mon Sep 17 00:00:00 2001 From: Osama Ansar Date: Wed, 28 Oct 2026 16:06:59 +0500 Subject: [PATCH] fix: reject invalid timezone offsets and arrays in JSON schema validation The JSON schema validator accepted `time` and `date-time` strings whose timezone offset has no hour (for example `12:00:00+:30`), because the hour group in the regexes was optional, while the grammar built for the schema requires it. It also accepted an array for an `object` schema, since `typeof [] === "object"`. Make the offset hour mandatory and reject arrays when an object is expected. --- .../utils/validateObjectAgainstGbnfSchema.ts | 10 +- .../validateObjectAgainstGbnfSchema.test.ts | 93 +++++++++++++++++++ 2 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 test/standalone/utils/validateObjectAgainstGbnfSchema.test.ts diff --git a/src/utils/gbnfJson/utils/validateObjectAgainstGbnfSchema.ts b/src/utils/gbnfJson/utils/validateObjectAgainstGbnfSchema.ts index 5a207a5a..ab6ed2fd 100644 --- a/src/utils/gbnfJson/utils/validateObjectAgainstGbnfSchema.ts +++ b/src/utils/gbnfJson/utils/validateObjectAgainstGbnfSchema.ts @@ -160,8 +160,10 @@ function validateObject( defs: Record = {}, defScopeDefs: DefScopeDefs = new DefScopeDefs() ): object is GbnfJsonSchemaToType { - if (typeof object !== "object" || object === null) - throw new TechnicalValidationError(`Expected an object but got "${typeof object}"`); + if (typeof object !== "object" || object === null || Array.isArray(object)) + throw new TechnicalValidationError( + `Expected an object but got "${object === null ? "null" : Array.isArray(object) ? "array" : typeof object}"` + ); let res = true; @@ -265,9 +267,9 @@ function validateBasicString(object: any, s } const dateRegex = /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12]\d|3[01])$/; -const timeRegex = /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d{3})?(Z|[+-]([01][0-9]|2[0-3])?:[0-5][0-9])$/; +const timeRegex = /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d{3})?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$/; const datetimeRegex = - /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d{3})?(Z|[+-]([01][0-9]|2[0-3])?:[0-5][0-9])$/; + /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d{3})?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$/; function validateFormatString(object: any, schema: T): object is GbnfJsonSchemaToType { if (typeof object !== "string") diff --git a/test/standalone/utils/validateObjectAgainstGbnfSchema.test.ts b/test/standalone/utils/validateObjectAgainstGbnfSchema.test.ts new file mode 100644 index 00000000..5f2b95b3 --- /dev/null +++ b/test/standalone/utils/validateObjectAgainstGbnfSchema.test.ts @@ -0,0 +1,93 @@ +import {describe, expect, test} from "vitest"; +import { + LlamaJsonSchemaValidationError, validateObjectAgainstGbnfSchema +} from "../../../src/utils/gbnfJson/utils/validateObjectAgainstGbnfSchema.js"; +import {getGbnfGrammarForGbnfJsonSchema} from "../../../src/utils/gbnfJson/getGbnfGrammarForGbnfJsonSchema.js"; + + +describe("utils", () => { + describe("validateObjectAgainstGbnfSchema", () => { + describe("format string", () => { + test("time with a valid timezone offset", () => { + const schema = {type: "string", format: "time"} as const; + + expect(validateObjectAgainstGbnfSchema("12:00:00Z", schema)).toBe(true); + expect(validateObjectAgainstGbnfSchema("12:00:00+05:30", schema)).toBe(true); + expect(validateObjectAgainstGbnfSchema("12:00:00.123-23:59", schema)).toBe(true); + }); + + test("time rejects a timezone offset without an hour", () => { + const schema = {type: "string", format: "time"} as const; + + expect(() => validateObjectAgainstGbnfSchema("12:00:00+:30", schema)).toThrowError(LlamaJsonSchemaValidationError); + expect(() => validateObjectAgainstGbnfSchema("12:00:00-:00", schema)).toThrowError(LlamaJsonSchemaValidationError); + expect(() => validateObjectAgainstGbnfSchema("12:00:00+24:00", schema)).toThrowError(LlamaJsonSchemaValidationError); + }); + + test("date-time with a valid timezone offset", () => { + const schema = {type: "string", format: "date-time"} as const; + + expect(validateObjectAgainstGbnfSchema("2024-01-01T10:00:00Z", schema)).toBe(true); + expect(validateObjectAgainstGbnfSchema("2024-01-01T10:00:00-08:00", schema)).toBe(true); + }); + + test("date-time rejects a timezone offset without an hour", () => { + const schema = {type: "string", format: "date-time"} as const; + + expect(() => validateObjectAgainstGbnfSchema("2024-01-01T10:00:00-:00", schema)).toThrowError( + LlamaJsonSchemaValidationError + ); + expect(() => validateObjectAgainstGbnfSchema("2024-01-01T10:00:00+:30", schema)).toThrowError( + LlamaJsonSchemaValidationError + ); + }); + + test("the grammar requires an hour in the timezone offset as well", () => { + for (const format of ["time", "date-time"] as const) { + const grammar = getGbnfGrammarForGbnfJsonSchema({type: "string", format}); + + expect(grammar).toContain('("+" | "-") ([01] [0-9] | "2" [0-3]) ":" [0-5] [0-9]'); + } + }); + }); + + describe("object", () => { + test("accepts a plain object", () => { + expect(validateObjectAgainstGbnfSchema({}, {type: "object", properties: {}})).toBe(true); + expect(validateObjectAgainstGbnfSchema( + {a: "b"}, + {type: "object", properties: {a: {type: "string"}}} + )).toBe(true); + }); + + test("rejects an array", () => { + const schema = {type: "object", properties: {}} as const; + + expect(() => validateObjectAgainstGbnfSchema([], schema)).toThrowError(LlamaJsonSchemaValidationError); + expect(() => validateObjectAgainstGbnfSchema([1, 2], schema)).toThrowError(LlamaJsonSchemaValidationError); + expect(() => validateObjectAgainstGbnfSchema([], schema)).toThrowError('Expected an object but got "array"'); + }); + + test("rejects null and primitives", () => { + const schema = {type: "object", properties: {}} as const; + + expect(() => validateObjectAgainstGbnfSchema(null, schema)).toThrowError('Expected an object but got "null"'); + expect(() => validateObjectAgainstGbnfSchema("a", schema)).toThrowError('Expected an object but got "string"'); + }); + + test("an empty array does not satisfy a oneOf of an object and null", () => { + const schema = {oneOf: [{type: "object", properties: {}}, {type: "null"}]} as const; + + expect(validateObjectAgainstGbnfSchema({}, schema)).toBe(true); + expect(validateObjectAgainstGbnfSchema(null, schema)).toBe(true); + expect(() => validateObjectAgainstGbnfSchema([], schema)).toThrowError(LlamaJsonSchemaValidationError); + }); + + test("the grammar of an object schema starts with an opening brace", () => { + const grammar = getGbnfGrammarForGbnfJsonSchema({type: "object", properties: {}}); + + expect(grammar).toMatch(/^root ::= "\{"/); + }); + }); + }); +});