Skip to content

Commit 3250b3e

Browse files
glasstigerclaude
andcommitted
Harden OIDC token validation, lock and issuer-path pin
Three defects in the OIDC token-provider work. The ILP HTTP sender skipped token validation whenever the provider returned the same CharSequence instance it had validated before, on the theory that an instance never changes content. HttpTokenProvider makes no such promise: a provider that reuses one buffer (the idiomatic zero-alloc style) and mutates it in place between flushes had its mutated token spliced verbatim into the Authorization header, which request.authToken writes with no CR/LF filtering - a header-injection bypass of the very check validateToken exists to enforce. The sender now validates every pulled token; the scan is O(token length) and is dwarfed by the flush's network round-trip, matching what the WebSocket auth path already does on every pull. getToken() acquired its lock with the interruptible timed tryLock even on the uncontended fast path. That overload throws InterruptedException the moment the calling thread merely carries a set interrupt flag - even on a free lock - and the handler re-arms the flag, so every later getToken() on that thread failed with a valid token sitting in the cache. ILP producers commonly run on pooled or managed threads where interrupt is the standard cancellation signal. An untimed tryLock now handles the uncontended case; the timed poll remains only for genuine contention behind a peer's silent refresh. The issuer-path pin rejected a "." or ".." path segment but not "..;": a server or proxy that strips RFC 3986 matrix parameters resolves /realms/acme/..;/evil to /realms/evil, a different realm on the same host, redirecting the device code and refresh token to a sibling tenant. The dot-segment check now strips a ";suffix" from each decoded segment before comparing. Each fix gets a regression test proven to fail when the fix is reverted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 82b8bab commit 3250b3e

4 files changed

Lines changed: 138 additions & 29 deletions

