Skip to content

Commit 4b385ee

Browse files
glasstigerclaude
andcommitted
Harden OIDC device-flow input validation
Address the minor hardening findings from the device-flow review. endpointPathHasEncodedSeparator() out-decoded the endpoint path with a byte-oriented percentDecodeOnce and scanned each level for %2f/%5c/%25, but missed encodings it does not resolve - an overlong-UTF-8 %c0%ae or an IIS-style %u002e that a permissive server decodes to '.'/'/'. Such a segment, sitting past the issuer prefix, slipped the path scope. A real OIDC endpoint path is plain ASCII, so reject any '%' or '\' in it outright; a provider that encodes its path must be configured explicitly with builder(), which pins the origin only. adopt() accepted an empty served token (hasOnlyTokenChars("") is vacuously true) and would serve it as a blank "Bearer " header that only draws a 401. Reject an empty served token too, so a corrupt or tampered entry falls through to a refresh or an interactive sign-in. Endpoint.parse() let Integer.parseInt read a ":+443" port as 443, slipping the range check, and accepted a backslash in the host that the WHATWG URL spec folds to '/'. Reject a leading '+' on the port and a backslash in the host. Correct the JsonLexer.unescape() default-case comment: a '\' before a recognized escape letter is still decoded, so the "Windows path is not dropped" note held only for genuinely unknown escapes. Add regression tests for each - overlong-UTF-8 / %u endpoint paths, an empty served token, and the +port / backslash-host cases; every one fails on the reverted production line and passes with the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6334bba commit 4b385ee

4 files changed

Lines changed: 79 additions & 32 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -653,32 +653,22 @@ private static OidcAuthException endpointOriginNotPinned(String label, String ur
653653
}
654654

