Skip to content

Commit 2ff9d48

Browse files
authored
fix: properly validate $ref value escaping (#1760)
* add tests for $ref encoding * use percent encoding and decoding for $ref values * add validator for non-unreserved RFC3986 characters in $ref values * unescape $ref values before checking for existence of the path * don't use full unescaper for existential path testing * use querystring-browser for consistent cross-platform escaping * touch up error messaging * update tests to match actual error messaging
1 parent c282972 commit 2ff9d48

File tree

4 files changed

+187
-3
lines changed

4 files changed

+187
-3
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"json-beautify": "^1.0.1",
5252
"json-refs": "^3.0.4",
5353
"prop-types": "15.6.0",
54+
"querystring-browser": "^1.0.4",
5455
"react": "^15.6.2",
5556
"react-ace": "^4.1.6",
5657
"react-addons-css-transition-group": "^15.4.2",

src/plugins/refs-util.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import qs from "querystring-browser"
2+
13
/**
24
* Unescapes a JSON pointer.
35
* @api public
@@ -6,13 +8,13 @@ export function unescapeJsonPointerToken(token) {
68
if (typeof token !== "string") {
79
return token
810
}
9-
return token.replace(/~1/g, "/").replace(/~0/g, "~")
11+
return qs.unescape(token.replace(/~1/g, "/").replace(/~0/g, "~"))
1012
}
1113

1214
/**
1315
* Escapes a JSON pointer.
1416
* @api public
1517
*/
1618
export function escapeJsonPointerToken(token) {
17-
return token.replace(/~/g, "~0").replace(/\//g, "~1")
19+
return qs.escape(token.replace(/~/g, "~0").replace(/\//g, "~1"))
1820
}

src/plugins/validate-semantic/validators/2and3/refs.js

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import get from "lodash/get"
22
import { escapeJsonPointerToken } from "../../../refs-util"
3+
import qs from "querystring-browser"
34
import { pathFromPtr } from "json-refs"
45

56
export const validate2And3RefHasNoSiblings = () => system => {
@@ -89,7 +90,7 @@ export const validate2And3RefPointersExist = () => (system) => {
8990
const value = node.node
9091
if(typeof value === "string" && value[0] === "#") {
9192
// if pointer starts with "#", it is a local ref
92-
const path = pathFromPtr(value)
93+
const path = pathFromPtr(qs.unescape(value))
9394

9495
if(json.getIn(path) === undefined) {
9596
errors.push({
@@ -104,3 +105,35 @@ export const validate2And3RefPointersExist = () => (system) => {
104105
return errors
105106
})
106107
}
108+
109+
// from RFC3986: https://tools.ietf.org/html/rfc3986#section-2.2
110+
// plus "%", since it is needed for encoding.
111+
const RFC3986_UNRESERVED_CHARACTERS = /[A-Z|a-z|0-9|\-|_|\.|~|%]/g
112+
113+
export const validate2And3RefPointersAreProperlyEscaped = () => (system) => {
114+
return system.validateSelectors.all$refs()
115+
.then((refs) => {
116+
const errors = []
117+
118+
refs.forEach((node) => {
119+
const value = node.node
120+
const hashIndex = value.indexOf("#")
121+
const fragment = hashIndex > -1 ? value.slice(hashIndex + 1) : null
122+
if(typeof fragment === "string") {
123+
const rawPath = fragment.split("/")
124+
const hasReservedChars = rawPath
125+
.some(p => p.replace(RFC3986_UNRESERVED_CHARACTERS, "").length > 0)
126+
127+
if(hasReservedChars) {
128+
errors.push({
129+
path: [...node.path.slice(0, -1), "$ref"],
130+
message: "$ref values must be RFC3986-compliant percent-encoded URIs",
131+
level: "error"
132+
})
133+
}
134+
}
135+
})
136+
137+
return errors
138+
})
139+
}

test/plugins/validate-semantic/2and3/refs.js

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,154 @@ describe("validation plugin - semantic - 2and3 refs", function() {
340340
expect(allSemanticErrors).toEqual([])
341341
})
342342
})
343+
it("should return an error when a JSON pointer uses incorrect percent-encoding in Swagger 2", () => {
344+
const spec = {
345+
"swagger": "2.0",
346+
"paths": {
347+
"/foo": {
348+
"get": {
349+
"responses": {
350+
"200": {
351+
"description": "Success",
352+
"schema": {
353+
"$ref": "#/definitions/foo bar"
354+
}
355+
}
356+
}
357+
}
358+
}
359+
},
360+
"definitions": {
361+
"foo bar": {
362+
"type": "string"
363+
}
364+
}
365+
}
366+
367+
return validateHelper(spec)
368+
.then(system => {
369+
const allSemanticErrors = system.errSelectors.allErrors().toJS()
370+
.filter(err => err.source !== "resolver")
371+
expect(allSemanticErrors[0]).toInclude({
372+
level: "warning",
373+
message: "Definition was declared but never used in document",
374+
path: ["definitions", "foo bar"]
375+
})
376+
expect(allSemanticErrors.length).toEqual(2)
377+
expect(allSemanticErrors[1]).toInclude({
378+
level: "error",
379+
message: "$ref values must be RFC3986-compliant percent-encoded URIs",
380+
path: ["paths", "/foo", "get", "responses", "200", "schema", "$ref"]
381+
})
382+
})
383+
})
384+
it("should return an error when a JSON pointer uses incorrect percent-encoding in OpenAPI 3", () => {
385+
const spec = {
386+
"openapi": "3.0.0",
387+
"paths": {
388+
"/foo": {
389+
"get": {
390+
"responses": {
391+
"200": {
392+
"description": "Success",
393+
"schema": {
394+
"$ref": "#/components/schemas/foo bar"
395+
}
396+
}
397+
}
398+
}
399+
}
400+
},
401+
components: {
402+
schemas: {
403+
"foo bar": {
404+
"type": "string"
405+
}
406+
}
407+
}
408+
}
409+
410+
return validateHelper(spec)
411+
.then(system => {
412+
const allSemanticErrors = system.errSelectors.allErrors().toJS()
413+
.filter(err => err.source !== "resolver")
414+
expect(allSemanticErrors[0]).toInclude({
415+
level: "warning",
416+
message: "Definition was declared but never used in document",
417+
path: ["components", "schemas", "foo bar"]
418+
})
419+
expect(allSemanticErrors.length).toEqual(2)
420+
expect(allSemanticErrors[1]).toInclude({
421+
level: "error",
422+
message: "$ref values must be RFC3986-compliant percent-encoded URIs",
423+
path: ["paths", "/foo", "get", "responses", "200", "schema", "$ref"]
424+
})
425+
})
426+
})
427+
it("should return no errors when a JSON pointer uses correct percent-encoding in Swagger 2", () => {
428+
const spec = {
429+
"swagger": "2.0",
430+
"paths": {
431+
"/foo": {
432+
"get": {
433+
"responses": {
434+
"200": {
435+
"description": "Success",
436+
"schema": {
437+
"$ref": "#/definitions/foo%20bar"
438+
}
439+
}
440+
}
441+
}
442+
}
443+
},
444+
"definitions": {
445+
"foo bar": {
446+
"type": "string"
447+
}
448+
}
449+
}
450+
451+
return validateHelper(spec)
452+
.then(system => {
453+
const allSemanticErrors = system.errSelectors.allErrors().toJS()
454+
.filter(err => err.source !== "resolver")
455+
expect(allSemanticErrors).toEqual([])
456+
})
457+
})
458+
it("should return no errors when a JSON pointer uses correct percent-encoding in OpenAPI 3", () => {
459+
const spec = {
460+
"openapi": "3.0.0",
461+
"paths": {
462+
"/foo": {
463+
"get": {
464+
"responses": {
465+
"200": {
466+
"description": "Success",
467+
"schema": {
468+
"$ref": "#/components/schemas/foo%20bar"
469+
}
470+
}
471+
}
472+
}
473+
}
474+
},
475+
components: {
476+
schemas: {
477+
"foo bar": {
478+
"type": "string"
479+
}
480+
}
481+
}
482+
}
483+
484+
return validateHelper(spec)
485+
.then(system => {
486+
const allSemanticErrors = system.errSelectors.allErrors().toJS()
487+
.filter(err => err.source !== "resolver")
488+
expect(allSemanticErrors).toEqual([])
489+
})
490+
})
343491

344492
})
345493
describe("Nonexistent $ref pointers", () => {

0 commit comments

Comments
 (0)