Skip to content

Commit 78205ba

Browse files
committed
fix: Reject string-to-number coercion in type validation
JSON Schema 'type' refers to the JSON value's intrinsic kind, not whether its lexical form is parseable to another type. DefaultValidator was parsing numeric-looking strings as integers/numbers, which broke oneOf branches mixing string and number (both matched) and silently accepted strings for plain {"type": "number"} fields. Validator is now strict; parameter values (always strings on the wire) are coerced to the schema's primitive type in RequestPreparationFilter before validation runs.
1 parent 547e5fe commit 78205ba

4 files changed

Lines changed: 127 additions & 31 deletions

File tree

src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
import com.retailsvc.http.spec.Parameter;
1212
import com.retailsvc.http.spec.RequestBody;
1313
import com.retailsvc.http.spec.Spec;
14+
import com.retailsvc.http.spec.schema.BooleanSchema;
15+
import com.retailsvc.http.spec.schema.IntegerSchema;
16+
import com.retailsvc.http.spec.schema.NumberSchema;
17+
import com.retailsvc.http.spec.schema.Schema;
1418
import com.retailsvc.http.validate.ValidationError;
1519
import com.retailsvc.http.validate.Validator;
1620
import com.sun.net.httpserver.Filter;
@@ -124,10 +128,48 @@ private void validateParameters(
124128
}
125129
continue;
126130
}
127-
validator.validate(value, p.schema(), pointer);
131+
validator.validate(coerceParameterValue(value, p.schema(), pointer), p.schema(), pointer);
128132
}
129133
}
130134

