Skip to content

Commit e5f6dc1

Browse files
authored
fixes #1174 TextNodes as schema seem to validate any value (#1250)
* fixes #1174 TextNodes as schema seem to validate any value * Address loaded schema validation for issue 1174 * Address schema node validation review comments * Preserve mapped schema override path * Validate referenced document fragment schemas * Remove duplicate schema type validation * Validate loaded schemas for anchor refs
1 parent 2276a17 commit e5f6dc1

8 files changed

Lines changed: 250 additions & 47 deletions

File tree

src/main/java/com/networknt/schema/SchemaRegistry.java

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -469,9 +469,10 @@ public SchemaLoader getSchemaLoader() {
469469
* @return the schema
470470
*/
471471
protected Schema newSchema(SchemaLocation schemaUri, JsonNode schemaNode) {
472+
SchemaLocation schemaLocation = getSchemaLocation(schemaUri);
473+
validateSchemaNodeNotNull(schemaLocation, schemaNode);
472474
final SchemaContext schemaContext = createSchemaContext(schemaNode);
473-
Schema jsonSchema = doCreate(schemaContext, getSchemaLocation(schemaUri),
474-
schemaNode, null, false);
475+
Schema jsonSchema = doCreate(schemaContext, schemaLocation, schemaNode, null, false);
475476
preload(jsonSchema);
476477
return jsonSchema;
477478
}
@@ -507,10 +508,46 @@ public Schema create(SchemaContext schemaContext, SchemaLocation schemaLocation,
507508

508509
private Schema doCreate(SchemaContext schemaContext, SchemaLocation schemaLocation,
509510
JsonNode schemaNode, Schema parentSchema, boolean suppressSubSchemaRetrieval) {
510-
return Schema.from(withDialect(schemaContext, schemaNode), schemaLocation, schemaNode,
511+
return doCreate(schemaContext, schemaLocation, schemaNode, parentSchema, suppressSubSchemaRetrieval, true);
512+
}
513+
514+
private Schema doCreate(SchemaContext schemaContext, SchemaLocation schemaLocation,
515+
JsonNode schemaNode, Schema parentSchema, boolean suppressSubSchemaRetrieval,
516+
boolean validateSchemaNodeType) {
517+
validateSchemaNodeNotNull(schemaLocation, schemaNode);
518+
SchemaContext schemaContextToUse = withDialect(schemaContext, schemaNode);
519+
if (validateSchemaNodeType) {
520+
validateSchemaNodeType(schemaLocation, schemaNode, schemaContextToUse);
521+
}
522+
return Schema.from(schemaContextToUse, schemaLocation, schemaNode,
511523
parentSchema, suppressSubSchemaRetrieval);
512524
}
513525

526+
private void validateSchemaNodeNotNull(SchemaLocation schemaLocation, JsonNode schemaNode) {
527+
if (schemaNode == null) {
528+
throw new SchemaException("Schema at " + schemaLocation + " must not be null");
529+
}
530+
}
531+
532+
private void validateSchemaNodeType(SchemaLocation schemaLocation, JsonNode schemaNode,
533+
SchemaContext schemaContext) {
534+
if (schemaNode.isObject()) {
535+
return;
536+
}
537+
if (schemaNode.isBoolean() && supportsBooleanSchema(schemaContext)) {
538+
return;
539+
}
540+
String expected = supportsBooleanSchema(schemaContext) ? "object or boolean" : "object";
541+
throw new SchemaException("Schema at " + schemaLocation + " must be " + expected + " but was "
542+
+ schemaNode.getNodeType());
543+
}
544+
545+
private boolean supportsBooleanSchema(SchemaContext schemaContext) {
546+
return schemaContext != null
547+
&& schemaContext.getDialect().getSpecificationVersion().getOrder() >= SpecificationVersion.DRAFT_6
548+
.getOrder();
549+
}
550+
514551
/**
515552
* Determines the schema context to use for the schema given the parent schema
516553
* context.
@@ -657,6 +694,7 @@ public Schema getSchema(final InputStream schemaStream, InputFormat inputFormat)
657694
*/
658695
public Schema getSchema(final SchemaLocation schemaUri) {
659696
Schema schema = loadSchema(schemaUri);
697+
validateSchemaNodeType(schema.getSchemaLocation(), schema.getSchemaNode(), schema.getSchemaContext());
660698
preload(schema);
661699
return schema;
662700
}
@@ -722,6 +760,20 @@ public Schema getSchema(final JsonNode jsonNode) {
722760
* @return the schema
723761
*/
724762
public Schema loadSchema(final SchemaLocation schemaUri) {
763+
return loadSchema(schemaUri, true);
764+
}
765+
766+
/**
767+
* Loads the schema.
768+
*
769+
* @param schemaUri the absolute IRI of the schema which can map to the
770+
* retrieval IRI.
771+
* @param validateLoadedSchema true to validate that the loaded node is a schema;
772+
* false when loading a document container to resolve a
773+
* fragment before using it as a schema
774+
* @return the schema
775+
*/
776+
public Schema loadSchema(final SchemaLocation schemaUri, boolean validateLoadedSchema) {
725777
if (schemaCacheEnabled) {
726778
// ConcurrentHashMap computeIfAbsent does not allow calls that result in a
727779
// recursive update to the map.
@@ -731,19 +783,28 @@ public Schema loadSchema(final SchemaLocation schemaUri) {
731783
synchronized (this) { // acquire lock on shared registry object to prevent deadlock
732784
cachedUriSchema = schemaCache.get(schemaUri);
733785
if (cachedUriSchema == null) {
734-
cachedUriSchema = getMappedSchema(schemaUri);
786+
cachedUriSchema = validateLoadedSchema ? getMappedSchema(schemaUri)
787+
: getMappedSchema(schemaUri, false);
735788
if (cachedUriSchema != null) {
736789
schemaCache.put(schemaUri, cachedUriSchema);
737790
}
738791
}
739792
}
740793
}
794+
if (validateLoadedSchema && cachedUriSchema != null) {
795+
validateSchemaNodeType(cachedUriSchema.getSchemaLocation(), cachedUriSchema.getSchemaNode(),
796+
cachedUriSchema.getSchemaContext());
797+
}
741798
return cachedUriSchema;
742799
}
743-
return getMappedSchema(schemaUri);
800+
return validateLoadedSchema ? getMappedSchema(schemaUri) : getMappedSchema(schemaUri, false);
744801
}
745802

746803
protected Schema getMappedSchema(final SchemaLocation schemaUri) {
804+
return getMappedSchema(schemaUri, true);
805+
}
806+
807+
protected Schema getMappedSchema(final SchemaLocation schemaUri, boolean validateLoadedSchema) {
747808
InputStreamSource inputStreamSource = this.schemaLoader.getSchemaResource(schemaUri.getAbsoluteIri());
748809
if (inputStreamSource != null) {
749810
try (InputStream inputStream = inputStreamSource.getInputStream()) {
@@ -762,13 +823,13 @@ protected Schema getMappedSchema(final SchemaLocation schemaUri) {
762823
// Schema without fragment
763824
SchemaContext schemaContext = new SchemaContext(dialect, this);
764825
return doCreate(schemaContext, schemaUri, schemaNode, null,
765-
true /* retrieved via id, resolving will not change anything */);
826+
true /* retrieved via id, resolving will not change anything */, validateLoadedSchema);
766827
} else {
767828
// Schema with fragment pointing to sub schema
768829
final SchemaContext schemaContext = createSchemaContext(schemaNode);
769830
SchemaLocation documentLocation = new SchemaLocation(schemaUri.getAbsoluteIri());
770831
Schema document = doCreate(schemaContext, documentLocation, schemaNode, null,
771-
false);
832+
false, false);
772833
return document.getRefSchema(schemaUri.getFragment());
773834
}
774835
} catch (IOException e) {

src/main/java/com/networknt/schema/keyword/RefValidator.java

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -60,28 +60,30 @@ static SchemaRef getRefSchema(Schema parentSchema, SchemaContext schemaContext,
6060
refUri = refValue;
6161
}
6262

63-
// This will determine the correct absolute uri for the refUri. This decision will take into
64-
// account the current uri of the parent schema.
65-
String schemaUriFinal = resolve(parentSchema, refUri);
66-
SchemaLocation schemaLocation = SchemaLocation.of(schemaUriFinal);
67-
// This should retrieve schemas regardless of the protocol that is in the uri.
68-
return new SchemaRef(getSupplier(() -> {
69-
Schema schemaResource = schemaContext.getSchemaResources().get(schemaUriFinal);
70-
if (schemaResource == null) {
71-
schemaResource = schemaContext.getSchemaRegistry().loadSchema(schemaLocation);
72-
if (schemaResource != null) {
73-
copySchemaResources(schemaContext, schemaResource);
74-
}
75-
}
63+
// This will determine the correct absolute uri for the refUri. This decision will take into
64+
// account the current uri of the parent schema.
65+
String schemaUriFinal = resolve(parentSchema, refUri);
66+
SchemaLocation schemaLocation = SchemaLocation.of(schemaUriFinal);
67+
String fragment = index < 0 ? null : refValue.substring(index);
68+
boolean validateLoadedSchema = fragment == null || !SchemaLocation.Fragment.isJsonPointerFragment(fragment);
69+
// This should retrieve schemas regardless of the protocol that is in the uri.
70+
return new SchemaRef(getSupplier(() -> {
71+
Schema schemaResource = schemaContext.getSchemaResources().get(schemaUriFinal);
72+
if (schemaResource == null) {
73+
schemaResource = schemaContext.getSchemaRegistry().loadSchema(schemaLocation, validateLoadedSchema);
74+
if (schemaResource != null) {
75+
copySchemaResources(schemaContext, schemaResource);
76+
}
77+
}
7678
if (index < 0) {
7779
if (schemaResource == null) {
7880
return null;
79-
}
80-
return schemaResource;
81-
} else {
82-
String newRefValue = refValue.substring(index);
83-
String find = schemaLocation.getAbsoluteIri() + newRefValue;
84-
Schema findSchemaResource = schemaContext.getSchemaResources().get(find);
81+
}
82+
return schemaResource;
83+
} else {
84+
String newRefValue = fragment;
85+
String find = schemaLocation.getAbsoluteIri() + newRefValue;
86+
Schema findSchemaResource = schemaContext.getSchemaResources().get(find);
8587
if (findSchemaResource == null) {
8688
findSchemaResource = schemaContext.getDynamicAnchors().get(find);
8789
}

src/test/java/com/networknt/schema/DependentRequiredTest.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ class DependentRequiredTest {
1919
" \"$schema\":\"https://json-schema.org/draft/2019-09/schema\"," +
2020
" \"type\": \"object\"," +
2121
" \"properties\": {" +
22-
" \"optional\": \"string\"," +
23-
" \"requiredWhenOptionalPresent\": \"string\"" +
22+
" \"optional\": { \"type\": \"string\" }," +
23+
" \"requiredWhenOptionalPresent\": { \"type\": \"string\" }" +
2424
" }," +
2525
" \"dependentRequired\": {" +
2626
" \"optional\": [ \"requiredWhenOptionalPresent\" ]," +
@@ -63,4 +63,4 @@ private static List<Error> whenValidate(String content) throws JacksonException
6363
return schema.validate(mapper.readTree(content));
6464
}
6565

66-
}
66+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
* Copyright (c) 2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.networknt.schema;
17+
18+
import static org.junit.jupiter.api.Assertions.assertEquals;
19+
import static org.junit.jupiter.api.Assertions.assertThrows;
20+
import static org.junit.jupiter.api.Assertions.assertTrue;
21+
22+
import java.util.Collections;
23+
import java.util.List;
24+
25+
import org.junit.jupiter.api.Test;
26+
27+
import tools.jackson.databind.node.JsonNodeFactory;
28+
29+
class Issue1174Test {
30+
@Test
31+
void textNodeShouldNotBeAcceptedAsRootSchema() {
32+
SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7);
33+
34+
SchemaException exception = assertThrows(SchemaException.class,
35+
() -> registry.getSchema(JsonNodeFactory.instance.textNode("false")));
36+
37+
assertTrue(exception.getMessage().contains("must be object or boolean"));
38+
assertTrue(exception.getMessage().contains("STRING"));
39+
}
40+
41+
@Test
42+
void textNodeContainingJsonShouldNotBeAcceptedAsRootSchema() {
43+
SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7);
44+
45+
SchemaException exception = assertThrows(SchemaException.class,
46+
() -> registry.getSchema(JsonNodeFactory.instance.textNode("{\"type\":\"string\"}")));
47+
48+
assertTrue(exception.getMessage().contains("must be object or boolean"));
49+
assertTrue(exception.getMessage().contains("STRING"));
50+
}
51+
52+
@Test
53+
void textNodeShouldNotBeAcceptedAsLoadedRootSchema() {
54+
SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7,
55+
builder -> builder.schemas(Collections.singletonMap("https://www.example.org/text-schema", "\"false\"")));
56+
57+
SchemaException exception = assertThrows(SchemaException.class,
58+
() -> registry.getSchema(SchemaLocation.of("https://www.example.org/text-schema")));
59+
60+
assertTrue(exception.getMessage().contains("must be object or boolean"));
61+
assertTrue(exception.getMessage().contains("STRING"));
62+
}
63+
64+
@Test
65+
void textNodeShouldNotBeAcceptedAsReferencedRootSchema() {
66+
SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7,
67+
builder -> builder.schemas(Collections.singletonMap("https://www.example.org/text-schema", "\"false\"")));
68+
Schema schema = registry.getSchema("{\"$ref\":\"https://www.example.org/text-schema\"}");
69+
70+
SchemaException exception = assertThrows(SchemaException.class, () -> schema.validate("42", InputFormat.JSON));
71+
72+
assertTrue(exception.getMessage().contains("must be object or boolean"));
73+
assertTrue(exception.getMessage().contains("STRING"));
74+
}
75+
76+
@Test
77+
void textNodeShouldNotBeAcceptedAsReferencedDocumentFragmentSchema() {
78+
SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7,
79+
builder -> builder.schemas(Collections.singletonMap("https://www.example.org/text-schema", "\"false\"")));
80+
Schema schema = registry.getSchema("{\"$ref\":\"https://www.example.org/text-schema#\"}");
81+
82+
SchemaException exception = assertThrows(SchemaException.class, () -> schema.validate("42", InputFormat.JSON));
83+
84+
assertTrue(exception.getMessage().contains("must be object or boolean"));
85+
assertTrue(exception.getMessage().contains("STRING"));
86+
}
87+
88+
@Test
89+
void textNodeShouldNotBeAcceptedAsReferencedAnchorSchema() {
90+
SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7,
91+
builder -> builder.schemas(Collections.singletonMap("https://www.example.org/text-schema", "\"false\"")));
92+
Schema schema = registry.getSchema("{\"$ref\":\"https://www.example.org/text-schema#myAnchor\"}");
93+
94+
SchemaException exception = assertThrows(SchemaException.class, () -> schema.validate("42", InputFormat.JSON));
95+
96+
assertTrue(exception.getMessage().contains("must be object or boolean"));
97+
assertTrue(exception.getMessage().contains("STRING"));
98+
}
99+
100+
@Test
101+
void textNodeShouldNotBeAcceptedAsSubSchema() {
102+
SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7);
103+
104+
SchemaException exception = assertThrows(SchemaException.class,
105+
() -> registry.getSchema("{\"type\":\"object\",\"properties\":{\"a\":\"false\"}}"));
106+
107+
assertTrue(exception.getMessage().contains("must be object or boolean"));
108+
assertTrue(exception.getMessage().contains("STRING"));
109+
}
110+
111+
@Test
112+
void booleanSchemaShouldBeAcceptedForDraft7() {
113+
SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7);
114+
Schema schema = registry.getSchema("false");
115+
116+
List<Error> errors = schema.validate("42", InputFormat.JSON);
117+
118+
assertEquals(1, errors.size());
119+
}
120+
121+
@Test
122+
void loadedBooleanSchemaShouldBeAcceptedForDraft7() {
123+
SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7,
124+
builder -> builder.schemas(Collections.singletonMap("https://www.example.org/false-schema", "false")));
125+
Schema schema = registry.getSchema(SchemaLocation.of("https://www.example.org/false-schema"));
126+
127+
List<Error> errors = schema.validate("42", InputFormat.JSON);
128+
129+
assertEquals(1, errors.size());
130+
}
131+
132+
@Test
133+
void booleanSchemaShouldNotBeAcceptedForDraft4() {
134+
SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_4);
135+
136+
SchemaException exception = assertThrows(SchemaException.class, () -> registry.getSchema("false"));
137+
138+
assertTrue(exception.getMessage().contains("must be object"));
139+
assertTrue(exception.getMessage().contains("BOOLEAN"));
140+
}
141+
}

