Skip to content

Commit c58fa1f

Browse files
committed
fix: resolve refs to definitions keys with percent-encoded characters
1 parent 7fad3c2 commit c58fa1f

2 files changed

Lines changed: 117 additions & 1 deletion

File tree

‎lib/validator.js‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class Validator {
4343
const ajvSchema = clone(schema)
4444
this.convertSchemaToAjvFormat(ajvSchema)
4545
this.ajv.addSchema(ajvSchema, schemaKey)
46-
this._ajvSchemas[schemaKey] = schema
46+
this._ajvSchemas[schemaKey] = ajvSchema
4747
}
4848
}
4949

@@ -70,13 +70,56 @@ class Validator {
7070
schema.fjs_type = 'string'
7171
schema.type.push('object')
7272
}
73+
74+
// Ajv percent-decodes the $ref fragment before matching it against the
75+
// definitions keys (see ajv's unescapeFragment), while our internal
76+
// serializer resolves refs literally. Decode the definitions/$defs keys
77+
// so that keys copied verbatim into $ref (e.g. 'Some%3Cloremipsum%3E')
78+
// resolve consistently on both sides.
79+
this.normalizeDefinitionsKeys(schema)
80+
7381
for (const property in schema) {
7482
if (typeof schema[property] === 'object') {
7583
this.convertSchemaToAjvFormat(schema[property])
7684
}
7785
}
7886
}
7987

88+
normalizeDefinitionsKeys (schema) {
89+
for (const containerKey of ['definitions', '$defs']) {
90+
const defs = schema[containerKey]
91+
if (defs === null || typeof defs !== 'object' || Array.isArray(defs)) {
92+
continue
93+
}
94+
for (const key of Object.keys(defs)) {
95+
let decoded
96+
try {
97+
decoded = decodeURIComponent(key)
98+
} catch {
99+
// Invalid or incomplete percent sequences: decode only the valid
100+
// percent-encoded segments, leaving the rest untouched.
101+
decoded = key.replace(/%[0-9a-fA-F]{2}/g, (segment) => {
102+
try {
103+
return decodeURIComponent(segment)
104+
} catch {
105+
return segment
106+
}
107+
})
108+
}
109+
if (decoded === key) {
110+
continue
111+
}
112+
// Skip when decoding would collide with an existing key, otherwise
113+
// one of the two definitions would silently shadow the other.
114+
if (Object.prototype.hasOwnProperty.call(defs, decoded)) {
115+
continue
116+
}
117+
defs[decoded] = defs[key]
118+
delete defs[key]
119+
}
120+
}
121+
}
122+
80123
getState () {
81124
return {
82125
ajvOptions: this._ajvOptions,

‎test/ref.test.js‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2075,3 +2075,76 @@ test('ref nested', (t) => {
20752075
t.assert.doesNotThrow(() => JSON.parse(output))
20762076
t.assert.equal(output, '{"str":"test"}')
20772077
})
2078+
2079+
test('ref internal - definitions key with percent-encoded characters (#740)', (t) => {
2080+
t.plan(2)
2081+
2082+
const schema = {
2083+
title: 'object with $ref',
2084+
definitions: {
2085+
'Some%3Cloremipsum%3E': {
2086+
additionalProperties: {
2087+
oneOf: [
2088+
{ type: 'string' },
2089+
{ type: 'number' },
2090+
{ type: 'object' },
2091+
{ type: 'null' }
2092+
]
2093+
},
2094+
type: 'object'
2095+
}
2096+
},
2097+
type: 'object',
2098+
properties: {
2099+
obj: {
2100+
$ref: '#/definitions/Some%3Cloremipsum%3E'
2101+
}
2102+
}
2103+
}
2104+
2105+
const object = {
2106+
obj: {
2107+
str: 'test'
2108+
}
2109+
}
2110+
2111+
const stringify = build(schema)
2112+
const output = stringify(object)
2113+
2114+
t.assert.doesNotThrow(() => JSON.parse(output))
2115+
t.assert.equal(output, '{"obj":{"str":"test"}}')
2116+
})
2117+
2118+
test('ref internal - $defs key with percent-encoded characters (#740)', (t) => {
2119+
t.plan(2)
2120+
2121+
const schema = {
2122+
$defs: {
2123+
'Some%3Cloremipsum%3E': {
2124+
type: 'object',
2125+
properties: {
2126+
str: { type: 'string' }
2127+
},
2128+
required: ['str']
2129+
}
2130+
},
2131+
type: 'object',
2132+
properties: {
2133+
obj: {
2134+
$ref: '#/$defs/Some%3Cloremipsum%3E'
2135+
}
2136+
}
2137+
}
2138+
2139+
const object = {
2140+
obj: {
2141+
str: 'test'
2142+
}
2143+
}
2144+
2145+
const stringify = build(schema)
2146+
const output = stringify(object)
2147+
2148+
t.assert.doesNotThrow(() => JSON.parse(output))
2149+
t.assert.equal(output, '{"obj":{"str":"test"}}')
2150+
})

0 commit comments

Comments
 (0)