Skip to content

Commit e9cfa18

Browse files
authored
fix: Close integer validation bypass and query parser differential (#109)
* fix: Close integer validation bypass and query parser differential Two validation bypasses surfaced in a security review of the request pipeline: - checkInteger narrowed numbers with longValue() before bound-checking, so a fractional Double (4.9) satisfied `type: integer` and a value above 2^64 wrapped past a declared maximum. It now rejects non-integral numbers and compares the full magnitude, keeping an allocation-free long path for the common case and falling back to BigDecimal/BigInteger otherwise. - Query parameters were validated from the already-decoded URI.getQuery() but delivered to handlers from getRawQuery(), so an encoded separator (%26) could smuggle a value past a schema constraint. Both paths now share QueryParams.parse over the raw query, so the value validated is the value the handler receives. Adds exploit tests asserting both are rejected. * refactor: Simplify out-of-range integer check and cover new branches The SonarCloud new-code coverage gate failed on PR #109: extracting the bound checks into checkIntegerBounds turned the exclusive-bound and beyond-long paths into "new" lines that no test exercised. - Simplify the beyond-long branch to delegate to the long path when the value fits, and otherwise reject against whichever bound lies on its side (or an int32/int64 format), collapsing the per-bound arms. - Add tests for exclusive bounds, values above 2^53 that still fit in a long, every beyond-long rejection path, and QueryParams edge cases (empty pairs, keys without '=', encoded separators). * fix: Drop float-equality check that failed the reliability gate SonarCloud flagged S1244 (floating-point equality) on the `d == Math.rint(d)` integrality test in checkInteger's fast double path, dropping New Code reliability to C. Remove the double fast path entirely: doubles reaching an integer schema are either fractional (rejected) or rare integral doubles, both handled correctly by converting through BigDecimal.toBigIntegerExact(). Integral wrappers keep the allocation-free long path, so the common case is unaffected.
1 parent 23117db commit e9cfa18

7 files changed

Lines changed: 287 additions & 45 deletions

File tree

src/main/java/com/retailsvc/http/Request.java

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
package com.retailsvc.http;
22

3+
import com.retailsvc.http.internal.QueryParams;
34
import com.retailsvc.http.spec.HttpMethod;
4-
import java.net.URLDecoder;
5-
import java.nio.charset.StandardCharsets;
65
import java.util.ArrayList;
76
import java.util.Collections;
8-
import java.util.LinkedHashMap;
97
import java.util.List;
108
import java.util.Map;
119
import java.util.Objects;
@@ -265,7 +263,7 @@ public String rawQuery() {
265263
*/
266264
public Map<String, String> queryParams() {
267265
if (queryParamCache == null) {
268-
queryParamCache = parseQuery(rawQuery);
266+
queryParamCache = QueryParams.parse(rawQuery);
269267
}
270268
return queryParamCache;
271269
}
@@ -345,23 +343,4 @@ public void afterResponse(Runnable runnable) {
345343
public List<Runnable> afterHooks() {
346344
return Collections.unmodifiableList(afterHooks);
347345
}
348-
349-
private static Map<String, String> parseQuery(String query) {
350-
if (query == null || query.isBlank()) {
351-
return Map.of();
352-
}
353-
Map<String, String> out = new LinkedHashMap<>();
354-
for (String pair : query.split("&")) {
355-
if (pair.isEmpty()) {
356-
continue;
357-
}
358-
int eq = pair.indexOf('=');
359-
String rawKey = eq < 0 ? pair : pair.substring(0, eq);
360-
String rawValue = eq < 0 ? "" : pair.substring(eq + 1);
361-
out.putIfAbsent(
362-
URLDecoder.decode(rawKey, StandardCharsets.UTF_8),
363-
URLDecoder.decode(rawValue, StandardCharsets.UTF_8));
364-
}
365-
return out;
366-
}
367346
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.retailsvc.http.internal;
2+
3+
import java.net.URLDecoder;
4+
import java.nio.charset.StandardCharsets;
5+
import java.util.LinkedHashMap;
6+
import java.util.Map;
7+
8+
/**
9+
* Parses a raw (percent-encoded) URI query string into decoded parameters. Splitting on {@code &}
10+
* and {@code =} happens <em>before</em> per-component decoding, so a percent-encoded separator
11+
* ({@code %26}, {@code %3D}) stays inside a value instead of becoming a delimiter. Request
12+
* validation and handler consumption both parse through here, so the value validated is exactly the
13+
* value the handler receives.
14+
*/
15+
public final class QueryParams {
16+
17+
private QueryParams() {}
18+
19+
/**
20+
* Parses {@code rawQuery} (the undecoded query component, e.g. {@code URI.getRawQuery()}) into a
21+
* name→value map. Values are URL-decoded with UTF-8. For repeated names the first occurrence
22+
* wins.
23+
*/
24+
public static Map<String, String> parse(String rawQuery) {
25+
if (rawQuery == null || rawQuery.isBlank()) {
26+
return Map.of();
27+
}
28+
Map<String, String> out = new LinkedHashMap<>();
29+
for (String pair : rawQuery.split("&")) {
30+
if (pair.isEmpty()) {
31+
continue;
32+
}
33+
int eq = pair.indexOf('=');
34+
String rawKey = eq < 0 ? pair : pair.substring(0, eq);
35+
String rawValue = eq < 0 ? "" : pair.substring(eq + 1);
36+
out.putIfAbsent(
37+
URLDecoder.decode(rawKey, StandardCharsets.UTF_8),
38+
URLDecoder.decode(rawValue, StandardCharsets.UTF_8));
39+
}
40+
return out;
41+
}
42+
}

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

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
import com.sun.net.httpserver.Headers;
2121
import com.sun.net.httpserver.HttpExchange;
2222
import java.io.IOException;
23-
import java.util.HashMap;
2423
import java.util.LinkedHashMap;
2524
import java.util.List;
2625
import java.util.Locale;
@@ -189,7 +188,7 @@ private void validateParameters(
189188
for (Parameter p : op.parameters()) {
190189
String pointer = p.pointer();
191190
if (p.in() == Parameter.Location.QUERY && query == null) {
192-
query = parseQuery(exchange.getRequestURI().getQuery());
191+
query = QueryParams.parse(exchange.getRequestURI().getRawQuery());
193192
}
194193
String value =
195194
switch (p.in()) {
@@ -253,19 +252,4 @@ private ParsedBody validateAndParseBody(HttpExchange exchange, Operation op, byt
253252
validator.validate(parsed, mt.schema(), "");
254253
return new ParsedBody(parsed, mapper);
255254
}
256-
257-
private static Map<String, String> parseQuery(String query) {
258-
if (query == null || query.isBlank()) {
259-
return Map.of();
260-
}
261-
Map<String, String> out = new HashMap<>();
262-
for (String pair : query.split("&")) {
263-
int eq = pair.indexOf('=');
264-
if (eq <= 0) {
265-
continue;
266-
}
267-
out.putIfAbsent(pair.substring(0, eq), pair.substring(eq + 1));
268-
}
269-
return out;
270-
}
271255
}

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

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.retailsvc.http.spec.schema.StringSchema;
2222
import com.retailsvc.http.spec.schema.TypeName;
2323
import java.math.BigDecimal;
24+
import java.math.BigInteger;
2425
import java.net.URI;
2526
import java.net.URISyntaxException;
2627
import java.time.LocalDate;
@@ -47,6 +48,8 @@
4748
public final class DefaultValidator implements Validator {
4849

4950
private static final String FORMAT_KEYWORD = "format";
51+
private static final String MINIMUM_KEYWORD = "minimum";
52+
private static final String MAXIMUM_KEYWORD = "maximum";
5053
private static final Optional<ValidationError> OK = Optional.empty();
5154

5255
private record FormatCheck(Predicate<String> isValid, String message) {}
@@ -365,18 +368,39 @@ private static boolean isHextet(String hextet) {
365368
return true;
366369
}
367370

371+
/** Largest magnitude an IEEE-754 double represents as an exact integer (2^53). */
368372
private static Optional<ValidationError> checkInteger(
369373
Object value, IntegerSchema s, String pointer) {
370374
if (!(value instanceof Number num)) {
371375
return err(pointer, "type", "expected integer", value);
372376
}
373-
long n = num.longValue();
377+
// Fast path: integral wrappers bound-check as a long with no allocation. This is the common
378+
// case (e.g. Jackson int/long, Gson integral tokens parsed as Long).
379+
if (num instanceof Long
380+
|| num instanceof Integer
381+
|| num instanceof Short
382+
|| num instanceof Byte) {
383+
return checkIntegerBounds(num.longValue(), s, pointer);
384+
}
385+
// Any other numeric type (Double, BigDecimal, ...) must be a whole number: convert exactly and
386+
// reject a fractional part (e.g. 4.9) or a non-finite value, comparing the full magnitude so
387+
// nothing wraps past a bound.
388+
BigInteger n;
389+
try {
390+
n = new BigDecimal(num.toString()).toBigIntegerExact();
391+
} catch (ArithmeticException | NumberFormatException _) {
392+
return err(pointer, "type", "expected integer", value);
393+
}
394+
return checkIntegerBounds(n, s, pointer);
395+
}
374396

397+
private static Optional<ValidationError> checkIntegerBounds(
398+
long n, IntegerSchema s, String pointer) {
375399
if (s.minimum() != null && n < s.minimum()) {
376-
return err(pointer, "minimum", "integer below minimum " + s.minimum(), n);
400+
return err(pointer, MINIMUM_KEYWORD, "integer below minimum " + s.minimum(), n);
377401
}
378402
if (s.maximum() != null && n > s.maximum()) {
379-
return err(pointer, "maximum", "integer above maximum " + s.maximum(), n);
403+
return err(pointer, MAXIMUM_KEYWORD, "integer above maximum " + s.maximum(), n);
380404
}
381405
if (s.exclusiveMinimum() != null && n <= s.exclusiveMinimum()) {
382406
return err(
@@ -394,6 +418,25 @@ private static Optional<ValidationError> checkInteger(
394418
return OK;
395419
}
396420

421+
private static Optional<ValidationError> checkIntegerBounds(
422+
BigInteger n, IntegerSchema s, String pointer) {
423+
if (n.bitLength() < Long.SIZE) {
424+
return checkIntegerBounds(n.longValue(), s, pointer);
425+
}
426+
// Magnitude exceeds signed-long range: it breaches whichever bound lies on its side, and no
427+
// int32/int64 format can represent it.
428+
if (n.signum() > 0 && (s.maximum() != null || s.exclusiveMaximum() != null)) {
429+
return err(pointer, MAXIMUM_KEYWORD, "integer out of range", n);
430+
}
431+
if (n.signum() < 0 && (s.minimum() != null || s.exclusiveMinimum() != null)) {
432+
return err(pointer, MINIMUM_KEYWORD, "integer out of range", n);
433+
}
434+
if (s.format() != null && INTEGER_FORMAT_CHECKS.containsKey(s.format())) {
435+
return err(pointer, FORMAT_KEYWORD, INTEGER_FORMAT_CHECKS.get(s.format()).message(), n);
436+
}
437+
return OK;
438+
}
439+
397440
private static Optional<ValidationError> checkNumber(
398441
Object value, NumberSchema s, String pointer) {
399442
if (!(value instanceof Number num)) {
@@ -402,10 +445,10 @@ private static Optional<ValidationError> checkNumber(
402445
double n = num.doubleValue();
403446

404447
if (s.minimum() != null && n < s.minimum().doubleValue()) {
405-
return err(pointer, "minimum", "number below minimum " + s.minimum(), n);
448+
return err(pointer, MINIMUM_KEYWORD, "number below minimum " + s.minimum(), n);
406449
}
407450
if (s.maximum() != null && n > s.maximum().doubleValue()) {
408-
return err(pointer, "maximum", "number above maximum " + s.maximum(), n);
451+
return err(pointer, MAXIMUM_KEYWORD, "number above maximum " + s.maximum(), n);
409452
}
410453
if (s.exclusiveMinimum() != null && n <= s.exclusiveMinimum().doubleValue()) {
411454
return err(pointer, "exclusiveMinimum", "number not greater than " + s.exclusiveMinimum(), n);
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.retailsvc.http;
2+
3+
import static java.net.http.HttpRequest.BodyPublishers.noBody;
4+
import static org.assertj.core.api.Assertions.assertThat;
5+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
6+
7+
import com.retailsvc.http.spec.Spec;
8+
import java.io.InputStream;
9+
import java.net.http.HttpResponse;
10+
import java.net.http.HttpResponse.BodyHandlers;
11+
import java.nio.charset.StandardCharsets;
12+
import java.util.List;
13+
import java.util.Map;
14+
import org.junit.jupiter.api.Test;
15+
16+
class QueryParamSmugglingTest extends ServerBaseTest {
17+
18+
@Test
19+
void encodedAmpersandCannotSmuggleAValuePastQueryValidation() throws Exception {
20+
// Constrain q1 so any value containing '&' or '=' fails validation.
21+
constrainQ1ToLowercaseLetters();
22+
server = newServer(Map.of());
23+
24+
// %26 must stay inside q1: the handler would decode q1 to "abc&x=y", so validation must see the
25+
// same value and reject it against ^[a-z]+$ (400). The exploit was that validation parsed the
26+
// already-decoded query and saw q1="abc" (passing) while the handler received "abc&x=y".
27+
var request = newRequest(server, "/params/query?q1=abc%26x=y&q2=ok", "GET", noBody());
28+
HttpResponse<String> response = httpClient().send(request, BodyHandlers.ofString());
29+
30+
assertThat(response.statusCode()).isEqualTo(400);
31+
}
32+
33+
@SuppressWarnings("unchecked")
34+
private void constrainQ1ToLowercaseLetters() {
35+
spec =
36+
assertDoesNotThrow(
37+
() -> {
38+
try (InputStream in =
39+
QueryParamSmugglingTest.class.getResourceAsStream("/openapi.json")) {
40+
Map<String, Object> raw =
41+
(Map<String, Object>)
42+
gson.fromJson(
43+
new String(in.readAllBytes(), StandardCharsets.UTF_8), Map.class);
44+
var paths = (Map<String, Object>) raw.get("paths");
45+
var op =
46+
(Map<String, Object>)
47+
((Map<String, Object>) paths.get("/params/query")).get("get");
48+
for (Object p : (List<Object>) op.get("parameters")) {
49+
var param = (Map<String, Object>) p;
50+
if ("q1".equals(param.get("name"))) {
51+
((Map<String, Object>) param.get("schema")).put("pattern", "^[a-z]+$");
52+
}
53+
}
54+
return Spec.from(raw);
55+
}
56+
});
57+
}
58+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.retailsvc.http.internal;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.entry;
5+
6+
import org.junit.jupiter.api.Test;
7+
8+
class QueryParamsTest {
9+
10+
@Test
11+
void nullOrBlankQueryYieldsEmptyMap() {
12+
assertThat(QueryParams.parse(null)).isEmpty();
13+
assertThat(QueryParams.parse(" ")).isEmpty();
14+
}
15+
16+
@Test
17+
void encodedSeparatorStaysInsideValue() {
18+
// %26 is decoded after splitting, so it does not become a delimiter: one param, not two.
19+
assertThat(QueryParams.parse("q=a%26b=c")).containsExactly(entry("q", "a&b=c"));
20+
}
21+
22+
@Test
23+
void splitsPairsDecodesAndKeepsFirstOccurrence() {
24+
assertThat(QueryParams.parse("name=Alice%20Smith&name=Bob&city=x"))
25+
.containsExactly(entry("name", "Alice Smith"), entry("city", "x"));
26+
}
27+
28+
@Test
29+
void skipsEmptyPairsAndHandlesKeysWithoutValue() {
30+
// Empty pair (from "&&") is skipped; a key with no '=' maps to an empty value.
31+
assertThat(QueryParams.parse("a=1&&flag&b=2"))
32+
.containsExactly(entry("a", "1"), entry("flag", ""), entry("b", "2"));
33+
}
34+
}

0 commit comments

Comments
 (0)