Skip to content

Commit b016dd0

Browse files
glasstigerclaude
andcommitted
Harden OIDC token-store lock steal and release
acquireLock stole an abandoned lock by deleting it by bare path: two acquirers contending over one stale lock could both delete it and a third could delete a peer's freshly created live lock, admitting two holders into the read-refresh-write at once. stealIfStale now captures the lock with an atomic rename to a unique name (so exactly one stealer wins), verifies the captured owner stamp matches the one it judged stale, and on a mismatch restores the peer's lock with a non-clobbering move rather than stealing it. This mirrors releaseLock's own-stamp check and shrinks the race from the whole isStale->delete gap to the gap between two renames. releaseLock read the lock file with an unbounded Files.readAllBytes. The lock file shares the attacker-writable token-store directory, so an inflated lock could throw OutOfMemoryError - an Error the best-effort RuntimeException guards on the getToken()/signIn() path do not catch, aborting the sign-in. readLockHolder now reads it with the same hard cap readBounded applies to the token file and treats an oversized lock as unreadable. Add a concurrent steal-contention test asserting mutual exclusion and no capture-temp leak, and a test that an oversized stale lock is stolen rather than wedging acquisition. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8469be9 commit b016dd0

2 files changed

Lines changed: 212 additions & 20 deletions

File tree

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

Lines changed: 114 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
import java.nio.file.attribute.FileTime;
5353
import java.nio.file.attribute.PosixFilePermission;
5454
import java.nio.file.attribute.PosixFilePermissions;
55+
import java.util.Arrays;
5556
import java.util.Set;
5657
import java.util.UUID;
5758

@@ -113,6 +114,10 @@ public final class FileTokenStore implements TokenStore {
113114
// to a lock-free refresh (Layer 1 still guards integrity). Stays well below DEFAULT_LOCK_STALE_MILLIS so a
114115
// waiter degrades long before it could begin stealing live locks.
115116
private static final long MAX_LOCK_ACQUIRE_BUDGET_MILLIS = 30_000L;
117+
// reject a lock file larger than this before reading it: the <hash>.lock file sits in the same
118+
// attacker-writable directory as the token file, and a real owner stamp (pid@host + millis + UUID) is a
119+
// few hundred bytes, so anything past this cap is corrupt or hostile and is not read into memory
120+
private static final int MAX_LOCK_FILE_BYTES = 1 << 12;
116121
private static final int SCHEMA_VERSION = 1;
117122
// set once if the platform cannot enforce owner-only POSIX permissions on the token files (e.g. Windows),
118123
// so the at-rest protection falls back to the directory's inherited ACL; warns the user once
@@ -295,6 +300,16 @@ private static void createLockFile(Path lock) throws IOException {
295300
}
296301
}
297302

303+
private static void deleteCapturedLock(Path captured) {
304+
// best-effort cleanup of a lock we atomically captured during a steal; a leftover .tmp is reclaimed by
305+
// sweepStaleTempFiles on a later save
306+
try {
307+
Files.deleteIfExists(captured);
308+
} catch (IOException ignore) {
309+
// reclaimed by sweepStaleTempFiles later
310+
}
311+
}
312+
298313
private static String newLockNonce() {
299314
// a per-acquisition owner stamp: the pid@host and the acquire time are human-readable debugging aids,
300315
// and the random UUID guarantees two acquisitions never share a stamp even within one pid and one
@@ -453,20 +468,49 @@ private static byte[] readBounded(Path file) throws IOException {
453468
}
454469
}
455470

