Skip to content

Commit 1d67163

Browse files
glasstigerclaude
andcommitted
Reject non-ASCII OIDC hosts and fix review nits
A review flagged several minor issues; this commit addresses the actionable ones. Endpoint.parse now rejects a non-ASCII host. String.equalsIgnoreCase folds several non-ASCII letters onto ASCII (U+0130 -> i, U+212A -> k, ...), so a homoglyph host advertised by a tampered /settings could otherwise pass the origin pin against a pinned issuer; a non-ASCII host would not resolve anyway (the HTTP layer sends it to the OS resolver as raw UTF-8, no IDNA). Endpoint.parse also lower-cases the URL scheme before matching so a case-insensitive HTTPS/Http (RFC 3986) builds, matching the browser launcher; toLowerCase folds only ASCII, so a homoglyph scheme stays rejected. Renamed sameOrigin to isSameOrigin per the is/has convention. FileTokenStore.save now retries the atomic rename on a transient Windows AccessDeniedException (a sharing violation from a concurrent reader), so a routine read/write overlap does not needlessly degrade best-effort persistence. POSIX rename over an open file is unaffected. Smaller nits: DirectUtf8Sink.put(byte[],int,int) asserts its range; AbstractLineHttpSender.close() nulls jsonErrorParser after freeing it; Utf16Sink.putAsPrintable skips a redundant charAt on the BMP fast path; and a duplicated comment in TokenStoreKey is removed. Adds OidcDeviceAuthTest cases: a non-ASCII host and an uppercase scheme (both proven to fail against the pre-fix code), and the issuer-pin accept path for host casing and an implicit vs explicit 443 port. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9e0a756 commit 1d67163

7 files changed

Lines changed: 107 additions & 23 deletions