135+
/**
136+
* Converts a raw parameter string into a typed value matching the schema's primitive kind, so the
137+
* validator (which is faithful to JSON Schema {@code type} semantics) sees the value the spec
138+
* describes rather than its string serialization. Strings that fail to parse are passed through
139+
* unchanged so the validator surfaces a {@code type} error with the original input.
140+
*/
141+
private static Object coerceParameterValue(String raw, Schema schema, String pointer) {
142+
return switch (schema) {
143+
case IntegerSchema _ -> {
144+
try {
145+
yield Long.parseLong(raw);
146+
} catch (NumberFormatException _) {
147+
throw new ValidationException(
148+
new ValidationError(pointer, "type", "expected integer", raw));
149+
}
150+
}
151+
case NumberSchema _ -> {
152+
try {
153+
yield Double.parseDouble(raw);
154+
} catch (NumberFormatException _) {
155+
throw new ValidationException(
156+
new ValidationError(pointer, "type", "expected number", raw));
157+
}
158+
}
159+
case BooleanSchema _ -> {
160+
if ("true".equals(raw)) {
161+
yield Boolean.TRUE;
162+
}
163+
if ("false".equals(raw)) {
164+
yield Boolean.FALSE;
165+
}
166+
throw new ValidationException(
167+
new ValidationError(pointer, "type", "expected boolean", raw));
168+
}
169+
default -> raw;
170+
};
171+
}
172+
131173
private Object validateAndParseBody(HttpExchange exchange, Operation op, byte[] body) {
132174
Optional<RequestBody> rb = op.requestBody();
133175
if (rb.isEmpty()) {

src/main/java/com/retailsvc/http/validate/DefaultValidator.java

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -347,22 +347,11 @@ private static boolean isHextet(String hextet) {
347347
}
348348

349349
private void validateInteger(Object value, IntegerSchema s, String pointer) {
350-
long n;
351-
switch (value) {
352-
case Number num -> n = num.longValue();
353-
case String str -> {
354-
try {
355-
n = Long.parseLong(str);
356-
} catch (NumberFormatException _) {
357-
fail(pointer, "type", "expected integer", value);
358-
return;
359-
}
360-
}
361-
case null, default -> {
362-
fail(pointer, "type", "expected integer", value);
363-
return;
364-
}
350+
if (!(value instanceof Number num)) {
351+
fail(pointer, "type", "expected integer", value);
352+
return;
365353
}
354+
long n = num.longValue();
366355

367356
if (s.minimum() != null && n < s.minimum()) {
368357
fail(pointer, "minimum", "integer below minimum " + s.minimum(), n);
@@ -385,22 +374,11 @@ private void validateInteger(Object value, IntegerSchema s, String pointer) {
385374
}
386375

387376
private void validateNumber(Object value, NumberSchema s, String pointer) {
388-
double n;
389-
switch (value) {
390-
case Number num -> n = num.doubleValue();
391-
case String str -> {
392-
try {
393-
n = Double.parseDouble(str);
394-
} catch (NumberFormatException _) {
395-
fail(pointer, "type", "expected number", value);
396-
return;
397-
}
398-
}
399-
case null, default -> {
400-
fail(pointer, "type", "expected number", value);
401-
return;
402-
}
377+
if (!(value instanceof Number num)) {
378+
fail(pointer, "type", "expected number", value);
379+
return;
403380
}
381+
double n = num.doubleValue();
404382

405383
if (s.minimum() != null && n < s.minimum().doubleValue()) {
406384
fail(pointer, "minimum", "number below minimum " + s.minimum(), n);

src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import com.retailsvc.http.spec.PathTemplate;
1616
import com.retailsvc.http.spec.Server;
1717
import com.retailsvc.http.spec.Spec;
18+
import com.retailsvc.http.spec.schema.IntegerSchema;
19+
import com.retailsvc.http.spec.schema.NumberSchema;
1820
import com.retailsvc.http.spec.schema.StringSchema;
1921
import com.retailsvc.http.spec.schema.TypeName;
2022
import com.retailsvc.http.validate.DefaultValidator;
@@ -151,4 +153,61 @@ void invalidQueryParamThrowsValidation() {
151153
.extracting(t -> ((ValidationException) t).error().pointer())
152154
.isEqualTo("/query/q");
153155
}
156+
157+
@Test
158+
void integerQueryParamIsCoercedFromStringBeforeValidation() throws Exception {
159+
var intSchema = new IntegerSchema(Set.of(TypeName.INTEGER), 1L, 100L, null, null, null, null);
160+
var op =
161+
new Operation(
162+
"a",
163+
HttpMethod.GET,
164+
PathTemplate.compile("/x"),
165+
Optional.empty(),
166+
List.of(new Parameter("n", Parameter.Location.QUERY, true, intSchema)),
167+
Map.of());
168+
Spec spec = specWith(op);
169+
Filter f = newFilter(spec);
170+
171+
HttpExchange ex = exchange("GET", "/x?n=42", new byte[0]);
172+
f.doFilter(ex, Mockito.mock(Filter.Chain.class));
173+
}
174+
175+
@Test
176+
void integerQueryParamRejectsNonNumericString() {
177+
var intSchema = new IntegerSchema(Set.of(TypeName.INTEGER), null, null, null, null, null, null);
178+
var op =
179+
new Operation(
180+
"a",
181+
HttpMethod.GET,
182+
PathTemplate.compile("/x"),
183+
Optional.empty(),
184+
List.of(new Parameter("n", Parameter.Location.QUERY, true, intSchema)),
185+
Map.of());
186+
Spec spec = specWith(op);
187+
Filter f = newFilter(spec);
188+
189+
HttpExchange ex = exchange("GET", "/x?n=abc", new byte[0]);
190+
assertThatThrownBy(() -> f.doFilter(ex, Mockito.mock(Filter.Chain.class)))
191+
.isInstanceOf(ValidationException.class)
192+
.extracting(t -> ((ValidationException) t).error().keyword())
193+
.isEqualTo("type");
194+
}
195+
196+
@Test
197+
void numberQueryParamIsCoercedFromStringBeforeValidation() throws Exception {
198+
var numSchema = new NumberSchema(Set.of(TypeName.NUMBER), null, null, null, null, null, null);
199+
var op =
200+
new Operation(
201+
"a",
202+
HttpMethod.GET,
203+
PathTemplate.compile("/x"),
204+
Optional.empty(),
205+
List.of(new Parameter("n", Parameter.Location.QUERY, true, numSchema)),
206+
Map.of());
207+
Spec spec = specWith(op);
208+
Filter f = newFilter(spec);
209+
210+
HttpExchange ex = exchange("GET", "/x?n=1.5", new byte[0]);
211+
f.doFilter(ex, Mockito.mock(Filter.Chain.class));
212+
}
154213
}

src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,4 +312,21 @@ void numberFormatUnknownIsIgnored() {
312312
Set.of(TypeName.NUMBER), null, null, null, null, null, "definitely-not-a-format");
313313
assertThatCode(() -> v.validate(1.5, s, "/v")).doesNotThrowAnyException();
314314
}
315+
316+
@Test
317+
void integerRejectsNumericLookingString() {
318+
IntegerSchema s =
319+
new IntegerSchema(Set.of(TypeName.INTEGER), null, null, null, null, null, null);
320+
assertThatThrownBy(() -> v.validate("42", s, "/v"))
321+
.extracting(t -> ((ValidationException) t).error().keyword())
322+
.isEqualTo("type");
323+
}
324+
325+
@Test
326+
void numberRejectsNumericLookingString() {
327+
NumberSchema s = new NumberSchema(Set.of(TypeName.NUMBER), null, null, null, null, null, null);
328+
assertThatThrownBy(() -> v.validate("1234567890", s, "/v"))
329+
.extracting(t -> ((ValidationException) t).error().keyword())
330+
.isEqualTo("type");
331+
}
315332
}

0 commit comments

Comments
 (0)