Skip to content

Commit f3e62ed

Browse files
glasstigerclaude
andcommitted
Address code-review findings on OIDC device flow
Follow-up fixes from a review of the OIDC device-flow PR. Security - Endpoint.parse rejects a URL query (?...), matching its existing fragment (#) rejection: pathOnly() strips the query before the issuer-path pin, yet postForm sends endpoint.path verbatim, so a tampered /settings could route an unvalidated query to the IdP. Correctness - tryRefresh falls back to the interactive flow when a refreshed token carries a control/non-ASCII char, instead of letting validateTokenChars propagate past the fallback. - JsonLexer.unescape keeps the backslash on an unknown or malformed escape rather than dropping it, so non-conformant text survives. - DirectUtf8Sink.put(byte[]) range-checks and throws instead of an assert that is a no-op in client apps run without -ea. - FileTokenStore warns once about missing POSIX perms via an AtomicBoolean compareAndSet, closing a benign double-warn race. Performance - AbstractLineHttpSender precomputes the User-Agent header once instead of concatenating it on every newRequest. - OidcDeviceAuth reuses build()'s parsed endpoints in the constructor instead of re-parsing the raw strings. Docs and tests - DeviceCodePrompt javadoc says "display-safe text", not "plain ASCII". - oidc-token-persistence.md reflects the shipped implementation. - Add direct DirectUtf8Sink.put(byte[]) range/bounds tests and a short 1-digit 4xx/5xx status rejection test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6ced352 commit f3e62ed

10 files changed

Lines changed: 165 additions & 29 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
public interface DeviceCodePrompt {
4141

4242
/**
43-
* Prints the sign-in instructions to {@code System.out} as plain ASCII, without opening a browser.
43+
* Prints the sign-in instructions to {@code System.out} as display-safe text, without opening a browser.
4444
* The default prompt is {@link #openBrowser()}; use this to opt out of the browser launch.
4545
*/
4646
DeviceCodePrompt SYSTEM_OUT = challenge -> {

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
import java.util.Arrays;
5757
import java.util.Set;
5858
import java.util.UUID;
59+
import java.util.concurrent.atomic.AtomicBoolean;
5960

6061
/**
6162
* The default {@link TokenStore}: one plaintext JSON file per identity under a directory, with the
@@ -139,8 +140,9 @@ public final class FileTokenStore implements TokenStore {
139140
private static final long REPLACE_RETRY_SLEEP_MILLIS = 20L;
140141
private static final int SCHEMA_VERSION = 1;
141142
// set once if the platform cannot enforce owner-only POSIX permissions on the token files (e.g. Windows),
142-
// so the at-rest protection falls back to the directory's inherited ACL; warns the user once
143-
private static volatile boolean warnedNoPosixPerms;
143+
// so the at-rest protection falls back to the directory's inherited ACL; warns the user exactly once
144+
// (compareAndSet, so a race between two threads still prints a single warning)
145+
private static final AtomicBoolean warnedNoPosixPerms = new AtomicBoolean();
144146
private final Path directory;
145147
private final long lockAcquireBudgetMillis;
146148
private final long lockStaleMillis;
@@ -610,10 +612,9 @@ private static void warnNoPosixPermsOnce() {
610612
// best-effort, once per JVM: the token store could not enforce 0600/0700, so the persisted refresh
611613
// token is protected only by the directory's inherited ACL. ASCII-only, and never includes a path or
612614
// token byte (a path could itself carry terminal-spoofing characters)
613-
if (warnedNoPosixPerms) {
615+
if (!warnedNoPosixPerms.compareAndSet(false, true)) {
614616
return;
615617
}
616-
warnedNoPosixPerms = true;
617618
System.err.println("questdb client: the OIDC token store could not enforce owner-only (0600/0700) "
618619
+ "permissions on this filesystem; the persisted refresh token is protected only by the "
619620
+ "directory's default ACL. Back the store with an OS keychain for at-rest encryption.");

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

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -199,13 +199,14 @@ public class OidcDeviceAuth implements QuietCloseable {
199199
// clock skew at half of this so a short-lived token is not treated as expired the instant it is issued
200200
private long tokenTtlMillis;
201201

202-
private OidcDeviceAuth(Builder builder, ClientTlsConfiguration tlsConfig) {
202+
private OidcDeviceAuth(Builder builder, ClientTlsConfiguration tlsConfig, Endpoint deviceAuthorizationEndpoint, Endpoint tokenEndpoint) {
203203
String clientId = builder.clientId;
204204
// pre-encode the invariant form params once here, so the poll loop and silent refresh do not
205205
// re-run URLEncoder on every request (mirrors the pre-encoded GRANT_TYPE_* constants)
206206
this.clientIdEncoded = urlEncode(clientId);
207-
this.deviceAuthorizationEndpoint = Endpoint.parse(builder.deviceAuthorizationEndpoint);
208-
this.tokenEndpoint = Endpoint.parse(builder.tokenEndpoint);
207+
// build() already parsed and validated these endpoints; reuse them rather than re-parse the raw strings
208+
this.deviceAuthorizationEndpoint = deviceAuthorizationEndpoint;
209+
this.tokenEndpoint = tokenEndpoint;
209210
String scope = builder.scope;
210211
this.scopeEncoded = urlEncode(scope);
211212
String audience = builder.audience;
@@ -226,7 +227,7 @@ private OidcDeviceAuth(Builder builder, ClientTlsConfiguration tlsConfig) {
226227
audience != null && !audience.isEmpty() ? audience : null,
227228
this.groupsInToken
228229
);
229-
// allocate the native lexer last: an Endpoint.parse above can throw on a malformed url, and
230+
// allocate the native lexer last: urlEncode and the TokenStoreKey construction above can throw, and
230231
// the half-built instance is never returned, so close() could not free an earlier alloc
231232
this.jsonLexer = new JsonLexer(JSON_LEXER_CACHE_SIZE, JSON_LEXER_MAX_VALUE_BYTES);
232233
}
@@ -1517,7 +1518,17 @@ private boolean tryRefresh() {
15171518
&& isHttpStatusSuccess()
15181519
&& tokenParser.error.length() == 0;
15191520
if (hasRequiredToken) {
1520-
storeTokens(tokenParser);
1521+
try {
1522+
storeTokens(tokenParser);
1523+
} catch (OidcAuthException e) {
1524+
// storeTokens -> validateTokenChars rejects a refreshed served token carrying a control or
1525+
// non-ASCII char (reachable now that JsonLexer decodes an escaped \r/\n in the response into a
1526+
// real byte). Fall back to the interactive flow like the transport/parse-failure arms above,
1527+
// rather than let the rejection propagate out of getToken()/signIn() past the runDeviceFlow()
1528+
// fallback the caller expects. validateTokenChars runs before any state mutation, so the cached
1529+
// token and refresh token are left intact for that fallback.
1530+
return false;
1531+
}
15211532
return true;
15221533
}
15231534
// the refresh token expired or was revoked, or did not return the token we need; fall back to the
@@ -1631,7 +1642,7 @@ public OidcDeviceAuth build() {
16311642
.put("), otherwise a slow refresh's live cross-process lock could be stolen by a peer mid-refresh");
16321643
}
16331644
}
1634-
return new OidcDeviceAuth(this, tls);
1645+
return new OidcDeviceAuth(this, tls, deviceEndpoint, parsedTokenEndpoint);
16351646
}
16361647

16371648
public Builder clientId(String clientId) {
@@ -1943,6 +1954,17 @@ static Endpoint parse(String url) {
19431954
if (url.indexOf('#') >= 0) {
19441955
throw new OidcAuthException().put("invalid url, a fragment (#) is not supported [url=").put(url).put(']');
19451956
}
1957+
// reject a query (?...) for the same pin-bypass reason as the fragment above: pathOnly() strips it
1958+
// before the issuer-path check, yet postForm sends endpoint.path - query included - verbatim on the
1959+
// wire, so a tampered /settings could advertise an endpoint carrying a query the issuer-path pin
1960+
// never validated (and a lenient server could even normalize a '..' hidden past the '?'). An OIDC
1961+
// device/token endpoint carries its parameters in the request body (application/x-www-form-urlencoded),
1962+
// never the url query - RFC 6749 3.2 permits a query component but no real provider uses one here - so
1963+
// fail closed. The user-facing verification url, which legitimately carries the user code as a query,
1964+
// is parsed by BrowserLauncher (java.net.URI), not this method, so it is unaffected.
1965+
if (url.indexOf('?') >= 0) {
1966+
throw new OidcAuthException().put("invalid url, a query (?) is not supported [url=").put(url).put(']');
1967+
}
19461968
int schemeEnd = url.indexOf("://");
19471969
if (schemeEnd < 0) {
19481970
throw new OidcAuthException().put("invalid url, expected a scheme [url=").put(url).put(']');

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -409,14 +409,17 @@ private CharSequence unescape(CharSequence raw) {
409409
unescapeSink.put((char) cp);
410410
i += 6;
411411
} else {
412-
// malformed unicode escape: drop the backslash, keep the following character
413-
unescapeSink.put(esc);
412+
// malformed unicode escape: keep the backslash and the 'u' verbatim (lenient), so a
413+
// non-conformant server's literal text survives rather than silently losing a byte
414+
unescapeSink.put('\\').put(esc);
414415
i += 2;
415416
}
416417
break;
417418
default:
418-
// unknown escape: drop the backslash, keep the escaped character (lenient)
419-
unescapeSink.put(esc);
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
422+
unescapeSink.put('\\').put(esc);
420423
i += 2;
421424
break;
422425
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ public abstract class AbstractLineHttpSender implements Sender {
8383
private final CharSequence questDBVersion;
8484
private final Rnd rnd;
8585
private final StringSink sink = new StringSink();
86+
private final String userAgent;
8687
private final String username;
8788
protected HttpClient.Request request;
8889
private HttpClient client;
@@ -203,6 +204,9 @@ protected AbstractLineHttpSender(
203204
: HttpClientFactory.newPlainTextInstance(clientConfiguration);
204205
}
205206
this.questDBVersion = new BuildInformationHolder().getSwVersion();
207+
// precompute the User-Agent header value once: newRequest() runs on every flush (and twice per flush
208+
// for a token provider), so concatenating it there would allocate a String each time
209+
this.userAgent = "QuestDB/java/" + questDBVersion;
206210
this.request = newRequest();
207211
this.maxNameLength = maxNameLength;
208212
this.rnd = rnd;
@@ -760,7 +764,7 @@ private HttpClient.Request newRequest(boolean pullProviderToken) {
760764
HttpClient.Request r = client.newRequest(currentHost(), currentPort())
761765
.POST()
762766
.url(path)
763-
.header("User-Agent", "QuestDB/java/" + questDBVersion);
767+
.header("User-Agent", userAgent);
764768
if (username != null) {
765769
r.authBasic(username, password);
766770
} else if (httpTokenProvider != null) {

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,12 @@ 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";
113+
// a real check, not an assert: this is public API doing an unchecked Unsafe.copyMemory, and client apps
114+
// typically run without -ea, so a bad range must fail with a clear exception rather than a native
115+
// out-of-bounds read that corrupts memory or crashes the JVM
116+
if (lo < 0 || hi > src.length || lo > hi) {
117+
throw new IndexOutOfBoundsException("put(byte[]) range out of bounds [lo=" + lo + ", hi=" + hi + ", len=" + src.length + ']');
118+
}
114119
final int len = hi - lo;
115120
if (len > 0) {
116121
setAscii(false);

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,6 +1092,14 @@ public void testEndpointParseRejectsMalformedUrls() {
10921092
assertBuildFails("https://idp/realms/acme#/../other/device", "https://idp/realms/acme/token", "fragment");
10931093
assertBuildFails("https://idp/d", "https://idp/realms/acme#/../other/token", "fragment");
10941094
assertBuildFails("https://idp/d#", "https://idp/t", "fragment");
1095+
// a query (?...) is rejected for the same pin-bypass reason: pathOnly() strips it before the issuer-path
1096+
// pin while postForm sends endpoint.path - query included - verbatim, so a "?..." the pin never validated
1097+
// would still reach the wire. An OIDC device/token endpoint carries its parameters in the request body,
1098+
// never the url query, so fail closed (the user-facing verification url may carry one, but it is parsed
1099+
// by BrowserLauncher, not Endpoint.parse)
1100+
assertBuildFails("https://idp/realms/acme/device?x=/../other", "https://idp/realms/acme/token", "query");
1101+
assertBuildFails("https://idp/d", "https://idp/realms/acme/token?client_id=evil", "query");
1102+
assertBuildFails("https://idp/d?a=b", "https://idp/t", "query");
10951103
// a non-ASCII host is rejected: it would not resolve (the HTTP layer sends the host to the OS resolver
10961104
// as raw UTF-8, no IDNA), and equalsIgnoreCase folds several non-ASCII letters onto ASCII (U+0130 -> i,
10971105
// U+212A -> k, ...), so a homoglyph host could otherwise pass the origin pin against a pinned issuer
@@ -3298,6 +3306,48 @@ public void testShortAllDigitStatusIsNotTreatedAsSuccess() throws Exception {
32983306
});
32993307
}
33003308

3309+
@Test
3310+
public void testShortAllDigitStatusNotTreatedAsTransientOrTerminal() throws Exception {
3311+
// a real HTTP status is exactly 3 digits. A malformed 1-digit "5" must not be read as a transient 5xx
3312+
// (which would poll on to the device-code deadline), nor a 1-digit "4" as a terminal 4xx, by the leading
3313+
// digit alone; both fall through to the fast terminal reject rather than an infinite poll.
3314+
for (String shortStatus : new String[]{"5", "4"}) {
3315+
assertMemoryLeak(() -> {
3316+
String tokenBody = tokenJson("SHOULD-NOT-ACCEPT", null, null, 3600);
3317+
String rawToken = "HTTP/1.1 " + shortStatus + " X\r\n"
3318+
+ "Content-Type: application/json\r\n"
3319+
+ "Transfer-Encoding: chunked\r\n\r\n"
3320+
+ Integer.toHexString(tokenBody.length()) + "\r\n" + tokenBody + "\r\n"
3321+
+ "0\r\n\r\n";
3322+
MockOidcServer.Handler handler = (method, path, body) -> {
3323+
if (DEVICE_PATH.equals(path)) {
3324+
return MockOidcServer.json(200, "{"
3325+
+ "\"device_code\":\"DEV\","
3326+
+ "\"user_code\":\"WDJB-MJHT\","
3327+
+ "\"verification_uri\":\"https://verify.example/device\","
3328+
+ "\"expires_in\":300,"
3329+
+ "\"interval\":1"
3330+
+ "}");
3331+
}
3332+
return MockOidcServer.raw(rawToken);
3333+
};
3334+
try (MockOidcServer server = new MockOidcServer(handler);
3335+
OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
3336+
try {
3337+
auth.signIn();
3338+
Assert.fail("expected malformed 1-digit status '" + shortStatus + "' to be rejected fast");
3339+
} catch (OidcAuthException e) {
3340+
String msg = e.getMessage();
3341+
Assert.assertTrue(msg, msg.contains("rejected the request") || msg.contains("refusing to keep polling"));
3342+
// must NOT have polled to the device-code deadline (that would be a mis-classified transient)
3343+
Assert.assertFalse(msg, msg.contains("device code expired"));
3344+
Assert.assertFalse("the unaccepted token must not leak: " + msg, msg.contains("SHOULD-NOT-ACCEPT"));
3345+
}
3346+
}
3347+
});
3348+
}
3349+
}
3350+
33013351
// Forces the cached access/id token to look expired WITHOUT dropping the refresh token, so the next
33023352
// signIn()/getToken() takes the silent-refresh (or interactive re-sign-in) path. Reflection
33033353
// because the field is private and there is no configurable clock skew to lean on anymore; the client is

core/src/test/java/io/questdb/client/test/cutlass/json/JsonLexerTest.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -752,11 +752,12 @@ public void testStringEscapesExoticAndLenient() throws Exception {
752752
assertDecodedValue("{\"v\":\"a" + bs + "bb" + bs + "fc\"}",
753753
"a" + ((char) 8) + "b" + ((char) 12) + "c");
754754
// the lexer is deliberately lenient (not RFC 8259-strict) about malformed or unknown escapes:
755-
// it drops the backslash and keeps the following text rather than failing the parse. These pin
756-
// that behavior and cover the lenient arms that otherwise carry most of the file's coverage:
757-
assertDecodedValue("{\"v\":\"a" + bs + "xb\"}", "axb"); // unknown escape -> drop backslash
758-
assertDecodedValue("{\"v\":\"a" + bs + "uZZZZb\"}", "auZZZZb"); // non-hex unicode escape -> literal
759-
assertDecodedValue("{\"v\":\"ab" + bs + "u12\"}", "abu12"); // too few hex digits -> literal
755+
// it keeps the backslash and the following text verbatim rather than failing the parse, so a
756+
// literal backslash in non-conformant input is not silently lost. These pin that behavior and
757+
// cover the lenient arms that otherwise carry most of the file's coverage:
758+
assertDecodedValue("{\"v\":\"a" + bs + "xb\"}", "a" + bs + "xb"); // unknown escape -> kept verbatim
759+
assertDecodedValue("{\"v\":\"a" + bs + "uZZZZb\"}", "a" + bs + "uZZZZb"); // non-hex unicode escape -> literal
760+
assertDecodedValue("{\"v\":\"ab" + bs + "u12\"}", "ab" + bs + "u12"); // too few hex digits -> literal
760761
// a lone (unpaired) high surrogate is emitted as-is, not dropped or replaced
761762
assertDecodedValue("{\"v\":\"x" + bs + "uD83Dy\"}", "x" + ((char) 0xD83D) + "y");
762763
});

core/src/test/java/io/questdb/client/test/std/str/DirectUtf8SinkTest.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,43 @@ public void testDirectUtf8Sequence() {
121121
}
122122
}
123123

124+
@Test
125+
public void testPutByteArrayRange() {
126+
try (DirectUtf8Sink sink = new DirectUtf8Sink(4)) {
127+
final byte[] src = "abcdefgh".getBytes(StandardCharsets.UTF_8);
128+
129+
// a partial range [2, 5) copies exactly "cde" and grows the sink past its initial capacity
130+
sink.put(src, 2, 5);
131+
Assert.assertEquals(3, sink.size());
132+
TestUtils.assertEquals("cde".getBytes(StandardCharsets.UTF_8), sink);
133+
// the bulk overload sets the ascii hint to false conservatively, even for ascii bytes
134+
Assert.assertFalse(sink.isAscii());
135+
136+
// an empty range [3, 3) is a no-op
137+
final int sizeBefore = sink.size();
138+
sink.put(src, 3, 3);
139+
Assert.assertEquals(sizeBefore, sink.size());
140+
141+
// a full range [0, len) appends the whole array
142+
sink.clear();
143+
sink.put(src, 0, src.length);
144+
Assert.assertEquals(src.length, sink.size());
145+
TestUtils.assertEquals(src, sink);
146+
}
147+
}
148+
149+
@Test
150+
public void testPutByteArrayRangeRejectsBadBounds() {
151+
try (DirectUtf8Sink sink = new DirectUtf8Sink(4)) {
152+
final byte[] src = {1, 2, 3};
153+
// a bad range must throw rather than run an unchecked native copy (asserts are off in client apps)
154+
assertBadRange(sink, src, -1, 2); // lo < 0
155+
assertBadRange(sink, src, 0, 4); // hi > len
156+
assertBadRange(sink, src, 2, 1); // lo > hi
157+
Assert.assertEquals("a rejected put must not advance the sink", 0, sink.size());
158+
}
159+
}
160+
124161
@Test
125162
public void testPutUtf8Sequence() {
126163
try (DirectUtf8Sink sink = new DirectUtf8Sink(1)) {
@@ -208,6 +245,15 @@ public void testUtf8Sequence() {
208245
}
209246
}
210247

248+
private static void assertBadRange(DirectUtf8Sink sink, byte[] src, int lo, int hi) {
249+
try {
250+
sink.put(src, lo, hi);
251+
Assert.fail("expected IndexOutOfBoundsException for lo=" + lo + ", hi=" + hi);
252+
} catch (IndexOutOfBoundsException expected) {
253+
// ok: the public overload range-checks before the native copy
254+
}
255+
}
256+
211257
private static void assertUtf8Encoding(DirectUtf8Sink sink, String s) {
212258
sink.clear();
213259
sink.put(s);

0 commit comments

Comments
 (0)