471+
private static byte[] readLockHolder(Path lock) throws IOException {
472+
// read the lock's owner stamp with a hard cap rather than Files.readAllBytes: the <hash>.lock file
473+
// sits in the same attacker-writable directory as the token file, so an inflated lock would otherwise
474+
// make readAllBytes allocate without bound and throw OutOfMemoryError - an Error the best-effort
475+
// RuntimeException guards on the getToken()/signIn() refresh path would not catch, aborting the
476+
// sign-in (the same reason readBounded caps the token file). A real owner stamp is a few hundred
477+
// bytes; anything past the cap is corrupt or hostile, so report it as unreadable (null) rather than
478+
// read it into memory. Returns the exact bytes present, or null for an empty/oversized lock.
479+
try (FileChannel channel = FileChannel.open(lock, StandardOpenOption.READ)) {
480+
long size = channel.size();
481+
if (size <= 0 || size > MAX_LOCK_FILE_BYTES) {
482+
return null;
483+
}
484+
ByteBuffer buffer = ByteBuffer.allocate((int) size);
485+
while (buffer.hasRemaining() && channel.read(buffer) >= 0) {
486+
// read until EOF or the buffer fills
487+
}
488+
int read = buffer.position();
489+
if (read == 0) {
490+
return null;
491+
}
492+
byte[] bytes = new byte[read];
493+
buffer.flip();
494+
buffer.get(bytes);
495+
return bytes;
496+
}
497+
}
498+
456499
private static void releaseLock(Path lock, String nonce) {
457-
// release our own lock only: re-read it and delete it solely when it still carries our nonce. A hold
458-
// that outran lockStaleMillis may have been judged stale and stolen (deleted and recreated) by a peer;
459-
// deleting by bare path would then remove the peer's live lock and admit a third acquirer alongside it,
460-
// defeating the mutual exclusion this lock exists to provide. A microscopic window remains if a steal
461-
// lands between the read and the delete, but that is bounded to one syscall gap rather than the whole
462-
// hold, so a misconfigured staleness window degrades to at most the documented double-refresh rather
463-
// than corrupting a peer's lock state.
500+
// release our own lock only: re-read it (bounded - see readLockHolder) and delete it solely when it
501+
// still carries our nonce. A hold that outran lockStaleMillis may have been judged stale and stolen
502+
// (captured and recreated) by a peer; deleting by bare path would then remove the peer's live lock and
503+
// admit a third acquirer alongside it, defeating the mutual exclusion this lock exists to provide. A
504+
// microscopic window remains if a steal lands between the read and the delete, but that is bounded to
505+
// one syscall gap rather than the whole hold, so a misconfigured staleness window degrades to at most
506+
// the documented double-refresh rather than corrupting a peer's lock state.
464507
try {
465-
byte[] content = Files.readAllBytes(lock);
466-
if (nonce.equals(new String(content, StandardCharsets.UTF_8))) {
508+
byte[] content = readLockHolder(lock);
509+
if (content != null && nonce.equals(new String(content, StandardCharsets.UTF_8))) {
467510
Files.deleteIfExists(lock);
468511
}
469-
// otherwise a peer now owns this lock file; leave it for that owner (or staleness) to reclaim
512+
// otherwise a peer now owns this lock file, or it is unreadable/oversized; leave it for that owner
513+
// (or the staleness steal) to reclaim
470514
} catch (NoSuchFileException e) {
471515
// already gone (stolen and not yet recreated, or removed elsewhere); nothing to release
472516
} catch (IOException ignore) {
@@ -578,16 +622,11 @@ private String acquireLock(Path lock) {
578622
}
579623
return null;
580624
} catch (FileAlreadyExistsException e) {
581-
if (isStale(lock)) {
582-
// a crashed holder left the lock behind; steal it
583-
try {
584-
Files.deleteIfExists(lock);
585-
} catch (IOException ignore) {
586-
// another acquirer may have removed it; the next createLockFile settles the race
587-
}
588-
// fall through to the bounded wait below rather than retry immediately: a steal contest
589-
// between several acquirers (or a misconfigured tiny lockStaleMillis) must not hot-spin
590-
}
625+
// the lock exists; if a crashed holder abandoned it, steal it - atomically and stamp-verified,
626+
// so a stealer never removes a peer's freshly-created live lock (see stealIfStale). Then fall
627+
// through to the bounded wait below rather than retry immediately: a steal contest between
628+
// several acquirers (or a misconfigured tiny lockStaleMillis) must not hot-spin.
629+
stealIfStale(lock);
591630
if (System.currentTimeMillis() >= deadline) {
592631
return null; // give up and run without the lock rather than stall a sign-in
593632
}
@@ -637,6 +676,61 @@ private Path lockFile(TokenStoreKey key) {
637676
return directory.resolve(key.hash() + ".lock");
638677
}
639678

679+
private void stealIfStale(Path lock) {
680+
// Steal a lock abandoned by a crashed holder, but never remove a peer's freshly-created LIVE lock. A
681+
// bare deleteIfExists(lock) removes whatever sits at the path at that instant - including a fresh lock
682+
// a peer created in the gap since we judged the old one stale - and would admit two holders at once.
683+
// Instead: read the current owner stamp, confirm the lock is stale, then capture it atomically into a
684+
// private name (rename is atomic, so among racing stealers exactly one captures it; the losers get
685+
// NoSuchFileException and fall back to the wait), then verify what we captured carries the same stamp
686+
// we judged stale. If a peer had already replaced it with a live lock we grabbed that instead, so we
687+
// put it back rather than steal it. This mirrors releaseLock's own-stamp check and shrinks the
688+
// residual race from the whole isStale->delete gap to the gap between the two renames.
689+
final byte[] before;
690+
try {
691+
before = readLockHolder(lock);
692+
} catch (IOException e) {
693+
return; // gone or unreadable; nothing to steal here - the create attempt or a peer settles it
694+
}
695+
// read the stamp first, then check staleness: if a peer replaces the stale lock with a fresh one in
696+
// between, isStale reads the fresh mtime and returns false, so we never proceed against a live lock
697+
if (!isStale(lock)) {
698+
return;
699+
}
700+
final Path captured = lock.resolveSibling(lock.getFileName().toString() + '.' + UUID.randomUUID() + ".tmp");
701+
try {
702+
Files.move(lock, captured, StandardCopyOption.ATOMIC_MOVE);
703+
} catch (IOException e) {
704+
// NoSuchFile: a peer already stole/removed it; AtomicMoveNotSupported or other IO: degrade and
705+
// leave the lock for the staleness path. Either way we have not removed a peer's live lock.
706+
return;
707+
}
708+
byte[] after = null;
709+
try {
710+
after = readLockHolder(captured);
711+
} catch (IOException ignore) {
712+
// captured but cannot re-read; treated as a non-match below, so we restore rather than steal
713+
}
714+
// confirm we captured the same stamp we judged stale (or the same unreadable/empty junk), not a live
715+
// lock a peer recreated in the gap
716+
final boolean confirmedStale = before == null ? after == null : Arrays.equals(before, after);
717+
if (confirmedStale) {
718+
// genuinely the abandoned lock: drop it, so the next createLockFile can claim a fresh one
719+
deleteCapturedLock(captured);
720+
return;
721+
}
722+
// we captured a live lock a peer recreated in the gap: put it back rather than steal it. Use a plain
723+
// move (not ATOMIC_MOVE, which maps to rename(2) and would replace the target): if a third party
724+
// claimed the now-free path during our capture window, FileAlreadyExistsException leaves their lock
725+
// intact and we drop our captured copy rather than clobber it; a stray .tmp is reclaimed by
726+
// sweepStaleTempFiles on a later save.
727+
try {
728+
Files.move(captured, lock);
729+
} catch (IOException e) {
730+
deleteCapturedLock(captured);
731+
}
732+
}
733+
640734
private void sweepStaleTempFiles(String hashPrefix) {
641735
// a crash between createTempFile and the atomic rename orphans a <hash>*.tmp holding a
642736
// valid-at-the-time refresh token; unlike the lock file nothing ever steals it, so it would accumulate

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,13 @@
3838

3939
import java.io.File;
4040
import java.nio.charset.StandardCharsets;
41+
import java.nio.file.DirectoryStream;
4142
import java.nio.file.FileSystems;
4243
import java.nio.file.Files;
4344
import java.nio.file.Path;
4445
import java.nio.file.attribute.FileTime;
4546
import java.nio.file.attribute.PosixFilePermissions;
47+
import java.util.Arrays;
4648
import java.util.concurrent.atomic.AtomicBoolean;
4749
import java.util.concurrent.atomic.AtomicInteger;
4850

@@ -182,6 +184,62 @@ public void testClearOnEmptyStoreIsNoOp() throws Exception {
182184
});
183185
}
184186

187+
@Test
188+
public void testConcurrentStealContentionPreservesMutualExclusion() throws Exception {
189+
assertMemoryLeak(() -> {
190+
Path dir = storeDir();
191+
Files.createDirectories(dir);
192+
TokenStoreKey key = sampleKey();
193+
// a lock abandoned by a crashed holder, backdated well past the staleness window
194+
Path lock = lockFile(dir, key);
195+
Files.write(lock, "crashed-holder-stamp".getBytes(StandardCharsets.UTF_8));
196+
Files.setLastModifiedTime(lock, FileTime.fromMillis(System.currentTimeMillis() - 600_000));
197+
198+
// several "processes" race to steal the one abandoned lock. The staleness window (60s) far exceeds
199+
// each ~100ms hold, so a live holder's lock is never judged stale: only the abandoned lock is
200+
// stealable, and the atomic, stamp-verified steal must admit exactly one holder at a time. This is a
201+
// mutual-exclusion invariant guard under steal contention: it exercises the steal path concurrently
202+
// and fails on any gross loss of exclusion. It does not deterministically reproduce the narrow
203+
// isStale->delete race the atomic steal closes - that needs a timing seam this final class does not
204+
// expose - so the fix itself rests on the atomic capture + stamp re-check, not on this test alone.
205+
final int threads = 4;
206+
AtomicInteger inside = new AtomicInteger();
207+
AtomicInteger maxInside = new AtomicInteger();
208+
AtomicInteger overlaps = new AtomicInteger();
209+
AtomicInteger ran = new AtomicInteger();
210+
TokenStore.CriticalSection section = () -> {
211+
int now = inside.incrementAndGet();
212+
maxInside.accumulateAndGet(now, Math::max);
213+
if (now > 1) {
214+
overlaps.incrementAndGet();
215+
}
216+
Os.sleep(100);
217+
inside.decrementAndGet();
218+
ran.incrementAndGet();
219+
return true;
220+
};
221+
222+
Thread[] ts = new Thread[threads];
223+
for (int i = 0; i < threads; i++) {
224+
// a generous acquire budget so a contender waits for the lock rather than degrading to a
225+
// lock-free run (a degraded action runs without the lock and could legitimately overlap)
226+
FileTokenStore store = new FileTokenStore(dir, 30_000, 60_000);
227+
ts[i] = new Thread(() -> store.inLock(key, section));
228+
}
229+
for (Thread t : ts) {
230+
t.start();
231+
}
232+
for (Thread t : ts) {
233+
t.join();
234+
}
235+
236+
Assert.assertEquals("every contender must run its critical section", threads, ran.get());
237+
Assert.assertEquals("the steal must never admit two holders at once", 0, overlaps.get());
238+
Assert.assertEquals("at most one holder at a time", 1, maxInside.get());
239+
assertNoCaptureTempFiles(dir, key);
240+
});
241+
}
242+
185243
@Test
186244
public void testControlCharactersRoundTrip() throws Exception {
187245
assertMemoryLeak(() -> {
@@ -580,6 +638,36 @@ public void testOversizedFileReturnsNull() throws Exception {
580638
});
581639
}
582640

641+
@Test
642+
public void testOversizedStaleLockIsStolen() throws Exception {
643+
assertMemoryLeak(() -> {
644+
Path dir = storeDir();
645+
Files.createDirectories(dir);
646+
FileTokenStore store = new FileTokenStore(dir, 2000, 100);
647+
TokenStoreKey key = sampleKey();
648+
Path lock = lockFile(dir, key);
649+
// a corrupt/hostile lock far larger than the read cap, backdated past the staleness window. The
650+
// steal reads the owner stamp with a hard cap (not Files.readAllBytes, which on an attacker-grown
651+
// lock could OutOfMemoryError on the refresh path): a bounded read reports an oversized lock as
652+
// unreadable, which the steal treats as abandoned junk - it must still acquire, not wedge.
653+
byte[] huge = new byte[64 * 1024];
654+
Arrays.fill(huge, (byte) 'x');
655+
Files.write(lock, huge);
656+
Files.setLastModifiedTime(lock, FileTime.fromMillis(System.currentTimeMillis() - 10_000));
657+
658+
AtomicBoolean ran = new AtomicBoolean();
659+
boolean result = store.inLock(key, () -> {
660+
ran.set(true);
661+
return true;
662+
});
663+
664+
Assert.assertTrue("an oversized stale lock must be stolen, not wedge acquisition", ran.get());
665+
Assert.assertTrue(result);
666+
Assert.assertFalse("the acquired (stolen) lock must be released", Files.exists(lock));
667+
assertNoCaptureTempFiles(dir, key);
668+
});
669+
}
670+
583671
@Test
584672
public void testPerFieldFingerprintMismatchReturnsNull() throws Exception {
585673
assertMemoryLeak(() -> {
@@ -819,6 +907,16 @@ private static PersistedToken sampleToken(String access, String refresh) {
819907
return new PersistedToken(access, null, refresh, System.currentTimeMillis() + 300_000L, 300_000L);
820908
}
821909

910+
private void assertNoCaptureTempFiles(Path dir, TokenStoreKey key) throws Exception {
911+
// a successful steal deletes its atomic-capture file and a restore moves it back; neither must leak a
912+
// <hash>.lock.<uuid>.tmp behind (an orphan is otherwise only reclaimed by a later save's sweep)
913+
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, key.hash() + "*.tmp")) {
914+
for (Path p : stream) {
915+
Assert.fail("a steal must not leak a capture temp file: " + p.getFileName());
916+
}
917+
}
918+
}
919+
822920
private Path lockFile(Path dir, TokenStoreKey key) {
823921
return dir.resolve(key.hash() + ".lock");
824922
}

0 commit comments

Comments
 (0)