Skip to content

Commit 9be7c28

Browse files
glasstigerclaude
andcommitted
Harden token-store load and key validation
FileTokenStore.load read the file with Files.readAllBytes after a separate Files.size check. Because the file is attacker-writable, a concurrent grow between the two would make readAllBytes allocate gigabytes and throw OutOfMemoryError - an Error that the best-effort RuntimeException guard in OidcDeviceAuth.maybeLoadFromStore does not catch, so a bad file aborted sign-in instead of degrading. A new readBounded reads through a FileChannel into a buffer capped at the reported size (already bounded by MAX_FILE_BYTES) plus one byte, so a file that grew past its reported size is rejected, never allocated. TokenFileParser tracked only object depth, so an array-wrapped object ([ {..} ]) extracted its fields through the depth gate. The parser now marks any array as malformed and parseAndVerify rejects a shape that is not a single flat JSON object, keeping the on-disk format strict. TokenStoreKey now rejects a null clientId, tokenEndpoint, deviceAuthorizationEndpoint or scope with a clear OidcAuthException instead of surfacing a raw NullPointerException deep in a store's serialize/fingerprint path, and normalises an empty audience to null so getAudience(), hash() and the save/load round-trip agree that an absent audience is null (an empty audience previously broke its own round-trip). parseAndVerify now bulk-copies the file bytes into native memory via a new DirectUtf8Sink.put(byte[], int, int) instead of a byte-by-byte loop. Add tests for the array-wrapped reject, the empty-audience normalisation, and the null-required-field rejection; each was proven to fail with its fix reverted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 71e01f2 commit 9be7c28

4 files changed

Lines changed: 150 additions & 17 deletions

File tree

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

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -228,20 +228,15 @@ public PersistedToken load(TokenStoreKey key) {
228228
Path file = tokenFile(key);
229229
byte[] bytes;
230230
try {
231-
if (!Files.exists(file)) {
232-
return null;
233-
}
234-
long size = Files.size(file);
235-
if (size <= 0 || size > MAX_FILE_BYTES) {
236-
// an empty or implausibly large file is not a usable entry; ignore it rather than read it
237-
return null;
238-
}
239-
bytes = Files.readAllBytes(file);
231+
bytes = readBounded(file);
240232
} catch (NoSuchFileException e) {
241233
return null;
242234
} catch (IOException e) {
243235
throw new OidcAuthException(e).put("could not read the OIDC token store file");
244236
}
237+
if (bytes == null) {
238+
return null;
239+
}
245240
return parseAndVerify(key, bytes);
246241
}
247242

