Skip to content

Commit a454ac0

Browse files
glasstigerclaude
andcommitted
Fix OIDC refresh NPE and harden display safety
Address three moderate findings from the device-flow review. adopt() no longer nulls a live in-memory refresh token when a re-read store entry carries a valid served token but no refresh_token (a cross-language peer that never received one, or a tampered file). Nulling it made tryRefresh() call urlEncode(null) and throw an uncaught NullPointerException that aborted getToken()/signIn() instead of degrading to a refresh or an interactive sign-in. adopt() now keeps the current refresh token and tracks what the file actually carried; tryRefresh() also guards against a null refresh token defensively. DisplaySafe.isDisplaySafe() now rejects U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR, which are neither ISO control nor Cf format chars yet break a rendered log line in ECMAScript/GUI/JSON log consumers. One classifier change closes the gap across every sanitizer that shares it (sanitizeForDisplay, OidcAuthException.putSanitized, and Utf16Sink.putAsPrintable). The httpTokenProvider Javadoc now documents that a failed HTTP flush preserves the buffered request and its baked token and re-sends it verbatim on retry rather than re-pulling, so a flush that keeps failing until the pulled token expires is then rejected (for example a 401) until the caller discards the buffered rows. Add DisplaySafeTest coverage for U+2028/U+2029 and an OidcDeviceAuthPersistenceTest regression that reproduces the refresh NPE via a peer entry omitting the refresh token (verified both ways). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ea7c1a8 commit a454ac0

5 files changed

Lines changed: 80 additions & 7 deletions

File tree