655655
private static boolean endpointPathHasEncodedSeparator(String rawEndpointPath) {
656-
// Scan for a literal backslash (decodePathSegments folds it to '/') or a percent-encoded path
657-
// separator - %2f ('/'), %5c ('\'), or an encoded percent %25 that gates a split or double encoding
658-
// such as %2%66 or %252f - at every decode level, not just the raw string. A separator that only
659-
// emerges after the server unescapes more than once would pass a single-pass scan yet split one
660-
// segment in two, letting .../realms/acme%2%66evil/token slip the issuer-path scope. A real OIDC
661-
// endpoint path encodes none of these. Bounded like decodePathSegments; a real path needs 0-1 passes.
662-
String decoded = rawEndpointPath;
663-
for (int pass = 0; pass < 10; pass++) {
664-
for (int i = 0, n = decoded.length(); i < n; i++) {
665-
char c = decoded.charAt(i);
666-
if (c == '\\') {
667-
return true;
668-
}
669-
if (c == '%' && i + 2 < n) {
670-
char a = decoded.charAt(i + 1);
671-
char b = decoded.charAt(i + 2);
672-
if ((a == '2' && (b == 'f' || b == 'F' || b == '5')) || (a == '5' && (b == 'c' || b == 'C'))) {
673-
return true;
674-
}
675-
}
676-
}
677-
String next = percentDecodeOnce(decoded);
678-
if (next.equals(decoded)) {
679-
break;
656+
// A real OIDC endpoint path is plain ASCII with no percent-encoding and no backslash, so reject either
657+
// outright rather than try to out-decode the server. Percent-encoding is exactly where a tampered
658+
// /settings hides a path separator ('/', '\') or a '..' that only surfaces once the server unescapes -
659+
// and not only the forms this client's byte-oriented percentDecodeOnce resolves (%2f, %5c, %25, %252f,
660+
// %2%66), but ones it deliberately does NOT: an overlong-UTF-8 %c0%ae or %e0%80%ae, or an IIS-style
661+
// %u002e, which a permissive server decodes to '/' or '.' yet a single-byte decode leaves as high bytes
662+
// or literal text - so they would sail past the segment scan in isEndpointUnderIssuerPath and, sitting
663+
// past the issuer prefix, slip the scope. A literal backslash likewise folds to '/' on some proxies.
664+
// Failing closed on any '%' or '\' keeps the issuer-path scope airtight against every encoding trick; a
665+
// provider that genuinely percent-encodes its endpoint path must be configured explicitly with
666+
// OidcDeviceAuth.builder(), which pins the origin only.
667+
for (int i = 0, n = rawEndpointPath.length(); i < n; i++) {
668+
char c = rawEndpointPath.charAt(i);
669+
if (c == '%' || c == '\\') {
670+
return true;
680671
}
681-
decoded = next;
682672
}
683673
return false;
684674
}
@@ -1032,9 +1022,11 @@ private boolean adopt(PersistedToken token) {
10321022
}
10331023
// the file is attacker-writable, so treat the served token (the one getToken() puts verbatim into an
10341024
// Authorization header or a PG-wire password) as untrusted: reject a control/non-ASCII char - and the
1035-
// whole entry - rather than route a tampered credential onto the wire. A null served token is unusable.
1025+
// whole entry - rather than route a tampered credential onto the wire. A null OR empty served token is
1026+
// unusable: an empty string passes hasOnlyTokenChars vacuously but would be served as a blank
1027+
// "Bearer " header that only draws a 401, so reject it here and fall through to a refresh or sign-in.
10361028
String servedToken = groupsInToken ? token.getIdToken() : token.getAccessToken();
1037-
if (servedToken == null || !hasOnlyTokenChars(servedToken)) {
1029+
if (servedToken == null || servedToken.isEmpty() || !hasOnlyTokenChars(servedToken)) {
10381030
return false;
10391031
}
10401032
accessToken = token.getAccessToken();
@@ -2016,8 +2008,16 @@ static Endpoint parse(String url) {
20162008
int port;
20172009
if (colon >= 0) {
20182010
host = hostPort.substring(0, colon);
2011+
String portStr = hostPort.substring(colon + 1);
2012+
// reject a leading '+': Integer.parseInt would read ":+443" as 443 and slip the range check,
2013+
// but a real authority port is bare digits. A leading '-' or any non-digit still flows to
2014+
// parseInt below, which rejects it (a negative fails the 1..65535 range check, a non-number
2015+
// throws NumberFormatException) - so only the '+' that parseInt silently accepts is caught here
2016+
if (portStr.isEmpty() || portStr.charAt(0) == '+') {
2017+
throw new OidcAuthException().put("invalid url, could not parse the port [url=").put(url).put(']');
2018+
}
20192019
try {
2020-
port = Integer.parseInt(hostPort.substring(colon + 1));
2020+
port = Integer.parseInt(portStr);
20212021
} catch (NumberFormatException e) {
20222022
throw new OidcAuthException().put("invalid url, could not parse the port [url=").put(url).put(']');
20232023
}
@@ -2038,9 +2038,16 @@ static Endpoint parse(String url) {
20382038
// advertised by a tampered /settings could otherwise pass the pin against the issuer. LDH ASCII
20392039
// hosts, punycode (xn--...) and dotted IPv4 are all ASCII and unaffected.
20402040
for (int i = 0, n = host.length(); i < n; i++) {
2041-
if (host.charAt(i) > 0x7f) {
2041+
char hc = host.charAt(i);
2042+
if (hc > 0x7f) {
20422043
throw new OidcAuthException().put("invalid url, the host contains a non-ASCII character [url=").put(url).put(']');
20432044
}
2045+
// reject a backslash in the host: the WHATWG URL spec folds '\' to '/', so a host like
2046+
// good.com\.evil.com could be re-split by a lenient consumer into a different authority. The OS
2047+
// resolver this client hands the host to never resolves such a name anyway, so fail closed.
2048+
if (hc == '\\') {
2049+
throw new OidcAuthException().put("invalid url, the host contains a backslash [url=").put(url).put(']');
2050+
}
20442051
}
20452052
return new Endpoint(host, port, path, isTls);
20462053
}

core/src/main/java/io/questdb/client/cutlass/json/JsonLexer.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -416,9 +416,10 @@ private CharSequence unescape(CharSequence raw) {
416416
}
417417
break;
418418
default:
419-
// unknown escape: keep the backslash and the escaped character verbatim (lenient), so a
420-
// literal backslash in non-conformant input (e.g. a Windows path in an error body) is not
421-
// dropped
419+
// an unrecognized escape letter: keep the backslash and the char verbatim (lenient), so a
420+
// stray '\' before a non-escape char in non-conformant input survives rather than being
421+
// dropped. A '\' before a RECOGNIZED escape letter (" \ / b f n r t u) is still decoded by
422+
// the cases above - standard JSON unescape - so only genuinely unknown sequences reach here.
422423
unescapeSink.put('\\').put(esc);
423424
i += 2;
424425
break;

core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthPersistenceTest.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,33 @@ public void testStoreLoadedAtMostOncePerInstance() throws Exception {
595595
});
596596
}
597597

598+
@Test(timeout = 30_000)
599+
public void testTamperedEmptyServedTokenRejectedOnLoad() throws Exception {
600+
assertMemoryLeak(() -> {
601+
AtomicInteger device = new AtomicInteger();
602+
MockOidcServer.Handler handler = (method, path, body) -> {
603+
if (DEVICE_PATH.equals(path)) {
604+
device.incrementAndGet();
605+
return MockOidcServer.json(200, deviceAuthJson());
606+
}
607+
return MockOidcServer.json(200, tokenJson("ACCESS-FRESH", null, "REFRESH-1", 3600));
608+
};
609+
try (MockOidcServer server = new MockOidcServer(handler)) {
610+
FakeTokenStore fake = new FakeTokenStore();
611+
// a tampered persisted entry with an EMPTY served token passes hasOnlyTokenChars vacuously but
612+
// would be served as a blank "Bearer " header; adopt() must reject it (not serve "") and fall
613+
// back to the device flow, exactly like a control-char token
614+
fake.loadReturns = new PersistedToken("", null, "REFRESH-1", System.currentTimeMillis() + 300_000, 300_000);
615+
try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
616+
String result = auth.signIn();
617+
Assert.assertEquals("ACCESS-FRESH", result);
618+
Assert.assertNotEquals("", result);
619+
}
620+
Assert.assertTrue("a rejected empty persisted token must fall back to the device flow", device.get() >= 1);
621+
}
622+
});
623+
}
624+
598625
@Test(timeout = 30_000)
599626
public void testTamperedFarFutureExpiryIsBoundedNotTrustedForever() throws Exception {
600627
assertMemoryLeak(() -> {

core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTest.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,9 @@ public void testEndpointParseRejectsMalformedUrls() {
10751075
assertBuildFails("https://idp:0/d", "https://idp/t", "between 1 and 65535");
10761076
assertBuildFails("https://idp:-1/d", "https://idp/t", "between 1 and 65535");
10771077
assertBuildFails("https://idp/d", "https://idp:70000/t", "between 1 and 65535");
1078+
// a leading '+' on the port is rejected: Integer.parseInt would read ":+443" as 443, but a real
1079+
// authority port is bare digits (a leading '-' is already caught by the range check above)
1080+
assertBuildFails("https://idp:+443/d", "https://idp/t", "could not parse the port");
10781081
// a host carrying control characters or whitespace (e.g. a smuggled CR/LF that would inject into the
10791082
// outbound Host header) is rejected rather than passed verbatim to the transport
10801083
assertBuildFails("https://ho\r\nst/d", "https://idp/t", "illegal character");
@@ -1105,6 +1108,9 @@ public void testEndpointParseRejectsMalformedUrls() {
11051108
// U+212A -> k, ...), so a homoglyph host could otherwise pass the origin pin against a pinned issuer
11061109
assertBuildFails("https://\u0130dp/d", "https://idp/t", "non-ASCII"); // U+0130, folds to i
11071110
assertBuildFails("https://idp/d", "https://\u212Aelvin/t", "non-ASCII"); // U+212A Kelvin, folds to k
1111+
// a backslash in the host is rejected: the WHATWG URL spec folds '\' to '/', so a lenient consumer
1112+
// could re-split good.com\.evil.com into a different authority
1113+
assertBuildFails("https://good.com\\.evil.com/d", "https://idp/t", "backslash");
11081114
}
11091115

11101116
@Test(timeout = 30_000)
@@ -1934,6 +1940,12 @@ public void testIssuerPathScopingRejectsSplitEncodedAndBackslashSeparators() thr
19341940
// rejects it at the encoded level too, before decodePathSegments would fold it to '/'
19351941
Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%5cevil/token", issuer));
19361942
Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme%5Cevil/token", issuer));
1943+
// overlong-UTF-8 (%c0%ae, %e0%80%ae) and an IIS-style %u002e encode a '.'/'/' that a permissive server
1944+
// resolves but a byte-oriented percent decode leaves as high bytes or literal text; sitting past the
1945+
// issuer prefix they would slip the '..'/segment scan, so any '%' in an endpoint path is rejected
1946+
Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/%c0%ae%c0%ae/evil/token", issuer));
1947+
Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/%e0%80%ae%e0%80%ae/evil/token", issuer));
1948+
Assert.assertFalse(invokeIsEndpointUnderIssuerPath("https://idp.example.com/realms/acme/%u002e%u002e/evil/token", issuer));
19371949
}
19381950

19391951
@Test(timeout = 30_000)

0 commit comments

Comments
 (0)