@@ -313,10 +308,8 @@ private static PersistedToken parseAndVerify(TokenStoreKey key, byte[] bytes) {
313308
TokenFileParser parser = new TokenFileParser();
314309
try (DirectUtf8Sink mem = new DirectUtf8Sink(bytes.length);
315310
JsonLexer lexer = new JsonLexer(JSON_LEXER_CACHE_SIZE, JSON_LEXER_MAX_VALUE_BYTES)) {
316-
for (int i = 0; i < bytes.length; i++) {
317-
// putAny accepts any byte; put(byte) is asserted for non-ASCII bytes only
318-
mem.putAny(bytes[i]);
319-
}
311+
// bulk-copy the file bytes into native memory in one go rather than byte by byte
312+
mem.put(bytes, 0, bytes.length);
320313
long lo = mem.ptr();
321314
lexer.parse(lo, lo + mem.size(), parser);
322315
lexer.parseLast(); // reject a truncated document
@@ -325,8 +318,9 @@ private static PersistedToken parseAndVerify(TokenStoreKey key, byte[] bytes) {
325318
return null;
326319
}
327320
// schema and fingerprint must match the live identity; a mismatch is a hash collision or a file
328-
// copied from a different identity, so ignore it rather than serve the wrong identity's token
329-
if (parser.version != SCHEMA_VERSION) {
321+
// copied from a different identity, so ignore it rather than serve the wrong identity's token. A
322+
// malformed shape (an array anywhere - the schema is a single flat object) is likewise rejected.
323+
if (parser.malformed || parser.version != SCHEMA_VERSION) {
330324
return null;
331325
}
332326
if (!Chars.equals(key.getClientId(), parser.clientId)
@@ -411,6 +405,35 @@ private static void putStringMember(StringSink sink, String name, CharSequence v
411405
putString(sink, value);
412406
}
413407

408+
private static byte[] readBounded(Path file) throws IOException {
409+
// read with a hard cap instead of Files.readAllBytes after a separate Files.size: the file is
410+
// attacker-writable, so a size-check-then-read races a concurrent grow - a file enlarged past the cap
411+
// between the two would make readAllBytes allocate gigabytes and throw OutOfMemoryError (an Error, which
412+
// the best-effort RuntimeException guard in OidcDeviceAuth.maybeLoadFromStore would not catch, so a bad
413+
// file would abort sign-in instead of degrading). Cap the buffer at the reported size (already bounded
414+
// by MAX_FILE_BYTES) plus one byte, so a file that grew past its reported size is rejected, not allocated.
415+
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) {
416+
long size = channel.size();
417+
if (size <= 0 || size > MAX_FILE_BYTES) {
418+
// an empty or implausibly large file is not a usable entry; ignore it rather than read it in
419+
return null;
420+
}
421+
ByteBuffer buffer = ByteBuffer.allocate((int) size + 1);
422+
while (buffer.hasRemaining() && channel.read(buffer) >= 0) {
423+
// read until EOF or the (size + 1)-byte buffer fills
424+
}
425+
int read = buffer.position();
426+
if (read == 0 || read > size) {
427+
// empty, or grew past its reported size between the stat and the read: treat as corrupt/hostile
428+
return null;
429+
}
430+
byte[] bytes = new byte[read];
431+
buffer.flip();
432+
buffer.get(bytes);
433+
return bytes;
434+
}
435+
}
436+
414437
private static void releaseLock(Path lock, String nonce) {
415438
// release our own lock only: re-read it and delete it solely when it still carries our nonce. A hold
416439
// that outran lockStaleMillis may have been judged stale and stolen (deleted and recreated) by a peer;
@@ -623,10 +646,17 @@ private static final class TokenFileParser implements JsonParser {
623646
int version;
624647
private int depth;
625648
private int field = FIELD_NONE;
649+
private boolean malformed;
626650

627651
@Override
628652
public void onEvent(int code, CharSequence tag, int position) {
629653
switch (code) {
654+
case JsonLexer.EVT_ARRAY_START:
655+
// the on-disk schema is a single flat JSON object; an array anywhere (for example a
656+
// top-level [ {..} ] wrapper) is a malformed or hostile shape - mark the document invalid
657+
// rather than extract fields from it through the object-depth gate
658+
malformed = true;
659+
break;
630660
case JsonLexer.EVT_OBJ_START:
631661
depth++;
632662
break;

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,24 @@ 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
75+
// the identity fields are required; reject a null up front with a clear error rather than letting it
76+
// surface later as a raw NullPointerException deep inside a TokenStore's serialize/fingerprint path
77+
if (clientId == null || tokenEndpoint == null || deviceAuthorizationEndpoint == null || scope == null) {
78+
throw new OidcAuthException(
79+
"clientId, tokenEndpoint, deviceAuthorizationEndpoint and scope are required for a token store key");
80+
}
7381
this.clientId = clientId;
7482
this.tokenEndpoint = tokenEndpoint;
7583
this.deviceAuthorizationEndpoint = deviceAuthorizationEndpoint;
7684
this.scope = scope;
77-
this.audience = audience;
85+
// normalise an empty audience to null so getAudience(), hash() (which already folds null and "" together
86+
// via nullToEmpty), and a TokenStore's save/load round-trip all agree that an absent audience is null -
87+
// matching how OidcDeviceAuth builds the key
88+
this.audience = audience != null && !audience.isEmpty() ? audience : null;
7889
this.groupsInToken = groupsInToken;
79-
this.hash = computeHash(clientId, tokenEndpoint, deviceAuthorizationEndpoint, scope, audience, groupsInToken);
90+
this.hash = computeHash(clientId, tokenEndpoint, deviceAuthorizationEndpoint, scope, this.audience, groupsInToken);
8091
}
8192

8293
public String getAudience() {

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
package io.questdb.client.std.str;
2626

2727
import io.questdb.client.std.MemoryTag;
28+
import io.questdb.client.std.Unsafe;
2829
import io.questdb.client.std.bytes.DirectByteSink;
2930
import io.questdb.client.std.bytes.NativeByteSink;
3031
import org.jetbrains.annotations.NotNull;
@@ -103,6 +104,22 @@ public DirectUtf8Sink put(byte b) {
103104
return this;
104105
}
105106

107+
/**
108+
* Appends the bytes of {@code src} in {@code [lo, hi)} verbatim in a single bulk copy, rather than byte by
109+
* byte. The ascii hint is set to {@code false} conservatively (the bytes are treated as opaque), so callers
110+
* that rely on {@link #isAscii()} should not use this overload for ascii-only content.
111+
*/
112+
public DirectUtf8Sink put(byte[] src, int lo, int hi) {
113+
final int len = hi - lo;
114+
if (len > 0) {
115+
setAscii(false);
116+
final long dest = sink.ensureCapacity(len);
117+
Unsafe.getUnsafe().copyMemory(src, Unsafe.BYTE_OFFSET + lo, null, dest, len);
118+
sink.advance(len);
119+
}
120+
return this;
121+
}
122+
106123
@Override
107124
public DirectUtf8Sink putAny(byte b) {
108125
setAscii(isAscii() & b >= 0);

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,27 @@ public void testAdvancedConstructorRejectsOverCapAcquireBudget() throws Exceptio
103103
});
104104
}
105105

106+
@Test
107+
public void testArrayWrappedJsonReturnsNull() throws Exception {
108+
assertMemoryLeak(() -> {
109+
Path dir = storeDir();
110+
FileTokenStore store = new FileTokenStore(dir);
111+
TokenStoreKey key = sampleKey();
112+
// a valid entry, then the same object wrapped in a top-level array. The wrapper leaves the
113+
// fingerprint fields untouched, so only the non-object-root rejection - not a fingerprint mismatch -
114+
// can reject it: the parser must refuse a shape that is not a single flat JSON object
115+
store.save(key, sampleToken("ACCESS-1", "REFRESH-1"));
116+
Assert.assertNotNull("the plain object must load", store.load(key));
117+
byte[] obj = Files.readAllBytes(tokenFile(dir, key));
118+
byte[] wrapped = new byte[obj.length + 2];
119+
wrapped[0] = '[';
120+
System.arraycopy(obj, 0, wrapped, 1, obj.length);
121+
wrapped[wrapped.length - 1] = ']';
122+
Files.write(tokenFile(dir, key), wrapped);
123+
Assert.assertNull("an array-wrapped object must be rejected as a malformed shape", store.load(key));
124+
});
125+
}
126+
106127
@Test
107128
public void testAudienceNullVersusEmptyFingerprint() throws Exception {
108129
assertMemoryLeak(() -> {
@@ -181,6 +202,28 @@ public void testCorruptFileReturnsNull() throws Exception {
181202
});
182203
}
183204

205+
@Test
206+
public void testEmptyAudienceNormalizesToNull() throws Exception {
207+
assertMemoryLeak(() -> {
208+
Path dir = storeDir();
209+
FileTokenStore store = new FileTokenStore(dir);
210+
// an empty-string audience is normalised to null: getAudience() reports null, it shares the
211+
// null-audience identity hash/file, and its save->load round-trips (the pre-fix "" broke its own
212+
// round-trip because the writer recorded "audience":"" but the fingerprint treated it as absent)
213+
TokenStoreKey emptyAud = new TokenStoreKey("questdb", "https://idp.example.com:443/token",
214+
"https://idp.example.com:443/device", "openid", "", false);
215+
TokenStoreKey nullAud = new TokenStoreKey("questdb", "https://idp.example.com:443/token",
216+
"https://idp.example.com:443/device", "openid", null, false);
217+
Assert.assertNull("an empty audience must normalise to null", emptyAud.getAudience());
218+
Assert.assertEquals("null and empty audiences must share one identity hash", nullAud.hash(), emptyAud.hash());
219+
220+
store.save(emptyAud, sampleToken("ACCESS-1", "REFRESH-1"));
221+
PersistedToken loaded = store.load(emptyAud);
222+
Assert.assertNotNull("an empty-audience key must load the entry it just saved", loaded);
223+
Assert.assertEquals("ACCESS-1", loaded.getAccessToken());
224+
});
225+
}
226+
184227
@Test
185228
public void testEmptyFileReturnsNull() throws Exception {
186229
assertMemoryLeak(() -> {
@@ -666,6 +709,38 @@ public void testSpecialCharactersAndNullsRoundTrip() throws Exception {
666709
});
667710
}
668711

712+
@Test
713+
public void testTokenStoreKeyRejectsNullRequiredFields() throws Exception {
714+
assertMemoryLeak(() -> {
715+
// the identity fields are required; a null must fail fast with a clear OidcAuthException rather than
716+
// surface later as a raw NullPointerException inside save()/load() (audience stays optional)
717+
try {
718+
new TokenStoreKey(null, "https://idp/token", "https://idp/device", "openid", null, false);
719+
Assert.fail("a null clientId must be rejected");
720+
} catch (OidcAuthException expected) {
721+
// required identity field
722+
}
723+
try {
724+
new TokenStoreKey("questdb", null, "https://idp/device", "openid", null, false);
725+
Assert.fail("a null tokenEndpoint must be rejected");
726+
} catch (OidcAuthException expected) {
727+
// required identity field
728+
}
729+
try {
730+
new TokenStoreKey("questdb", "https://idp/token", null, "openid", null, false);
731+
Assert.fail("a null deviceAuthorizationEndpoint must be rejected");
732+
} catch (OidcAuthException expected) {
733+
// required identity field
734+
}
735+
try {
736+
new TokenStoreKey("questdb", "https://idp/token", "https://idp/device", null, null, false);
737+
Assert.fail("a null scope must be rejected");
738+
} catch (OidcAuthException expected) {
739+
// required identity field
740+
}
741+
});
742+
}
743+
669744
@Test
670745
public void testTruncatedJsonReturnsNull() throws Exception {
671746
assertMemoryLeak(() -> {

0 commit comments

Comments
 (0)