File tree

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

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import java.nio.ByteBuffer;
4040
import java.nio.channels.FileChannel;
4141
import java.nio.charset.StandardCharsets;
42+
import java.nio.file.AccessDeniedException;
4243
import java.nio.file.AtomicMoveNotSupportedException;
4344
import java.nio.file.DirectoryStream;
4445
import java.nio.file.FileAlreadyExistsException;
@@ -130,6 +131,12 @@ public final class FileTokenStore implements TokenStore {
130131
// attacker-writable directory as the token file, and a real owner stamp (pid@host + millis + UUID) is a
131132
// few hundred bytes, so anything past this cap is corrupt or hostile and is not read into memory
132133
private static final int MAX_LOCK_FILE_BYTES = 1 << 12;
134+
// Windows can fail the atomic token-file rename with a transient AccessDeniedException (a sharing violation)
135+
// when a concurrent reader in any process holds the target open; retry the rename this many times on a short
136+
// backoff before giving up, so a routine read/write overlap does not needlessly degrade persistence. Kept
137+
// small - persistence is best-effort and the in-memory token is valid regardless.
138+
private static final int REPLACE_MAX_ATTEMPTS = 5;
139+
private static final long REPLACE_RETRY_SLEEP_MILLIS = 20L;
133140
private static final int SCHEMA_VERSION = 1;
134141
// set once if the platform cannot enforce owner-only POSIX permissions on the token files (e.g. Windows),
135142
// so the at-rest protection falls back to the directory's inherited ACL; warns the user once
@@ -286,12 +293,7 @@ public void save(TokenStoreKey key, PersistedToken token) {
286293
boolean moved = false;
287294
try {
288295
writeAndFlush(tmp, content);
289-
try {
290-
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
291-
} catch (AtomicMoveNotSupportedException e) {
292-
// a rare filesystem without atomic rename; a plain replace still beats a partial write
293-
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
294-
}
296+
replaceTarget(tmp, target);
295297
moved = true;
296298
} finally {
297299
if (!moved) {
@@ -536,6 +538,31 @@ private static void releaseLock(Path lock, String nonce) {
536538
}
537539
}
538540

541+
private static void replaceTarget(Path tmp, Path target) throws IOException {
542+
// atomically rename tmp over target. On Windows a concurrent reader in any process holding target open
543+
// can make the rename fail transiently with AccessDeniedException (a sharing violation); retry a few
544+
// times on a short backoff before giving up, so a routine read/write overlap does not needlessly degrade
545+
// persistence (best-effort - the in-memory token is still valid). POSIX rename over an open file never
546+
// hits this. AtomicMoveNotSupported (a rare filesystem) falls back to a plain replace, which still beats
547+
// leaving a partial write.
548+
AccessDeniedException lastDenied = null;
549+
for (int attempt = 0; attempt < REPLACE_MAX_ATTEMPTS; attempt++) {
550+
if (attempt > 0) {
551+
Os.sleep(REPLACE_RETRY_SLEEP_MILLIS);
552+
}
553+
try {
554+
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
555+
return;
556+
} catch (AtomicMoveNotSupportedException e) {
557+
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
558+
return;
559+
} catch (AccessDeniedException e) {
560+
lastDenied = e;
561+
}
562+
}
563+
throw lastDenied;
564+
}
565+
539566
private static void restrictToOwner(Path directory) {
540567
// best-effort: the at-rest protection of the plaintext token files is exactly these owner-only
541568
// directory permissions, so re-tighten a pre-existing directory another tool/umask left loose rather

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

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -360,10 +360,10 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, DiscoveryOptions opt
360360
// still applies to every endpoint, enforced by validateEndpointOrigins in build().
361361
if (resolvedIssuer != null) {
362362
Endpoint pin = Endpoint.parse(resolvedIssuer);
363-
if (tokenEndpointFromSettings && !sameOrigin(Endpoint.parse(tokenEndpoint), pin)) {
363+
if (tokenEndpointFromSettings && !isSameOrigin(Endpoint.parse(tokenEndpoint), pin)) {
364364
throw endpointOriginNotPinned("token endpoint", tokenEndpoint, originOf(pin));
365365
}
366-
if (deviceEndpointFromSettings && !sameOrigin(Endpoint.parse(deviceAuthorizationEndpoint), pin)) {
366+
if (deviceEndpointFromSettings && !isSameOrigin(Endpoint.parse(deviceAuthorizationEndpoint), pin)) {
367367
throw endpointOriginNotPinned("device authorization endpoint", deviceAuthorizationEndpoint, originOf(pin));
368368
}
369369
}
@@ -810,6 +810,14 @@ private static boolean isLoopbackHost(String host) {
810810
return host != null && (host.equalsIgnoreCase("localhost") || (host.startsWith("127.") && isDottedIpv4(host)));
811811
}
812812

813+
private static boolean isSameOrigin(Endpoint a, Endpoint b) {
814+
// scheme (via isTls), host and port - the security origin; path is deliberately not compared, the
815+
// token and device endpoints legitimately differ in path on one authorization server. Endpoint.parse
816+
// rejects a non-ASCII host, so this equalsIgnoreCase host compare only ever folds ASCII case - no
817+
// non-ASCII homoglyph can fold onto a pinned issuer host here.
818+
return a.isTls == b.isTls && a.port == b.port && a.host.equalsIgnoreCase(b.host);
819+
}
820+
813821
private static String originOf(Endpoint endpoint) {
814822
return (endpoint.isTls ? "https://" : "http://") + endpoint.host + ':' + endpoint.port;
815823
}
@@ -917,12 +925,6 @@ private static void requireSecureTransport(boolean isTls, String label, String u
917925
}
918926
}
919927

920-
private static boolean sameOrigin(Endpoint a, Endpoint b) {
921-
// scheme (via isTls), host and port - the security origin; path is deliberately not compared, the
922-
// token and device endpoints legitimately differ in path on one authorization server
923-
return a.isTls == b.isTls && a.port == b.port && a.host.equalsIgnoreCase(b.host);
924-
}
925-
926928
private static String sanitizeForDisplay(String value) {
927929
if (value == null) {
928930
return null;
@@ -984,20 +986,20 @@ private static void validateEndpointOrigins(Endpoint tokenEndpoint, Endpoint dev
984986
// the issuer origin must then be configured without an issuer. fromQuestDB pins differently: it
985987
// origin-pins only the /settings-advertised endpoints itself (a discovered endpoint is trusted), so
986988
// it passes no issuer here and relies on this method only for the co-location check.
987-
if (!sameOrigin(tokenEndpoint, deviceAuthorizationEndpoint)) {
989+
if (!isSameOrigin(tokenEndpoint, deviceAuthorizationEndpoint)) {
988990
throw new OidcAuthException()
989991
.put("the OIDC token and device authorization endpoints are on different origins (")
990992
.put(originOf(tokenEndpoint)).put(" vs ").put(originOf(deviceAuthorizationEndpoint))
991993
.put("); refusing to send credentials. This indicates a misconfigured or tampered OIDC configuration");
992994
}
993995
if (issuer != null) {
994-
if (!sameOrigin(tokenEndpoint, issuer)) {
996+
if (!isSameOrigin(tokenEndpoint, issuer)) {
995997
throw new OidcAuthException()
996998
.put("the OIDC token endpoint origin (").put(originOf(tokenEndpoint))
997999
.put(") does not match the issuer origin (").put(originOf(issuer))
9981000
.put("); refusing to send credentials to an endpoint outside the trusted issuer");
9991001
}
1000-
if (!sameOrigin(deviceAuthorizationEndpoint, issuer)) {
1002+
if (!isSameOrigin(deviceAuthorizationEndpoint, issuer)) {
10011003
throw new OidcAuthException()
10021004
.put("the OIDC device authorization endpoint origin (").put(originOf(deviceAuthorizationEndpoint))
10031005
.put(") does not match the issuer origin (").put(originOf(issuer))
@@ -1946,7 +1948,10 @@ static Endpoint parse(String url) {
19461948
throw new OidcAuthException().put("invalid url, expected a scheme [url=").put(url).put(']');
19471949
}
19481950
boolean isTls;
1949-
String scheme = url.substring(0, schemeEnd);
1951+
// lower-case the scheme before matching: RFC 3986 schemes are case-insensitive, so HTTPS/Http are
1952+
// valid. toLowerCase(Locale.ROOT) folds only ASCII case, so a homoglyph scheme (a long-s for the s,
1953+
// say) does NOT fold onto http/https and still falls through to the reject below.
1954+
String scheme = url.substring(0, schemeEnd).toLowerCase(Locale.ROOT);
19501955
if ("https".equals(scheme)) {
19511956
isTls = true;
19521957
} else if ("http".equals(scheme)) {
@@ -2006,6 +2011,17 @@ static Endpoint parse(String url) {
20062011
if (host.isEmpty()) {
20072012
throw new OidcAuthException().put("invalid url, the host is empty [url=").put(url).put(']');
20082013
}
2014+
// reject a non-ASCII host. The HTTP layer hands the host to the OS resolver as raw UTF-8 with no
2015+
// IDNA, so a non-ASCII name would not resolve anyway; and a non-ASCII code point makes the origin-pin
2016+
// host compare (isSameOrigin -> String.equalsIgnoreCase) unsafe, because equalsIgnoreCase folds
2017+
// several non-ASCII letters (U+0130, U+0131, U+017F, U+212A, ...) onto ASCII - so a homoglyph host
2018+
// advertised by a tampered /settings could otherwise pass the pin against the issuer. LDH ASCII
2019+
// hosts, punycode (xn--...) and dotted IPv4 are all ASCII and unaffected.
2020+
for (int i = 0, n = host.length(); i < n; i++) {
2021+
if (host.charAt(i) > 0x7f) {
2022+
throw new OidcAuthException().put("invalid url, the host contains a non-ASCII character [url=").put(url).put(']');
2023+
}
2024+
}
20092025
return new Endpoint(host, port, path, isTls);
20102026
}
20112027
}

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,6 @@ public TokenStoreKey(
7070
String audience,
7171
boolean groupsInToken
7272
) {
73-
// the identity fields are required; reject a null up front with a clear error rather than letting it
74-
// surface later as a raw NullPointerException deep inside a TokenStore's serialize/fingerprint path
7573
// the identity fields are required; reject a null up front with a clear error rather than letting it
7674
// surface later as a raw NullPointerException deep inside a TokenStore's serialize/fingerprint path
7775
if (clientId == null || tokenEndpoint == null || deviceAuthorizationEndpoint == null || scope == null) {

core/src/main/java/io/questdb/client/cutlass/line/http/AbstractLineHttpSender.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ public void close() {
475475
flush0(true);
476476
}
477477
} finally {
478-
Misc.free(jsonErrorParser);
478+
jsonErrorParser = Misc.free(jsonErrorParser);
479479
closed = true;
480480
client = Misc.free(client);
481481
}

core/src/main/java/io/questdb/client/std/str/DirectUtf8Sink.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ public DirectUtf8Sink put(byte b) {
110110
* that rely on {@link #isAscii()} should not use this overload for ascii-only content.
111111
*/
112112
public DirectUtf8Sink put(byte[] src, int lo, int hi) {
113+
assert lo >= 0 && hi <= src.length && lo <= hi : "put(byte[]) range out of bounds";
113114
final int len = hi - lo;
114115
if (len > 0) {
115116
setAscii(false);

core/src/main/java/io/questdb/client/std/str/Utf16Sink.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,11 @@ default void putAsPrintable(CharSequence nonPrintable) {
5252
final int cp = Character.codePointAt(nonPrintable, i);
5353
final int count = Character.charCount(cp);
5454
if (DisplaySafe.isDisplaySafe(cp)) {
55-
for (int j = 0; j < count; j++) {
56-
put(nonPrintable.charAt(i + j));
55+
if (count == 1) {
56+
put((char) cp); // BMP: cp already is the char, so skip the redundant charAt re-read
57+
} else {
58+
put(nonPrintable.charAt(i));
59+
put(nonPrintable.charAt(i + 1));
5760
}
5861
} else {
5962
DisplaySafe.putUnicodeEscape(this, cp);

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,23 @@ public void testAudienceSentOnRefresh() throws Exception {
213213
});
214214
}
215215

216+
@Test(timeout = 30_000)
217+
public void testBuilderIssuerPinAcceptsHostCasingAndImplicitPort() throws Exception {
218+
assertMemoryLeak(() -> {
219+
// the origin pin (isSameOrigin) folds host case (ASCII) and treats an implicit https port as 443, so
220+
// an endpoint differing from the issuer only in host case or an explicit :443 is still same-origin
221+
try (OidcDeviceAuth ignored = OidcDeviceAuth.builder()
222+
.clientId("c")
223+
.deviceAuthorizationEndpoint("https://IDP.Example:443/as/device")
224+
.tokenEndpoint("https://idp.example/as/token")
225+
.issuer("https://Idp.Example")
226+
.build()
227+
) {
228+
// accepted: host-case and implicit-vs-explicit 443 differences do not defeat the origin pin
229+
}
230+
});
231+
}
232+
216233
@Test(timeout = 30_000)
217234
public void testBuilderIssuerPinAcceptsMatchingOrigin() throws Exception {
218235
assertMemoryLeak(() -> {
@@ -1024,6 +1041,23 @@ public void testEndpointParseRejectsDisplayUnsafeUrl() {
10241041
}
10251042
}
10261043

1044+
@Test(timeout = 30_000)
1045+
public void testEndpointParseAcceptsUppercaseScheme() throws Exception {
1046+
assertMemoryLeak(() -> {
1047+
// RFC 3986 schemes are case-insensitive, so HTTPS/Http must build - matching BrowserLauncher's
1048+
// case-insensitive scheme allowlist. (Endpoint.parse lower-cases only ASCII, so a homoglyph scheme
1049+
// is still rejected as "expected http or https".)
1050+
try (OidcDeviceAuth ignored = OidcDeviceAuth.builder()
1051+
.clientId("c")
1052+
.deviceAuthorizationEndpoint("HTTPS://idp.example/device")
1053+
.tokenEndpoint("Https://idp.example/token")
1054+
.build()
1055+
) {
1056+
// accepted: build() did not reject the mixed-case https scheme
1057+
}
1058+
});
1059+
}
1060+
10271061
@Test(timeout = 30_000)
10281062
public void testEndpointParseRejectsMalformedUrls() {
10291063
// Endpoint.parse rejects malformed endpoint URLs at build time
@@ -1058,6 +1092,11 @@ public void testEndpointParseRejectsMalformedUrls() {
10581092
assertBuildFails("https://idp/realms/acme#/../other/device", "https://idp/realms/acme/token", "fragment");
10591093
assertBuildFails("https://idp/d", "https://idp/realms/acme#/../other/token", "fragment");
10601094
assertBuildFails("https://idp/d#", "https://idp/t", "fragment");
1095+
// a non-ASCII host is rejected: it would not resolve (the HTTP layer sends the host to the OS resolver
1096+
// as raw UTF-8, no IDNA), and equalsIgnoreCase folds several non-ASCII letters onto ASCII (U+0130 -> i,
1097+
// U+212A -> k, ...), so a homoglyph host could otherwise pass the origin pin against a pinned issuer
1098+
assertBuildFails("https://\u0130dp/d", "https://idp/t", "non-ASCII"); // U+0130, folds to i
1099+
assertBuildFails("https://idp/d", "https://\u212Aelvin/t", "non-ASCII"); // U+212A Kelvin, folds to k
10611100
}
10621101

10631102
@Test(timeout = 30_000)

0 commit comments

Comments
 (0)