From 91722a5e2f61f566e3e8f97ec5adb18671608b37 Mon Sep 17 00:00:00 2001 From: Mitch Fultz <57411549+fitchmultz@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:45:36 -0600 Subject: [PATCH] fix: escape literal property names in data pointers --- src/getNode.ts | 3 +- src/keywords/additionalProperties.ts | 5 +- src/keywords/oneOf.ts | 10 +- src/keywords/patternProperties.ts | 7 +- src/keywords/properties.ts | 3 +- src/keywords/propertyNames.ts | 3 +- src/keywords/unevaluatedProperties.ts | 12 +- src/methods/toDataNodes.ts | 5 +- .../issues/data-pointer-escaping.test.ts | 165 ++++++++++++++++++ src/utils/appendDataPointer.ts | 6 + 10 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 src/tests/issues/data-pointer-escaping.test.ts create mode 100644 src/utils/appendDataPointer.ts diff --git a/src/getNode.ts b/src/getNode.ts index 06eef06d..b7a4586a 100644 --- a/src/getNode.ts +++ b/src/getNode.ts @@ -2,6 +2,7 @@ import { GetNodeOptions, isSchemaNode, SchemaNode } from "./SchemaNode"; import { isJsonError, NodeOrError, OptionalNodeOrError } from "./types"; import { split } from "@sagold/json-pointer"; import { getValue } from "./utils/getValue"; +import { appendDataPointer } from "./utils/appendDataPointer"; // prettier-ignore export function getNode(pointer: string, data: unknown, options: { withSchemaWarning: true } & GetNodeOptions): NodeOrError; @@ -42,7 +43,7 @@ export function getNode( let currentPointer = "#"; let currentNode = node; for (let i = 0, l = keys.length; i < l; i += 1) { - currentPointer = `${currentPointer}/${keys[i]}`; + currentPointer = appendDataPointer(currentPointer, keys[i]); const result = currentNode.getNodeChild(keys[i], data, { ...options, pointer: currentPointer }); if (result.error) { return result; diff --git a/src/keywords/additionalProperties.ts b/src/keywords/additionalProperties.ts index bda67a6a..c8194c19 100644 --- a/src/keywords/additionalProperties.ts +++ b/src/keywords/additionalProperties.ts @@ -1,3 +1,4 @@ +import { appendDataPointer } from "../utils/appendDataPointer"; import settings from "../settings"; import { isObject } from "../utils/isObject"; import { Keyword, JsonSchemaResolverParams, JsonSchemaValidatorParams, ValidationReturnType } from "../Keyword"; @@ -106,7 +107,7 @@ function validateAdditionalProperty({ node, data, pointer = "#", path }: JsonSch const validationErrors = validateNode( node.additionalProperties, propertyValue, - `${pointer}/${property}`, + appendDataPointer(pointer, property), path ); if (validationErrors) { @@ -116,7 +117,7 @@ function validateAdditionalProperty({ node, data, pointer = "#", path }: JsonSch } else { errors.push( node.createError("no-additional-properties-error", { - pointer: `${pointer}/${property}`, + pointer: appendDataPointer(pointer, property), schema, value: data, property, diff --git a/src/keywords/oneOf.ts b/src/keywords/oneOf.ts index 964a9917..1b0ea474 100644 --- a/src/keywords/oneOf.ts +++ b/src/keywords/oneOf.ts @@ -1,3 +1,4 @@ +import { appendDataPointer } from "../utils/appendDataPointer"; import { Keyword, JsonSchemaReducerParams, @@ -161,7 +162,7 @@ export function reduceOneOfDeclarator({ node, data, pointer, path }: Omit 0) { @@ -317,7 +318,12 @@ function validateFromDeclarator({ node, data, pointer = "#", path }: JsonSchemaV for (const oneOfNode of oneOf) { const { node: oneOfPropertyNode, error } = oneOfNode.getNodeChild(oneOfProperty, oneOfValue); if (oneOfPropertyNode) { - const validationResult = validateNode(oneOfPropertyNode, oneOfValue, `${pointer}/${oneOfProperty}`, path); + const validationResult = validateNode( + oneOfPropertyNode, + oneOfValue, + appendDataPointer(pointer, oneOfProperty), + path + ); if (validationResult.length > 0) { errors.push(...validationResult); } else { diff --git a/src/keywords/patternProperties.ts b/src/keywords/patternProperties.ts index 91ae49a8..61965df8 100644 --- a/src/keywords/patternProperties.ts +++ b/src/keywords/patternProperties.ts @@ -1,3 +1,4 @@ +import { appendDataPointer } from "../utils/appendDataPointer"; import { mergeSchema } from "../utils/mergeSchema"; import { JsonSchema, SchemaNode } from "../types"; import { isObject } from "../utils/isObject"; @@ -107,7 +108,9 @@ function validatePatternProperties({ node, data, pointer, path }: JsonSchemaVali const value = getValue(data, key); // patternProperties was tested in addValidate const matchingPatterns = patternProperties!.filter((property) => property.pattern.test(key)); - matchingPatterns.forEach(({ node }) => errors.push(...validateNode(node, value, `${pointer}/${key}`, path))); + matchingPatterns.forEach(({ node }) => + errors.push(...validateNode(node, value, appendDataPointer(pointer, key), path)) + ); if (properties[key]) { return; @@ -118,7 +121,7 @@ function validatePatternProperties({ node, data, pointer, path }: JsonSchemaVali errors.push( node.createError("no-additional-properties-error", { key, - pointer: `${pointer}/${key}`, + pointer: appendDataPointer(pointer, key), schema, value, patterns diff --git a/src/keywords/properties.ts b/src/keywords/properties.ts index c1bc2146..55f41a8e 100644 --- a/src/keywords/properties.ts +++ b/src/keywords/properties.ts @@ -1,3 +1,4 @@ +import { appendDataPointer } from "../utils/appendDataPointer"; import { getValue } from "../utils/getValue"; import { SchemaNode } from "../types"; import { @@ -71,7 +72,7 @@ function validateProperties({ node, data, pointer, path }: JsonSchemaValidatorPa return; } - const result = validateNode(propertyNode, value, `${pointer}/${propertyName}`, path); + const result = validateNode(propertyNode, value, appendDataPointer(pointer, propertyName), path); errors.push(...result); }); return errors; diff --git a/src/keywords/propertyNames.ts b/src/keywords/propertyNames.ts index 6ceb666b..ed6b5ff2 100644 --- a/src/keywords/propertyNames.ts +++ b/src/keywords/propertyNames.ts @@ -1,3 +1,4 @@ +import { appendDataPointer } from "../utils/appendDataPointer"; import { isBooleanSchema, isJsonSchema, JsonError } from "../types"; import { isObject } from "../utils/isObject"; import { SchemaNode } from "../types"; @@ -71,7 +72,7 @@ function validatePropertyNames({ node, data, pointer, path }: JsonSchemaValidato const errors: JsonError[] = []; const properties = Object.keys(data); properties.forEach((prop) => { - const validationResult = validateNode(propertyNames, prop, `${pointer}/${prop}`, path); + const validationResult = validateNode(propertyNames, prop, appendDataPointer(pointer, prop), path); if (validationResult.length > 0) { errors.push( node.createError("invalid-property-name-error", { diff --git a/src/keywords/unevaluatedProperties.ts b/src/keywords/unevaluatedProperties.ts index b3f5e529..888d2965 100644 --- a/src/keywords/unevaluatedProperties.ts +++ b/src/keywords/unevaluatedProperties.ts @@ -1,3 +1,4 @@ +import { appendDataPointer } from "../utils/appendDataPointer"; import { isObject } from "../utils/isObject"; import { isBooleanSchema, isJsonSchema, SchemaNode } from "../types"; import { Keyword, JsonSchemaValidatorParams, ValidationReturnType } from "../Keyword"; @@ -68,20 +69,21 @@ function validateUnevaluatedProperties({ node, data, pointer, path }: JsonSchema } const { node: child } = node.getNodeChild(propertyName, data, { pointer, path }); + const propertyPointer = appendDataPointer(pointer, propertyName); if (child === undefined) { if (node.unevaluatedProperties) { const validationResult = validateNode( node.unevaluatedProperties, data[propertyName], - `${pointer}/${propertyName}`, + propertyPointer, path ); errors.push(...validationResult); } else if (node.schema.unevaluatedProperties === false) { errors.push( node.createError("unevaluated-property-error", { - pointer: `${pointer}/${propertyName}`, + pointer: propertyPointer, value: JSON.stringify(data[propertyName]), schema: node.schema }) @@ -89,19 +91,19 @@ function validateUnevaluatedProperties({ node, data, pointer, path }: JsonSchema } } - if (child && validateNode(child, data[propertyName], `${pointer}/${propertyName}`, path).length > 0) { + if (child && validateNode(child, data[propertyName], propertyPointer, path).length > 0) { if (node.unevaluatedProperties) { const validationResult = validateNode( node.unevaluatedProperties, data[propertyName], - `${pointer}/${propertyName}`, + propertyPointer, path ); errors.push(...validationResult); } else if (node.schema.unevaluatedProperties === false) { errors.push( node.createError("unevaluated-property-error", { - pointer: `${pointer}/${propertyName}`, + pointer: propertyPointer, value: JSON.stringify(data[propertyName]), schema: node.schema }) diff --git a/src/methods/toDataNodes.ts b/src/methods/toDataNodes.ts index 6bdb181f..e2467524 100644 --- a/src/methods/toDataNodes.ts +++ b/src/methods/toDataNodes.ts @@ -1,3 +1,4 @@ +import { appendDataPointer } from "../utils/appendDataPointer"; import { getValue } from "../utils/getValue"; import { isObject } from "../utils/isObject"; import { SchemaNode } from "../types"; @@ -16,14 +17,14 @@ export function toDataNodes(node: SchemaNode, data: unknown, pointer = "#", data Object.keys(data).forEach((key) => { const { node: nextNode } = currentNode.getNodeChild(key, data); if (nextNode) { - toDataNodes(nextNode, getValue(data, key), `${pointer}/${key}`, dataNodes); + toDataNodes(nextNode, getValue(data, key), appendDataPointer(pointer, key), dataNodes); } }); } else if (Array.isArray(data)) { data.forEach((next: unknown, key: number) => { const { node: nextNode } = currentNode.getNodeChild(key, data); if (nextNode) { - toDataNodes(nextNode, getValue(data, key), `${pointer}/${key}`, dataNodes); + toDataNodes(nextNode, getValue(data, key), appendDataPointer(pointer, key), dataNodes); } }); } diff --git a/src/tests/issues/data-pointer-escaping.test.ts b/src/tests/issues/data-pointer-escaping.test.ts new file mode 100644 index 00000000..cfe3f037 --- /dev/null +++ b/src/tests/issues/data-pointer-escaping.test.ts @@ -0,0 +1,165 @@ +import { strict as assert } from "assert"; +import { compileSchema } from "../../compileSchema"; +import { isJsonError, JsonSchema } from "../../types"; + +describe("data pointer escaping", () => { + it("distinguishes nested property names containing slashes", () => { + const node = compileSchema({ + properties: { + "a/b": { properties: { c: { type: "number" } } }, + a: { properties: { "b/c": { type: "number" } } } + } + }); + const { valid, errors } = node.validate({ "a/b": { c: "invalid" }, a: { "b/c": "invalid" } }); + + assert.equal(valid, false); + assert.deepEqual(errors.map((error) => error.data.pointer), ["#/a~1b/c", "#/a/b~1c"]); + assert.equal(node.validate({ "a/b": { c: 1 }, a: { "b/c": 2 } }).valid, true); + }); + + it("preserves prefixes, empty names and array indexes without re-escaping ancestors", () => { + const node = compileSchema({ + properties: { "~1/": { properties: { "": { items: { type: "number" } } } } } + }); + for (const prefix of ["#", "", "/parent~1name", "custom/root~0/"]) { + const { valid, errors } = node.validate({ "~1/": { "": ["invalid"] } }, prefix); + assert.equal(valid, false); + assert.deepEqual(errors.map((error) => error.data.pointer), [`${prefix}/~01~1//0`]); + } + assert.equal(node.validate({ "~1/": { "": [1] } }).valid, true); + }); + + const cases: { name: string; schema: JsonSchema; validData: unknown; codes: string[] }[] = [ + { + name: "additionalProperties schema", + schema: { additionalProperties: { type: "number" } }, + validData: { "a/b~": 1 }, + codes: ["type-error"] + }, + { + name: "additionalProperties false", + schema: { additionalProperties: false }, + validData: {}, + codes: ["no-additional-properties-error"] + }, + { + name: "matching patternProperties", + schema: { patternProperties: { "^a": { type: "number" } } }, + validData: { "a/b~": 1 }, + codes: ["type-error"] + }, + { + name: "unmatched patternProperties with additionalProperties false", + schema: { patternProperties: { "^z": {} }, additionalProperties: false }, + validData: { z: true }, + codes: ["no-additional-properties-error"] + }, + { + name: "unevaluatedProperties schema without a child schema", + schema: { unevaluatedProperties: { type: "number" } }, + validData: { "a/b~": 1 }, + codes: ["type-error"] + }, + { + name: "unevaluatedProperties false without a child schema", + schema: { unevaluatedProperties: false }, + validData: {}, + codes: ["unevaluated-property-error"] + }, + { + name: "unevaluatedProperties schema with an invalid child", + schema: { + allOf: [{ properties: { "a/b~": { type: "string" } } }], + unevaluatedProperties: { type: "number" } + }, + validData: { "a/b~": "valid" }, + codes: ["type-error", "type-error"] + }, + { + name: "unevaluatedProperties false with an invalid child", + schema: { + allOf: [{ properties: { "a/b~": { type: "string" } } }], + unevaluatedProperties: false + }, + validData: { "a/b~": "valid" }, + codes: ["type-error", "unevaluated-property-error"] + } + ]; + + for (const { name, schema, validData, codes } of cases) { + it(`escapes property names in ${name} errors`, () => { + const node = compileSchema(schema); + const { valid, errors } = node.validate({ "a/b~": true }); + + assert.equal(valid, false); + assert.deepEqual(errors.map((error) => error.code), codes); + assert.deepEqual(errors.map((error) => error.data.pointer), codes.map(() => "#/a~1b~0")); + assert.equal(node.validate(validData).valid, true); + }); + } + + it("escapes the nested propertyNames error while retaining the containing object pointer", () => { + const node = compileSchema({ propertyNames: { pattern: "^[a-z]+$" } }); + const { valid, errors } = node.validate({ "a/b~": 1 }); + + assert.equal(valid, false); + assert.equal(errors.length, 1); + assert.equal(errors[0].code, "invalid-property-name-error"); + assert.equal(errors[0].data.pointer, "#"); + const validationError = errors[0].data.validationError; + assert(isJsonError(validationError)); + assert.equal(validationError.data.pointer, "#/a~1b~0"); + assert.equal(node.validate({ abc: 1 }).valid, true); + }); + + for (const method of ["validate", "reduceNode"] as const) { + it(`escapes nested oneOf declarator errors from ${method}`, () => { + const node = compileSchema({ + oneOfProperty: "a/b~", + oneOf: [ + { properties: { "a/b~": { const: "first" } } }, + { properties: { "a/b~": { const: "second" } } } + ] + }); + const data = { "a/b~": "unknown" }; + const error = method === "validate" ? node.validate(data).errors[0] : node.reduceNode(data).error; + + assert(error); + assert.equal(error.code, method === "validate" ? "one-of-error" : "one-of-property-error"); + const nestedErrors = error.data.errors; + assert(Array.isArray(nestedErrors)); + assert.equal(nestedErrors.length, 2); + for (const nestedError of nestedErrors) { + assert(isJsonError(nestedError)); + assert.equal(nestedError.data.pointer, "#/a~1b~0"); + } + assert.equal(node.validate({ "a/b~": "first" }).valid, true); + assert(node.reduceNode({ "a/b~": "first" }).node); + }); + } + + it("escapes traversal warning pointers in getNode", () => { + const node = compileSchema({ properties: { "a/b": {} } }); + const { error } = node.getNode("#/a~1b/~0", { "a/b": { "~": 1 } }, { withSchemaWarning: true }); + + assert(error); + assert.equal(error.code, "schema-warning"); + assert.equal(error.data.pointer, "#/a~1b/~0"); + }); + + it("escapes toDataNodes pointers while preserving values, prefixes, empty names and indexes", () => { + const node = compileSchema({ + properties: { "~1/": { properties: { "": { items: { type: "number" } } } } } + }); + const data = { "~1/": { "": [1] } }; + for (const prefix of ["#", "", "/parent~1name", "custom/root~0/"]) { + const nodes = node.toDataNodes(data, prefix); + assert.deepEqual(nodes.map(({ pointer, value }) => [pointer, value]), [ + [prefix, data], + [`${prefix}/~01~1`, { "": [1] }], + [`${prefix}/~01~1/`, [1]], + [`${prefix}/~01~1//0`, 1] + ]); + } + }); +}); diff --git a/src/utils/appendDataPointer.ts b/src/utils/appendDataPointer.ts new file mode 100644 index 00000000..cea77272 --- /dev/null +++ b/src/utils/appendDataPointer.ts @@ -0,0 +1,6 @@ +import { join } from "@sagold/json-pointer"; + +/** Append a literal property or index without reinterpreting the existing pointer. */ +export function appendDataPointer(pointer: string, key: string | number) { + return pointer + join([String(key)]); +}