src/test/java/com/networknt/schema/SchemaTest.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,12 @@ void concurrency() throws Exception {
4444
+ " \"name\": {\r\n"
4545
+ " \"type\": \"string\",\r\n"
4646
+ " \"description\": \"The name\"\r\n"
47-
+ " },\r\n"
48-
+ " \"required\": [\r\n"
49-
+ " \"name\"\r\n"
50-
+ " ]\r\n"
51-
+ " }\r\n"
52-
+ "}";
47+
+ " }\r\n"
48+
+ " },\r\n"
49+
+ " \"required\": [\r\n"
50+
+ " \"name\"\r\n"
51+
+ " ]\r\n"
52+
+ "}";
5353
String inputData = "{\r\n"
5454
+ " \"name\": 1\r\n"
5555
+ "}";

src/test/java/com/networknt/schema/UnknownMetaSchemaTest.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212

1313
class UnknownMetaSchemaTest {
1414

15-
private final String schema1 = "{\"$schema\":\"http://json-schema.org/draft-07/schema\",\"title\":\"thingModel\",\"description\":\"description of thing\",\"type\":\"object\",\"properties\":{\"data\":{\"type\":\"integer\"},\"required\":[\"data\"]}}";
16-
private final String schema2 = "{\"$schema\":\"https://json-schema.org/draft-07/schema\",\"title\":\"thingModel\",\"description\":\"description of thing\",\"type\":\"object\",\"properties\":{\"data\":{\"type\":\"integer\"},\"required\":[\"data\"]}}";
17-
private final String schema3 = "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"title\":\"thingModel\",\"description\":\"description of thing\",\"type\":\"object\",\"properties\":{\"data\":{\"type\":\"integer\"},\"required\":[\"data\"]}}";
15+
private final String schema1 = "{\"$schema\":\"http://json-schema.org/draft-07/schema\",\"title\":\"thingModel\",\"description\":\"description of thing\",\"type\":\"object\",\"properties\":{\"data\":{\"type\":\"integer\"}},\"required\":[\"data\"]}";
16+
private final String schema2 = "{\"$schema\":\"https://json-schema.org/draft-07/schema\",\"title\":\"thingModel\",\"description\":\"description of thing\",\"type\":\"object\",\"properties\":{\"data\":{\"type\":\"integer\"}},\"required\":[\"data\"]}";
17+
private final String schema3 = "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"title\":\"thingModel\",\"description\":\"description of thing\",\"type\":\"object\",\"properties\":{\"data\":{\"type\":\"integer\"}},\"required\":[\"data\"]}";
1818

1919
private final String json = "{\"data\":1}";
2020

0 commit comments

Comments
 (0)