File tree

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

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -797,7 +797,16 @@ private static boolean isEndpointUnderIssuerPath(String endpointUrl, String issu
797797
// a "." or ".." segment is rejected outright: the server normalizes it away, so a naive prefix test
798798
// would pass /realms/acme/../evil/token yet it resolves to a different realm
799799
for (int i = 0; i < endpointSegs.length; i++) {
800-
if (".".equals(endpointSegs[i]) || "..".equals(endpointSegs[i])) {
800+
// strip an RFC 3986 ";matrix" parameter suffix before the dot-segment test: a server or proxy that
801+
// drops matrix params resolves "..;" (or "..;x") to "..", so /realms/acme/..;/evil/token would
802+
// otherwise slip the issuer-path pin to a sibling realm. decodePathSegments already percent-decoded,
803+
// so a "%3b"-encoded ";" is a literal ";" here too.
804+
String seg = endpointSegs[i];
805+
int semi = seg.indexOf(';');
806+
if (semi >= 0) {
807+
seg = seg.substring(0, semi);
808+
}
809+
if (".".equals(seg) || "..".equals(seg)) {
801810
return false;
802811
}
803812
}
@@ -1036,15 +1045,25 @@ private static String wellKnownUrl(String issuer) {
10361045
}
10371046

10381047
private void acquireForGetToken() {
1039-
// Never wait behind an interactive signIn(): it holds the lock for the whole device-code lifetime
1040-
// (up to 30 min) with no token to serve until it completes, so fail fast and let the caller retry. A
1041-
// peer holding the lock for a quick cached read or a silent refresh (bounded, usually well under a
1042-
// second) is different - the HttpTokenProvider contract permits a brief wait behind such a refresh - so
1043-
// poll for the lock in short slices rather than fail every concurrent caller sharing this instance on
1044-
// each token refresh (the old unconditional tryLock() did exactly that). Polling, not one blocking
1045-
// acquire, lets us still fail fast the moment an interactive sign-in - or close() - begins while we
1046-
// wait. Bound the total wait by httpTimeoutMillis so a stuck or pathologically slow holder degrades to
1047-
// a retryable failure instead of stalling the flush path without bound.
1048+
throwIfClosed();
1049+
// Uncontended fast path: a plain CAS. It deliberately bypasses the interruptible timed tryLock in the
1050+
// loop below, which throws InterruptedException the moment the calling thread merely carries a set
1051+
// interrupt flag - even on a FREE lock - and then re-arms that flag, so every later getToken() on the
1052+
// same thread would fail with a valid token sitting in the cache. An ILP producer on a pooled or
1053+
// managed thread, where interrupt is the standard cancellation signal, is the common case. An
1054+
// uncontended acquire cannot be behind an interactive sign-in (which holds the lock), so it is correct.
1055+
if (lock.tryLock()) {
1056+
return;
1057+
}
1058+
// Contended - a peer holds the lock. Never wait behind an interactive signIn(): it holds the lock for
1059+
// the whole device-code lifetime (up to 30 min) with no token to serve until it completes, so fail fast
1060+
// and let the caller retry. A peer holding the lock for a quick cached read or a silent refresh
1061+
// (bounded, usually well under a second) is different - the HttpTokenProvider contract permits a brief
1062+
// wait behind such a refresh - so poll for the lock in short slices rather than fail every concurrent
1063+
// caller sharing this instance on each token refresh (the old unconditional tryLock() did exactly that).
1064+
// Polling, not one blocking acquire, lets us still fail fast the moment an interactive sign-in - or
1065+
// close() - begins while we wait. Bound the total wait by httpTimeoutMillis so a stuck or pathologically
1066+
// slow holder degrades to a retryable failure instead of stalling the flush path without bound.
10481067
final long deadline = System.currentTimeMillis() + httpTimeoutMillis;
10491068
while (true) {
10501069
throwIfClosed();

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

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,6 @@ public abstract class AbstractLineHttpSender implements Sender {
9494
private boolean isTokenPending;
9595
private JsonErrorParser jsonErrorParser;
9696
private boolean lastFlushFailed;
97-
// the last provider token instance validated in stampTokenIfPending(); lets an unchanged multi-KB token
98-
// skip re-validation (a full scan) on every flush. Identity only, never dereferenced for content.
99-
private CharSequence lastValidatedToken;
10097
private long pendingRows;
10198
private int rowBookmark;
10299
private RequestState state = RequestState.EMPTY;
@@ -835,19 +832,14 @@ private void stampTokenIfPending() {
835832
// The throwing operations run BEFORE the request is mutated: a getToken()/validateToken() throw
836833
// (not signed in yet, a failed refresh, or a rejected token) leaves isTokenPending set and the
837834
// request untouched at the header stage, so the next row retries cleanly - the sender is never left
838-
// corrupted. validateToken (which scans the whole token) is skipped when the provider returned the
839-
// same instance already validated: an unchanged multi-KB id token is otherwise re-scanned every
840-
// flush. A provider returning the same instance must not mutate its content (HttpTokenProvider
841-
// contract), so the skip cannot let a changed token bypass validation.
835+
// corrupted. Validate EVERY pulled token, not just a changed instance: HttpTokenProvider.getToken()
836+
// makes no immutability promise, so a provider that reuses one CharSequence buffer (the idiomatic
837+
// zero-alloc style) and mutates its content between flushes must be re-checked, or a mutated token
838+
// could splice a CR/LF into the "Authorization: Bearer" header (request.authToken writes it verbatim,
839+
// with no CR/LF filtering). The scan is O(token length) and is dwarfed by the flush's network
840+
// round-trip; the WebSocket auth path validates on every pull for the same reason.
842841
CharSequence token = httpTokenProvider.getToken();
843-
// always validate a null token (it must be rejected, and null is the initial lastValidatedToken
844-
// value); otherwise skip re-validation only for the exact same instance already validated.
845-
// lastValidatedToken is assigned only after a successful validation, so it never holds a rejected
846-
// (null/blank/bad-char) token - a re-returned bad token is therefore re-validated and re-rejected.
847-
if (token == null || token != lastValidatedToken) {
848-
HttpTokenProvider.validateToken(token);
849-
lastValidatedToken = token;
850-
}
842+
HttpTokenProvider.validateToken(token);
851843
request.authToken(token);
852844
request.withContent();
853845
rowBookmark = request.getContentLength();

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1541,6 +1541,38 @@ public void testGetTokenDoesNotBlockBehindInteractiveSignIn() throws Exception {
15411541
});
15421542
}
15431543

1544+
@Test(timeout = 30_000)
1545+
public void testGetTokenSucceedsWhenCallingThreadIsInterrupted() throws Exception {
1546+
assertMemoryLeak(() -> {
1547+
// getToken()'s uncontended lock acquire must NOT fail merely because the calling thread carries a
1548+
// set interrupt flag. An ILP producer on a pooled/managed thread commonly does (interrupt is the
1549+
// standard cancellation signal), and the old timed tryLock threw InterruptedException even on a FREE
1550+
// lock and then re-armed the flag, so every getToken() on that thread failed with a valid token
1551+
// sitting in the cache. The untimed fast-path acquire fixes it.
1552+
MockOidcServer.Handler handler = (method, path, body) -> {
1553+
if (DEVICE_PATH.equals(path)) {
1554+
return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
1555+
}
1556+
return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 3600));
1557+
};
1558+
try (MockOidcServer server = new MockOidcServer(handler);
1559+
OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
1560+
Assert.assertEquals("ACCESS-1", auth.signIn()); // seed a valid cached token
1561+
1562+
Thread.currentThread().interrupt(); // the calling (producer) thread carries a pending interrupt
1563+
try {
1564+
// uncontended lock, valid cached token: getToken() must return it, not throw on the interrupt
1565+
Assert.assertEquals("ACCESS-1", auth.getToken());
1566+
// and it must not silently swallow the caller's interrupt (the untimed acquire preserves it)
1567+
Assert.assertTrue("getToken() must not clear the caller's interrupt flag",
1568+
Thread.currentThread().isInterrupted());
1569+
} finally {
1570+
Thread.interrupted(); // clear so the flag does not leak into later tests sharing this fork
1571+
}
1572+
}
1573+
});
1574+
}
1575+
15441576
@Test(timeout = 30_000)
15451577
public void testGetTokenWaitsBehindSilentRefreshInsteadOfFailing() throws Exception {
15461578
assertMemoryLeak(() -> {
@@ -1971,6 +2003,34 @@ public void testIssuerPathScopingRejectsEncodedTraversal() throws Exception {
19712003
});
19722004
}
19732005

2006+
@Test(timeout = 30_000)
2007+
public void testIssuerPathScopingRejectsMatrixParamTraversal() throws Exception {
2008+
assertMemoryLeak(() -> {
2009+
// the device endpoint hides a parent traversal as an RFC 3986 ";matrix" segment (..;): a server or
2010+
// proxy that strips matrix params resolves /realms/acme/..;/evil to /realms/evil, a DIFFERENT realm.
2011+
// The plain "." / ".." dot-segment check does not match "..;", so the check must strip the ";suffix"
2012+
// first and reject it - the origin pin alone cannot stop a sibling-tenant redirect on one host.
2013+
AtomicReference<MockOidcServer> serverRef = new AtomicReference<>();
2014+
MockOidcServer.Handler handler = (method, path, body) -> {
2015+
MockOidcServer server = serverRef.get();
2016+
return MockOidcServer.json(200, "{\"config\":{"
2017+
+ "\"acl.oidc.enabled\":true,"
2018+
+ "\"acl.oidc.client.id\":\"questdb\","
2019+
+ "\"acl.oidc.token.endpoint\":\"" + server.httpUrl("/realms/acme/token") + "\","
2020+
+ "\"acl.oidc.device.authorization.endpoint\":\"" + server.httpUrl("/realms/acme/..;/evil/device") + "\""
2021+
+ "}}");
2022+
};
2023+
try (MockOidcServer server = new MockOidcServer(handler)) {
2024+
serverRef.set(server);
2025+
try (OidcDeviceAuth ignored = OidcDeviceAuth.fromQuestDB(server.httpUrl(""), insecure().issuer(server.httpUrl("/realms/acme")))) {
2026+
Assert.fail("expected the ..;-matrix-param traversal device endpoint to be rejected");
2027+
} catch (OidcAuthException e) {
2028+
Assert.assertTrue(e.getMessage(), e.getMessage().contains("not under the pinned issuer"));
2029+
}
2030+
}
2031+
});
2032+
}
2033+
19742034
@Test(timeout = 30_000)
19752035
public void testIssuerPathScopingRejectsSiblingRealm() throws Exception {
19762036
assertMemoryLeak(() -> {

core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderTokenProviderTest.java

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,10 @@ public void testCancelRowWithPendingTokenDoesNotCorruptRequest() throws Exceptio
133133
@Test(timeout = 30_000)
134134
public void testChangedProviderTokenIsRevalidated() throws Exception {
135135
assertMemoryLeak(() -> {
136-
// the per-flush token validation is skipped only for the SAME instance already validated, so a
137-
// token that CHANGES to a bad one must still be re-validated and rejected - the identity guard must
138-
// not cache a previously-valid result past a token change. First flush a valid token, then return a
139-
// CR/LF token and require the next flush to reject it rather than splice it onto the wire.
136+
// every pulled token is validated per flush, so a token that CHANGES to a bad one must be rejected.
137+
// First flush a valid token, then return a distinct CR/LF token and require the next flush to reject
138+
// it rather than splice it onto the wire. (The same-instance-mutated case - a reused buffer whose
139+
// content changes - is covered by testMutatedSameInstanceProviderTokenIsRevalidated.)
140140
AtomicInteger calls = new AtomicInteger();
141141
try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.json(204, ""))) {
142142
HttpTokenProvider provider = () -> calls.incrementAndGet() == 1
@@ -211,6 +211,44 @@ public void testFailedFlushReSendsSameTokenWithoutRePull() throws Exception {
211211
});
212212
}
213213

214+
@Test(timeout = 30_000)
215+
public void testMutatedSameInstanceProviderTokenIsRevalidated() throws Exception {
216+
assertMemoryLeak(() -> {
217+
// A provider may reuse one CharSequence buffer (the idiomatic zero-alloc style) and return the SAME
218+
// instance every call. HttpTokenProvider.getToken() makes no immutability promise, so the sender
219+
// must re-validate EVERY pulled token, not trust instance identity: a token mutated in place to
220+
// carry a CR/LF between flushes must be rejected, not spliced verbatim into the "Authorization:
221+
// Bearer" header (authToken writes it with no CR/LF filtering). This pins the fix that dropped the
222+
// identity-cache skip; before it, the second flush injected a header past the auth line.
223+
StringBuilder token = new StringBuilder("GOODTOKEN"); // one instance, mutated in place below
224+
try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.json(204, ""))) {
225+
try (Sender sender = Sender.builder(Sender.Transport.HTTP)
226+
.address("127.0.0.1:" + server.port())
227+
.protocolVersion(Sender.PROTOCOL_VERSION_V1)
228+
.disableAutoFlush()
229+
.httpTokenProvider(() -> token) // always the SAME instance
230+
.build()) {
231+
sender.table("t").longColumn("v", 1L).atNow();
232+
sender.flush(); // first flush: GOODTOKEN validated and sent
233+
// mutate the SAME instance to inject a CR/LF header break
234+
token.setLength(0);
235+
token.append("abc").append((char) 0x0d).append((char) 0x0a).append("X-Injected: pwned");
236+
try {
237+
sender.table("t").longColumn("v", 2L).atNow();
238+
sender.flush();
239+
Assert.fail("a mutated same-instance token carrying CR/LF must be re-validated and rejected");
240+
} catch (LineSenderException e) {
241+
Assert.assertTrue(e.getMessage(), e.getMessage().contains("control or non-ASCII character"));
242+
}
243+
}
244+
// only the first (valid) flush reached the wire; the injected token was rejected before any send
245+
List<String> auth = server.requestAuthHeaders();
246+
Assert.assertEquals("only the valid first flush must reach the server", 1, auth.size());
247+
Assert.assertEquals("Bearer GOODTOKEN", auth.get(0));
248+
}
249+
});
250+
}
251+
214252
@Test
215253
public void testNullOrEmptyProviderTokenIsRejected() throws Exception {
216254
assertMemoryLeak(() -> {

0 commit comments

Comments
 (0)