Skip to content

Commit 2ce1ca0

Browse files
glasstigerclaude
andcommitted
Reject OIDC endpoint fragment, harden token lock
Endpoint.parse now rejects an endpoint URL carrying a fragment (#...). pathOnly() stripped the fragment before the issuer-path pin while postForm sent endpoint.path verbatim on the wire, so a "#/../other/token" payload from a tampered /settings could steer the credential POST to a path the pin never validated on a server that normalizes '..'. Rejecting the fragment makes the validated path and the sent path identical. The token-store lock's stamp write and its stamp-failure cleanup now verify ownership the way release and steal already do. writeLockHolder refuses to overwrite a lock a peer stamped while this holder was pre-empted in the create->stamp gap (a long pause or cross-machine clock skew wider than the empty-lock grace), and the cleanup drops only a still-empty lock, never a peer's live one by bare path. The residual degrades to a re-prompt on a rotating refresh token, never a torn or forged credential; the design doc and the overstated grace comment are corrected to say so. The on-disk lock protocol is unchanged. Add end-to-end tests proving the httpTokenProvider token reaches the Authorization: Bearer header on the wire and rotates per request, that a failed flush re-sends the same baked token without re-pulling, and that adopt() re-saves a kept refresh token the file omitted. The endpoint fragment and re-save guards are verified both ways. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a454ac0 commit 2ce1ca0

6 files changed

Lines changed: 157 additions & 15 deletions

File tree

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

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,17 @@ private static boolean writeLockHolder(Path lock, String nonce) {
611611
// failure so acquireLock drops an unverifiable lock rather than hold one it cannot safely release.
612612
// Writing also refreshes the mtime, which is what the staleness age check reads
613613
try {
614+
// acquireLock created this lock empty; if it already carries a stamp, a peer judged it stale and
615+
// stole+restamped it in the create->stamp gap (a long GC/suspend pause between the two syscalls, or
616+
// a cross-machine clock skew wider than the empty-lock grace). A plain WRITE|TRUNCATE_EXISTING has no
617+
// exclusivity and would overwrite the peer's stamp, leaving two processes each believing they hold
618+
// the lock. Refuse instead - honouring releaseLock's own-stamp ownership rule - so acquireLock
619+
// degrades to a lock-free refresh (the documented best-effort residual) rather than clobber a live
620+
// peer's stamp. A readLockHolder that throws (our file was moved away during the peer's steal) is
621+
// caught below and likewise fails the stamp.
622+
if (readLockHolder(lock) != null) {
623+
return false;
624+
}
614625
Files.write(lock, nonce.getBytes(StandardCharsets.UTF_8), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
615626
return true;
616627
} catch (Exception e) {
@@ -632,11 +643,16 @@ private String acquireLock(Path lock) {
632643
}
633644
// the exclusive create won the lock but the owner nonce could not be stamped, so releaseLock
634645
// could not later prove ownership and would risk deleting a peer's lock; drop the file we just
635-
// created and degrade to a lock-free refresh rather than hold an unverifiable lock
646+
// created and degrade to a lock-free refresh rather than hold an unverifiable lock. Remove it
647+
// only while it is still the empty file we created: writeLockHolder also returns false when a
648+
// peer stole and restamped this path in the create->stamp gap, and deleting that peer's non-empty
649+
// live lock by bare path would admit a third holder (mirrors releaseLock's own-stamp rule).
636650
try {
637-
Files.deleteIfExists(lock);
651+
if (Files.size(lock) == 0) {
652+
Files.deleteIfExists(lock);
653+
}
638654
} catch (IOException ignore) {
639-
// another acquirer may have removed it; the next createLockFile settles the race
655+
// gone (a peer moved it during its steal) or unreadable; another acquirer settles the race
640656
}
641657
return null;
642658
} catch (FileAlreadyExistsException e) {
@@ -721,9 +737,14 @@ private void stealIfStale(Path lock) {
721737
// an empty/unreadable lock is never a validly-held lock (a holder stamps right after creating): it
722738
// is a peer mid-create/stamp (recovers on its own in microseconds) or one a crash orphaned in that
723739
// gap. Steal it on the short empty-lock grace rather than the full staleness window, so a crash
724-
// orphan stops wedging peers for the whole window; the grace dwarfs the create->stamp gap, so a
725-
// peer mid-stamp is never pre-empted. The capture-verify below still confirms the lock is unchanged
726-
// before completing the steal.
740+
// orphan stops wedging peers for the whole window. The grace normally dwarfs the create->stamp gap,
741+
// but a pause wider than the grace (a long GC/safepoint or a suspend landing between the two
742+
// syscalls) or a cross-machine clock skew (isOlderThan compares the local clock to the file mtime)
743+
// can still make a freshly-created empty lock look stale and pre-empt a peer mid-stamp. That never
744+
// forges or tears a credential - Layer-1 atomic rename holds, and the pre-empted peer's
745+
// writeLockHolder refuses to overwrite this stamp - it degrades to a concurrent refresh (a re-prompt
746+
// on a rotating-refresh-token IdP), the same best-effort residual inLock already accepts. The
747+
// capture-verify below still confirms the lock is unchanged before completing the steal.
727748
return;
728749
}
729750
final Path captured = lock.resolveSibling(lock.getFileName().toString() + '.' + UUID.randomUUID() + ".tmp");

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1928,6 +1928,14 @@ static Endpoint parse(String url) {
19281928
}
19291929
i += Character.charCount(cp);
19301930
}
1931+
// reject a fragment (#...): it has no meaning in an endpoint url (RFC 3986 fragments are client-side
1932+
// only, never sent to a server), and folding it into the path opens a pin-bypass - pathOnly() strips
1933+
// it before the issuer-path check while postForm sends endpoint.path verbatim on the wire, so a
1934+
// lenient server that normalizes a '..' hidden past the '#' (POST /realms/acme#/../other/token) could
1935+
// resolve the request-target to a path the issuer-path pin never validated. Fail closed instead.
1936+
if (url.indexOf('#') >= 0) {
1937+
throw new OidcAuthException().put("invalid url, a fragment (#) is not supported [url=").put(url).put(']');
1938+
}
19311939
int schemeEnd = url.indexOf("://");
19321940
if (schemeEnd < 0) {
19331941
throw new OidcAuthException().put("invalid url, expected a scheme [url=").put(url).put(']');

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,41 @@ public void testRefreshUnderLockKeepsLiveRefreshTokenWhenPeerEntryOmitsIt() thro
341341
});
342342
}
343343

344+
@Test(timeout = 30_000)
345+
public void testRefreshUnderLockResavesKeptRefreshTokenWhenPeerEntryOmitsIt() throws Exception {
346+
// the other half of the NPE fix: when the coordinated re-read adopts a peer entry that omits the refresh
347+
// token, adopt() keeps the live refresh token AND records that the file carried none
348+
// (lastPersistedRefreshToken=null). The refresh that follows must therefore RE-SAVE the kept token, so a
349+
// restart still finds it on disk. If adopt() instead marked the kept token as already-persisted, the save
350+
// would be skipped and the refresh token would silently vanish from disk, forcing a needless re-prompt.
351+
assertMemoryLeak(() -> {
352+
MockOidcServer.Handler handler = (method, path, body) -> {
353+
if (path.startsWith(DEVICE_PATH)) {
354+
return MockOidcServer.json(200, deviceAuthJson());
355+
}
356+
// non-rotating refresh: the same REFRESH-1 comes back, so ONLY the null-vs-REFRESH-1 lastPersisted
357+
// bookkeeping (not a token change) decides whether persistIfRotated re-saves
358+
return MockOidcServer.json(200, tokenJson("REFRESHED-ACCESS", null, "REFRESH-1", 3600));
359+
};
360+
try (MockOidcServer server = new MockOidcServer(handler)) {
361+
FakeTokenStore fake = new FakeTokenStore();
362+
long now = System.currentTimeMillis();
363+
// our own entry, adopted on load: an expired served token carrying REFRESH-1
364+
fake.stored = new PersistedToken("OLD-ACCESS", null, "REFRESH-1", now - 60_000, 300_000);
365+
// a peer overwrites the file with an expired served token and NO refresh_token while we hold the
366+
// lock; the re-read adopts it, keeps REFRESH-1, and records that the file carried no refresh token
367+
fake.peerInstallsOnLock = new PersistedToken("PEER-ACCESS", null, null, now - 60_000, 300_000);
368+
try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
369+
Assert.assertEquals("REFRESHED-ACCESS", auth.getToken());
370+
}
371+
Assert.assertTrue("the kept refresh token must be re-saved (the file carried none), not skipped as already-persisted",
372+
fake.saves.get() >= 1);
373+
Assert.assertNotNull("the re-saved entry must exist", fake.stored);
374+
Assert.assertEquals("the re-saved entry must carry the kept refresh token", "REFRESH-1", fake.stored.getRefreshToken());
375+
}
376+
});
377+
}
378+
344379
@Test(timeout = 30_000)
345380
public void testRestartRefreshesExpiredTokenSkippingDeviceFlow() throws Exception {
346381
assertMemoryLeak(() -> {

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,12 @@ public void testEndpointParseRejectsMalformedUrls() {
10521052
assertBuildFails("https://idp/devic\r\ne", "https://idp/t", "illegal character");
10531053
assertBuildFails("https://idp/d", "https://idp/toke\r\nX-Injected:1", "illegal character");
10541054
assertBuildFails("https://idp/d", "https://idp/t?a=b\nc", "illegal character");
1055+
// a fragment (#...) is rejected: pathOnly() strips it before the issuer-path pin while postForm sends
1056+
// endpoint.path verbatim on the wire, so folding a "#/../other/token" past the '#' would let a lenient
1057+
// server that normalizes '..' resolve the request-target to a path the pin never validated. Fail closed
1058+
assertBuildFails("https://idp/realms/acme#/../other/device", "https://idp/realms/acme/token", "fragment");
1059+
assertBuildFails("https://idp/d", "https://idp/realms/acme#/../other/token", "fragment");
1060+
assertBuildFails("https://idp/d#", "https://idp/t", "fragment");
10551061
}
10561062

10571063
@Test(timeout = 30_000)

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

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@
2727
import io.questdb.client.HttpTokenProvider;
2828
import io.questdb.client.Sender;
2929
import io.questdb.client.cutlass.line.LineSenderException;
30+
import io.questdb.client.test.cutlass.auth.MockOidcServer;
3031
import org.junit.Assert;
3132
import org.junit.Test;
3233

34+
import java.util.List;
3335
import java.util.concurrent.atomic.AtomicBoolean;
3436
import java.util.concurrent.atomic.AtomicInteger;
3537

@@ -42,10 +44,12 @@
4244
* {@code .httpTokenProvider(auth::getToken)} - be wired before the interactive sign-in
4345
* has completed.
4446
* <p>
45-
* An explicit {@code protocol_version} keeps {@link Sender.LineSenderBuilder#build()} from probing
46-
* the server, and auto-flush is disabled, so rows can be buffered against a port nobody listens on
47-
* without ever opening a connection. Each test runs under {@code assertMemoryLeak} so the sender's
48-
* native buffers are proven freed on close.
47+
* The deferral tests pin an explicit {@code protocol_version} to keep {@link Sender.LineSenderBuilder#build()}
48+
* from probing the server, and disable auto-flush, so rows buffer against a port nobody listens on without
49+
* opening a connection. The end-to-end tests instead flush against a {@link MockOidcServer} and assert the
50+
* pulled token actually reaches the {@code Authorization: Bearer} header on the wire, is re-queried per
51+
* request as a rotating provider refreshes, and is re-sent verbatim (not re-pulled) on a retry. Each test
52+
* runs under {@code assertMemoryLeak} so the sender's native buffers are proven freed on close.
4953
*/
5054
public class LineHttpSenderTokenProviderTest {
5155

@@ -98,6 +102,38 @@ public void testControlOrNonAsciiProviderTokenIsRejected() throws Exception {
98102
});
99103
}
100104

105+
@Test(timeout = 30_000)
106+
public void testFailedFlushReSendsSameTokenWithoutRePull() throws Exception {
107+
assertMemoryLeak(() -> {
108+
// a failed flush preserves the buffered request - token included - and re-sends it verbatim on retry
109+
// rather than re-pulling the provider (the documented contract on httpTokenProvider()). Here the first
110+
// send gets a retryable 500 and the retry must carry the SAME baked token, with the provider queried
111+
// only once - so a rotating provider does not change the credential mid-retry of one buffered batch.
112+
AtomicInteger requests = new AtomicInteger();
113+
try (MockOidcServer server = new MockOidcServer((method, path, body) ->
114+
requests.incrementAndGet() == 1
115+
? MockOidcServer.chunkedJson(500, "boom") // first send: retryable server error
116+
: MockOidcServer.json(204, ""))) { // retry: success
117+
AtomicInteger calls = new AtomicInteger();
118+
HttpTokenProvider provider = () -> "TOKEN-" + calls.incrementAndGet();
119+
try (Sender sender = Sender.builder(Sender.Transport.HTTP)
120+
.address("127.0.0.1:" + server.port())
121+
.protocolVersion(Sender.PROTOCOL_VERSION_V1)
122+
.disableAutoFlush()
123+
.httpTokenProvider(provider)
124+
.build()) {
125+
sender.table("t").longColumn("v", 1L).atNow();
126+
sender.flush(); // first send 500 -> retry -> 204
127+
}
128+
Assert.assertEquals("the provider must be pulled once, not re-pulled on retry", 1, calls.get());
129+
List<String> auth = server.requestAuthHeaders();
130+
Assert.assertEquals("the failed send plus its retry must be two requests", 2, auth.size());
131+
Assert.assertEquals("the first send carries the pulled token", "Bearer TOKEN-1", auth.get(0));
132+
Assert.assertEquals("the retry must re-send the same baked token", "Bearer TOKEN-1", auth.get(1));
133+
}
134+
});
135+
}
136+
101137
@Test
102138
public void testNullOrEmptyProviderTokenIsRejected() throws Exception {
103139
assertMemoryLeak(() -> {
@@ -136,6 +172,34 @@ public void testProviderTokenNotPulledAtBuildAndPulledOnFirstRow() throws Except
136172
});
137173
}
138174

175+
@Test(timeout = 30_000)
176+
public void testTokenReachesAuthorizationHeaderAndRotatesPerFlush() throws Exception {
177+
assertMemoryLeak(() -> {
178+
// end-to-end against a real socket: the pulled token must reach the "Authorization: Bearer" header
179+
// on the wire (not merely be pulled), and a rotating provider must be re-queried per request so a
180+
// long-lived sender follows token refreshes rather than sending a token captured once.
181+
try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.json(204, ""))) {
182+
AtomicInteger calls = new AtomicInteger();
183+
HttpTokenProvider provider = () -> "TOKEN-" + calls.incrementAndGet();
184+
try (Sender sender = Sender.builder(Sender.Transport.HTTP)
185+
.address("127.0.0.1:" + server.port())
186+
.protocolVersion(Sender.PROTOCOL_VERSION_V1)
187+
.disableAutoFlush()
188+
.httpTokenProvider(provider)
189+
.build()) {
190+
sender.table("t").longColumn("v", 1L).atNow();
191+
sender.flush();
192+
sender.table("t").longColumn("v", 2L).atNow();
193+
sender.flush();
194+
}
195+
List<String> auth = server.requestAuthHeaders();
196+
Assert.assertEquals("two flushes must send two requests", 2, auth.size());
197+
Assert.assertEquals("the first request must carry the first pulled token", "Bearer TOKEN-1", auth.get(0));
198+
Assert.assertEquals("the second flush must re-query the provider and carry the rotated token", "Bearer TOKEN-2", auth.get(1));
199+
}
200+
});
201+
}
202+
139203
private static void assertProviderTokenRejected(HttpTokenProvider provider, String expectedMessage) {
140204
try (Sender sender = Sender.builder(Sender.Transport.HTTP)
141205
.address("127.0.0.1:1")

design/oidc-token-persistence.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -395,11 +395,19 @@ re-prompt. To eliminate it, serialise the *read-modify-write* of a refresh per i
395395
between the two leaves an empty `<hex>.lock` whose mtime is fresh — which the staleness check
396396
would protect for the whole window, wedging peers into lock-free refreshes. Treat a lock that
397397
carries no readable owner stamp as stealable once it is older than a short grace (a few
398-
seconds) instead of the full window. The grace MUST comfortably exceed the create→stamp gap
399-
(microseconds) so a peer momentarily between its create and its stamp is never pre-empted —
400-
pre-empting it would steal a lock its rightful owner is about to hold and force that owner to
401-
degrade. The capture-then-verify steal below still aborts if the lock acquires a stamp in the
402-
gap. The Python client MUST mirror this empty-lock grace.
398+
seconds) instead of the full window. The grace normally dwarfs the create→stamp gap
399+
(microseconds), but cannot be guaranteed to: a pause wider than the grace (a long GC/safepoint
400+
or a process suspend landing between the two syscalls) or a cross-machine clock skew (the age
401+
check compares the local clock against the file's mtime) can make a freshly-created empty lock
402+
look stale and let a peer pre-empt a holder mid-stamp. This never forges or tears a credential —
403+
Layer 1's atomic replacement always holds — it degrades to a concurrent refresh (a re-prompt on
404+
a rotating-refresh-token IdP), the same best-effort residual as running lock-free. To keep that
405+
residual bounded, the **stamp write and the stamp-failure cleanup verify ownership** the way
406+
release does: a holder stamps only a lock still empty (never overwriting a stamp a peer wrote
407+
while the holder was pre-empted), and on a stamp failure drops only a lock still empty (never a
408+
peer's non-empty live lock by bare path). The capture-then-verify steal below still aborts if
409+
the lock acquires a stamp in the gap. The Python client MUST mirror this empty-lock grace, and
410+
SHOULD mirror the ownership-verified stamp write.
403411
- **Release verifies ownership.** A holder releases by re-reading the lock and deleting it
404412
**only when it still carries that holder's own owner stamp**, never by bare path. Should
405413
a hold ever outrun the staleness window and be stolen and recreated by a peer, the

0 commit comments

Comments
 (0)