core/src/main/java/io/questdb/client/Sender.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2026,8 +2026,16 @@ public LineSenderBuilder httpToken(String token) {
20262026
* refreshed token is presented each time the link is (re)established; an already-established WebSocket
20272027
* is not re-authenticated mid-stream. The two transports differ on a sustained token outage: over HTTP
20282028
* a failed pull is retried on the next row, but over WebSocket a pull that keeps failing past the
2029-
* reconnect budget terminates the sender for good, like any persistent reconnect failure. A
2030-
* lazily-signing-in provider can therefore be wired before the interactive sign-in completes over HTTP,
2029+
* reconnect budget terminates the sender for good, like any persistent reconnect failure.
2030+
* <br>
2031+
* Over HTTP the token is pulled once per request and written into the request buffer ahead of the
2032+
* buffered rows, so a token refresh is picked up on the next new batch after a successful flush. A
2033+
* failed flush preserves the buffer - token included - for a later retry and re-sends it verbatim
2034+
* rather than re-pulling the token, so a flush that keeps failing until the already-pulled token
2035+
* expires is then rejected (for example a {@code 401}); recover by discarding the buffered rows (close
2036+
* and rebuild the sender) so the next request pulls a fresh token.
2037+
* <br>
2038+
* A lazily-signing-in provider can therefore be wired before the interactive sign-in completes over HTTP,
20312039
* where the first pull is deferred to the first row; over WebSocket a token must already be obtainable
20322040
* when {@code build()} runs, since the initial handshake pulls it - otherwise that {@code build()} (or,
20332041
* over HTTP, the first row) fails. Running on the send/flush and reconnect paths, the provider must

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,7 +1037,15 @@ private boolean adopt(PersistedToken token) {
10371037
}
10381038
accessToken = token.getAccessToken();
10391039
idToken = token.getIdToken();
1040-
refreshToken = token.getRefreshToken();
1040+
// keep the current refresh token when the file carries none, mirroring storeTokens(). A file with a
1041+
// valid served token but no refresh_token - a cross-language peer that never received one, or a
1042+
// tampered file - must not null a live in-memory refresh token: doing so would make a later
1043+
// tryRefresh() urlEncode(null) and throw an uncaught NPE (aborting the sign-in) instead of refreshing
1044+
// with the token we still hold or degrading to an interactive sign-in.
1045+
String fileRefreshToken = token.getRefreshToken();
1046+
if (fileRefreshToken != null) {
1047+
refreshToken = fileRefreshToken;
1048+
}
10411049
// the file is attacker-writable (and may have been written under a skewed clock), so bound how long
10421050
// the loaded token is trusted exactly as storeTokens() bounds a token from the wire: never past
10431051
// MAX_EXPIRES_IN_SECONDS from now. Clamp the expiry to [0, now + maxLife]: the ceiling stops a tampered
@@ -1054,8 +1062,10 @@ private boolean adopt(PersistedToken token) {
10541062
// half the lifetime); for a legitimate file the two already agree, and the expiry clamp bounds this to
10551063
// [0, maxLife]
10561064
tokenTtlMillis = Math.max(0L, expiresAtMillis - now);
1057-
// it is already on disk, so a later non-rotating refresh must not rewrite the file
1058-
lastPersistedRefreshToken = refreshToken;
1065+
// track what the file actually carried (which is null when it had no refresh_token but we kept a live
1066+
// one above), so a later non-rotating refresh does not rewrite an unchanged on-disk token, yet a token
1067+
// we kept that the file did not carry is not mistaken for already-persisted and can be re-saved
1068+
lastPersistedRefreshToken = fileRefreshToken;
10591069
return true;
10601070
}
10611071

@@ -1463,6 +1473,11 @@ private void throwIfClosed() {
14631473
}
14641474

14651475
private boolean tryRefresh() {
1476+
if (refreshToken == null) {
1477+
// nothing to present: degrade to the interactive flow rather than urlEncode(null) and throw.
1478+
// adopt() keeps a live refresh token, so this only fires if a caller reaches here with none.
1479+
return false;
1480+
}
14661481
formSink.clear();
14671482
formSink.putAscii("grant_type=").putAscii(GRANT_TYPE_REFRESH_TOKEN_ENCODED);
14681483
appendParam(formSink, "refresh_token", refreshToken);

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ private DisplaySafe() {
4242
* Returns {@code true} when {@code cp} can be shown verbatim, {@code false} when it must be escaped or
4343
* stripped. A code point is unsafe if it is a control char (C0/C1, DEL), a Unicode format char (bidi
4444
* embeddings/overrides/isolates, LRM/RLM marks, zero-width joiners, the BOM, supplementary-plane tag
45-
* chars) or a surrogate (a lone half, with no displayable meaning).
45+
* chars), a Unicode line/paragraph separator (U+2028/U+2029, which break a rendered log line), or a
46+
* surrogate (a lone half, with no displayable meaning).
4647
*/
4748
public static boolean isDisplaySafe(int cp) {
4849
// Printable ASCII is the overwhelmingly common case and is never a control, format or surrogate char,
@@ -54,7 +55,12 @@ public static boolean isDisplaySafe(int cp) {
5455
return false;
5556
}
5657
final int type = Character.getType(cp);
57-
if (type == Character.FORMAT || type == Character.SURROGATE) {
58+
// FORMAT covers bidi/zero-width/joiners/BOM/tag chars; SURROGATE a lone half. LINE_SEPARATOR (U+2028)
59+
// and PARAGRAPH_SEPARATOR (U+2029) are Unicode line breaks that split a rendered log line in
60+
// ECMAScript/GUI/JSON log consumers, yet they are neither C0/C1 (isISOControl) nor FORMAT, so catch
61+
// them here rather than let a tampered field forge an apparent extra log line.
62+
if (type == Character.FORMAT || type == Character.SURROGATE
63+
|| type == Character.LINE_SEPARATOR || type == Character.PARAGRAPH_SEPARATOR) {
5864
return false;
5965
}
6066
// The explicit bidi/BOM set is redundant with the FORMAT category on a conformant JDK, but kept as

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,37 @@ public void testRefreshUnderLockAdoptsPeerTokenAndSkipsNetwork() throws Exceptio
310310
});
311311
}
312312

313+
@Test(timeout = 30_000)
314+
public void testRefreshUnderLockKeepsLiveRefreshTokenWhenPeerEntryOmitsIt() throws Exception {
315+
// regression: a peer (or cross-language client, or a tampered file) persists a valid-but-expired served
316+
// token with NO refresh_token while we hold a live refresh token in memory. The coordinated re-read must
317+
// keep our refresh token, not null it - nulling it made tryRefresh() urlEncode(null) and throw an
318+
// uncaught NPE that aborted getToken()/signIn() instead of degrading. With the fix REFRESH-1 is kept and
319+
// the refresh succeeds.
320+
assertMemoryLeak(() -> {
321+
MockOidcServer.Handler handler = (method, path, body) -> {
322+
if (path.startsWith(DEVICE_PATH)) {
323+
return MockOidcServer.json(200, deviceAuthJson());
324+
}
325+
// the token endpoint honours the kept refresh token and returns a fresh access token
326+
return MockOidcServer.json(200, tokenJson("REFRESHED-ACCESS", null, "REFRESH-1", 3600));
327+
};
328+
try (MockOidcServer server = new MockOidcServer(handler)) {
329+
FakeTokenStore fake = new FakeTokenStore();
330+
long now = System.currentTimeMillis();
331+
// our own entry, adopted on load: an expired served token carrying REFRESH-1
332+
fake.stored = new PersistedToken("OLD-ACCESS", null, "REFRESH-1", now - 60_000, 300_000);
333+
// a peer overwrites the file with a valid-but-expired served token and NO refresh_token (the
334+
// frozen on-disk format permits omitting it) while we hold the cross-process lock
335+
fake.peerInstallsOnLock = new PersistedToken("PEER-ACCESS", null, null, now - 60_000, 300_000);
336+
try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
337+
Assert.assertEquals("the live refresh token must be kept and used, not nulled into an NPE",
338+
"REFRESHED-ACCESS", auth.getToken());
339+
}
340+
}
341+
});
342+
}
343+
313344
@Test(timeout = 30_000)
314345
public void testRestartRefreshesExpiredTokenSkippingDeviceFlow() throws Exception {
315346
assertMemoryLeak(() -> {

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ public void testFormatBidiAndBomAreUnsafe() {
7171
}
7272
}
7373

74+
@Test
75+
public void testLineAndParagraphSeparatorsAreUnsafe() {
76+
// U+2028 LINE SEPARATOR (Zl) and U+2029 PARAGRAPH SEPARATOR (Zp) are Unicode line breaks that split a
77+
// rendered log line in ECMAScript/GUI/JSON log consumers, yet are neither ISO control nor Cf format,
78+
// so a tampered field could otherwise forge an apparent extra log line
79+
int[] unsafe = {0x2028, 0x2029};
80+
for (int cp : unsafe) {
81+
String hex = "0x" + Integer.toHexString(cp);
82+
Assert.assertTrue("separator " + hex + " must be unsafe", DisplaySafe.isUnsafeForDisplay(cp));
83+
Assert.assertFalse("separator " + hex + " must be unsafe", DisplaySafe.isDisplaySafe(cp));
84+
}
85+
}
86+
7487
@Test
7588
public void testLoneSurrogatesAreUnsafe() {
7689
// a lone surrogate half has no displayable meaning; the code-point classifier must reject it

0 commit comments

Comments
 (0)