Repository navigation
chore(deps): update dependency filelock to v4.2.1 [security] - #750
Open
renovate[bot] wants to merge 1 commit into
Open
renovate[bot] wants to merge 1 commit into
renovate[bot] wants to merge 1 commit into
Conversation
|
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Note
This PR body was truncated due to platform limits.
This PR contains the following updates:
4.0.4→4.2.1filelock ReadWriteLock: SQLite reopens the lock database by name, so a planted symlink or journal file makes the lock user's process overwrite or unlink its own files
CVE-2026-112940 / GHSA-v4x8-3wr5-3wq8
More information
Details
ReadWriteLockandAsyncReadWriteLock(the SQLite-backed reader/writer lock) hand SQLite a name they do not control. On Linux the lock validates its database through a descriptor (the number the kernel gives a process for an open file). It then connects SQLite to/proc/self/fd/<n>, a Linux path that names the process's own open filen. SQLite turns that path back into a plain path and reopens it by name, following symlinks (a symlink is a file that points at another path). On every POSIX route SQLite also acts on-journal,-waland-shmfiles it finds beside that name. Those are SQLite's side files: the rollback journal (pages saved to undo an interrupted write), the write-ahead log or WAL (changes kept outside the main file), and the shared-memory index for the WAL.Another local user who can create or rename entries in the directory that holds the lock database can turn this against the user running the lock (below: the lock user). Such a directory is a
0777or group-writable cache, build or project directory without the sticky bit (a directory flag that lets only a file's owner remove or rename it). That other user can make the lock user's ordinaryread_lock()orwrite_lock()write bytes of the other user's choosing into the lock user's own files. The same position lets the other user make that call create or unlink files of the lock user, admit a reader beside a writer, or kill the holder. All of this runs with the lock user's privileges. In a sticky directory such as/tmpa planted-walfile makes every acquire fail. Affected versions are filelock 3.20.4 through 4.2.0 (the/proc/self/fdredirect since 4.0.9, the side-file variants since 3.20.4).Anthropic's OSS Scanner reported this in private as
ANT-2026-KMJ2575J[high, CWE-363]: ReadWriteLock: SQLite still follows planted symlinks, overwriting files.Severity
CVSS 3.1
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:Hscores 7.1 (High).AV:L(local): the other user needs an account on the same host.PR:L(low privileges): an ordinary account is enough.UI:N(no user interaction).AC:L(low attack complexity): the planted-journal variant needs no race, and the redirect won 77 of 77 attempts.I:H(high integrity impact): the other user writes bytes of their choosing into files of the lock user and unlinks files of their choosing.A:H(high availability impact): the other user makes every acquire fail or kills the lock holder.C:N(no confidentiality impact): the other user learns nothing.S:U(scope unchanged): the effect stays within the lock user's own privileges.Classification from the external review: conditional security, a cross-user effect that exists only when another user can change names in the lock database's directory. The review notes that the 77/77 inotify result (inotify is a Linux interface that reports file events) is stronger evidence than a theoretical TOCTOU (a time-of-check to time-of-use race). The scanner rated it high. The review accepts the mechanism and narrows it to that precondition.
Scope and preconditions
0777directory or a group-writable one (including a setgid2775project directory shared with that account), in each case without the sticky bit. The lock user's lock must be able to open the database read-write, so a database the other user creates is0666. filelock itself creates the database0600./tmp,/var/tmp, any1777directory) narrow the finding rather than exclude it. The redirect and the journal replay do not reach a database the lock user owns there (tested withfs.protected_regular=2). What remains is a planted non-empty<db>-wal(any mode; a directory or FIFO works too) that the lock user cannot unlink. It makes every acquire raisesqlite3.OperationalError: disk I/O error. A hard link (a second name for the same file) to a file of the lock user at the lock path also remains, iffs.protected_hardlinks=0.fs.protected_regular(1 or 2 is the systemd default on most distributions; the kernel's own default is 0) keeps a user from opening a file another user owns in a sticky world-writable directory. Where it is 0, the other user can own the database in/tmp, and SQLite follows a planted symlink itself (not tested).fs.protected_hardlinks=1(most distributions; kernel default 0) blocks a hard link of a lock user's file onto the lock path. Neither setting affects a non-sticky shared directory./proc/self/fdredirect is Linux with/procmounted, 4.0.9 and later. The side-file variants (-journal,-wal,-shm) exist on every POSIX route. On the hard-link route (macOS since 4.1.1 for every connection, Linux without/proc) they need the link-directory swap described under "Mechanism", so a non-sticky parent. Neither the scanner nor the review executed on macOS; the statement rests on the platform branch and the identical code after it. Windows does not take this path.Affected code
At the scanned commit
b831496b0273:_connect_through_descriptorbuilds/proc/self/fd/<n>and hands it tosqlite3.connect. The assumption is the comment at line 63, "Descriptor paths reopen the validated inode" (an inode is the kernel's identity for a file, independent of its name). SQLite first reads the file it opened atPRAGMA journal_mode=MEMORY, line 816. The check in_raise_if_database_replacedat line 836 runs only afterBEGIN. Both_open_lock_databaseand_raise_if_walinspect the descriptor, not the file SQLite is on. The one-level hard link of the macOS route is_link_beside.Introduced
The
/proc/self/fdredirect came with4d45e83("Merge commit from fork", the fix for GHSA-j8f7-rjxc-mr56), first released in 4.0.9 (verified:git tag --contains 4d45e83gives 4.0.9); 4.0.8 does not have it. The side-file variants are older. They reproduce oncfd4446("feat(lock): add SQLite-based ReadWriteLock (#399)"), first released in 3.20.4 (verified), which passed the lock path straight tosqlite3.connect.Mechanism
On Linux the lock opens the database with
O_RDWR|O_CREAT|O_NOFOLLOW|O_NONBLOCK. It checks through the descriptor that the file is a regular file and not a WAL database. It then callssqlite3.connect("/proc/self/fd/<n>")and expects SQLite to reopen the validated inode. SQLite does not open that name. It runsreadlink()on it, which yields the path string the kernel reports for the descriptor at that moment, and opens that string by path, following every symlink. Without interference the trace showsreadlink("/proc/534/fd/3") = "/tmp/benign/app.lock.db"and then a secondopenatof that path. The descriptor contributes only a string. Once the directory entry behind the descriptor is gone, the string is<database> (deleted). A symlink with that exact name sends SQLite wherever its creator chose.The other user prepares the directory before the lock user runs. They create a database
cache.lock.db(0666, so the lock user's lock accepts it) with a second hard linkcache.lock.db.keep, and the symlinkcache.lock.db (deleted) -> /tmp/victim-notes.txt. The lock user'sos.openraises an inotifyIN_OPENevent. The other user unlinkscache.lock.dband linkscache.lock.db.keepback under the same name. The lock user's descriptor still refers to the unlinked entry, so/proc/self/fd/3now reads/tmp/shared-cache/cache.lock.db (deleted).sqlite3.connectresolves it, follows the symlink and opens the targetO_RDWR|O_CREAT. None of filelock's checks look at that file._open_lock_databaseand_raise_if_walinspect descriptor 3._raise_if_database_replacedcompares anlstatof the lock path with the descriptor's(st_dev, st_ino), which passes because the other user linked the same inode back, and it runs only afterBEGIN.The damage comes from the first statement,
PRAGMA journal_mode=MEMORY. SQLite finds<target>-journal, treats it as the rollback journal of an interrupted transaction, and copies its page records into the target before it checks whether the target is a database at all. The journal header supplies the page size and the page count SQLite resizes the file to. Whoever wrote the journal computed the per-page checksum. The content from offset 0 and the final length are therefore the other user's. Strace of the lock user's process (SQLite 3.40.1):The redirect itself needs no journal. The constructor's validation connection already makes SQLite open the target with
O_CREAT. With the symlink pointing at a missing path in the lock user's home, constructing the lock created that file (empty,0644, owned by the lock user) and raised nothing. With the symlink pointing at a0600WAL-mode SQLite database of the lock user,write_lock()returned without error and the database's header bytes 18 and 19 changed from2 2to1 1. The pragma converted the lock user's database out of WAL mode, and the redirected acquire held its SQLite lock on that database rather than on the lock database.Side-file names beside the database. Two comments record the belief that SQLite names its side files after the descriptor path (line 63, line 1095). The path is not synthetic to SQLite. It derives
<database>-journal,<database>-waland<database>-shmfrom the resolved real path in the shared directory.journal_mode=MEMORYgoverns only journals SQLite would write, not ones it finds. These variants need no redirect.A hot
-journalmakes the lock user unlink a file the journal names; no race. The other user unlinks the lock user's emptylock.db, puts a one-page SQLite database (0666) in its place, and writes a 46-bytelock.db-journal: a path, its 4-byte length, the 4-byte sum of its bytes and SQLite's journal magic. SQLite treats a journal beside a non-empty database as hot (left by an interrupted transaction). The tail names a "super-journal", and recovery ends by unlinking that file. The lock user's nextread_lock()orwrite_lock()returns without error and the named file is gone (unlink("/home/victim/private/notes.txt") = 0, from a0700directory of the lock user). The named file must be a non-empty regular file the lock user can read and unlink. A FIFO planted aslock.db-journalinstead blocksread_lock(timeout=2)in SQLite'sopen()of the journal (still blocked when killed after 10 s).A re-planted
-walputs the holder in WAL mode past_raise_if_wal._raise_if_walchecks header bytes 18 and 19. Whenever a connection takes its first lock, SQLite stats<db>-wal, and if that file and the database are both non-empty it opens the WAL whatever the header says. The pragma at line 816 converts such a database and unlinks-waland-shm. TheBEGINat line 833 is a separate statement that starts unlocked and stats<db>-walagain. Re-creating the 32-byte-walin that gap landed on the first attempt in 20 of 20 runs on a 2-CPU machine.The lock user's
with lock.write_lock():then holds the-shmbyte-120 WAL write lock and only SHARED on the database. Once the other user removes the-wal, a second process of the lock user getsread_lock(timeout=1)granted while the writer is inside its block, with no exception on either side. SQLite also creates<db>-shmwith the database's permission bits (0666) and keeps it mapped into memory. After the other user truncates it, the writer dies withSIGBUS(the signal for touching a mapped file region that no longer exists) inconnection.rollback()duringrelease().A
-walin a sticky directory makes every acquire raise; no race, no access to the database. Beside the lock user's own empty0600database, a non-empty<db>-wal(one byte, any mode; a directory or FIFO works too) is a leftover SQLite tries to delete. In a1777directory the lock user cannot unlink another user's file (unlink(...) = -1 EPERM), so the pragma fails withSQLITE_IOERR_DELETE. The code in_do_acquire_innertranslates only "database is locked" intoTimeout.sqlite3.OperationalError: disk I/O errortherefore leaves everyacquire_read()/acquire_write()(and the async variants) within milliseconds, whatevertimeoutorblockingsays, until the file's owner or root removes it.Further effects observed at this commit in a non-sticky directory. A
0755script owned by the lock user, renamed to the lock path with a one-record journal beside it, ends up holding the other user's bytes (the acquire raisesfile is not a database). A hard link to the lock user's0600~/.profileat the lock path does the same in a sticky/tmpwhenfs.protected_hardlinks=0. When the locking process runs as root, SQLite runsfchownon a-walor-shmit opens to match the owner of the database, so root's0600service.envrenamed toapp.lock.db-walbelonged to the other user after root'sread_lock().A journal whose super-journal names the database itself makes SQLite open and close a second descriptor on the database, which drops the process's POSIX locks, so
acquire_readreturns with no lock held in/proc/locksand a writer in another process acquires (two journals in a row, one per statement; write locks are not affected). A one-bytelock.db-walplus alock.db-shmon which the other user holds two byte locks makes the acquire take 10 s regardless oftimeoutand raiselocking protocol, or die withSIGBUSif the other user truncates the-shm.The hard-link route in a directory without the sticky bit. None of the above occurs on the
_link_besidebranch (macOS, Linux without/proc). There SQLite's main file is.filelock-…/lock.dbin the connecting user's0700directory. That protection holds only where no other user can rename the.filelock-*entry itself, because0700protects the contents, not the entry in the parent._link_besidechecks the link once and returns a pathname, and every later use is by that name.With
/prochidden in a0777directory, the other user staged a directory holding a hard linklock.dbto a one-page database and a hotlock.db-journal. They heldBEGIN EXCLUSIVEon the database so the lock user's first statement waited in the busy handler (SQLite's retry loop for a locked database), renamed the lock user's.filelock-*directory aside, renamed the staged directory onto its name and rolled back. SQLite got SHARED, found the journal under the swapped name and unlinked the file it named. The lock user's process reported the lock held and exited 0. A polling variant without the write lock also won (in 40 trials the file was gone after 1 to 26 starts of the lock user's program). Under the sticky bit the rename fails withEPERM.Reproduction
The scanner's reproducer for
ANT-2026-KMJ2575J, quoted as received. It ran against commitb831496b0273.reprorepro.d/plant_hot_journal.pyrepro.d/redirect_lock_db.pyrepro.d/victim_app.pyAttack sequence
The redirect, with
Athe other local user:sequenceDiagram participant A as other local user participant V as lock user's process (filelock) participant S as SQLite inside that process participant K as kernel and filesystem rect rgb(255, 214, 214) Note over A,K: filelock 4.0.9 to 4.2.0 on Linux, lock directory 0777 without the sticky bit A->>K: create cache.lock.db (0666) and a second hard link cache.lock.db.keep A->>K: symlink "cache.lock.db (deleted)" pointing at /tmp/victim-notes.txt A->>K: write /tmp/victim-notes.txt-journal (hot rollback journal, one page record) V->>K: open(cache.lock.db, O_RDWR O_CREAT O_NOFOLLOW) gives fd 3, fstat says regular, header not WAL K-->>A: inotify IN_OPEN on cache.lock.db A->>K: unlink(cache.lock.db) then link(cache.lock.db.keep, cache.lock.db) V->>S: sqlite3.connect("/proc/self/fd/3") S->>K: readlink(/proc/self/fd/3) returns "cache.lock.db (deleted)" S->>K: open("cache.lock.db (deleted)") follows the symlink to victim-notes.txt V->>S: PRAGMA journal_mode=MEMORY S->>K: open(victim-notes.txt-journal), pread page 1, pwrite it at offset 0 of victim-notes.txt Note over V,K: victim-notes.txt (0600, 22 bytes) is now the other user's 4096-byte page image, the lock user sees "disk I/O error" V->>K: lstat(cache.lock.db) matches fd 3 identity, so _raise_if_database_replaced passes endThe side-file variants, which need no redirect:
flowchart TD B0["acquire validates fd, connects SQLite by a name in the shared directory"] --> B1{"what did the other user put beside the real path?"} B1 -- "hot -journal beside a swapped-in one-page database" --> B2["PRAGMA replays the journal, then unlinks the super-journal file it names, acquire succeeds"] B1 -- "-wal re-created between PRAGMA and BEGIN" --> B3["holder in WAL mode, reader admitted beside the writer, SIGBUS when -shm is truncated"] B1 -- "-wal in a sticky directory the lock user cannot unlink" --> B4["every acquire raises OperationalError disk I/O error, timeout ignored"] B1 -- "name unlinked and a (deleted) symlink planted" --> B5["SQLite opens the symlink target with O_CREAT"] classDef ok fill:#d8f3dc,stroke:#​2d6a4f,color:#​1b4332; classDef bad fill:#ffd6d6,stroke:#c1121f,color:#​5c0000; classDef info fill:#cfe4ff,stroke:#​1f6feb,color:#​0b1f3a; class B0,B1 info; class B2,B3,B4,B5 bad;Evidence
The scanner's reproducer runs the documented
ReadWriteLock(path)+with rw.read_lock(timeout=5):as uservictimagainst/tmp/shared-cache/cache.lock.db(directory0777, not sticky) while userattackerholds the symlink and reacts to inotify. It first shows thatattackercannot write the target (PermissionError: [Errno 13] Permission denied: '/tmp/victim-notes.txt'). On the firstvictimrun it printsvictim run 1: exit status 1: sqlite3.OperationalError: disk I/O error,target owner, mode, size: 1000 0o600 4096, target beforeb'original victim notes\n', afterb"ATTACKER CONTENT WRITTEN BY THE VICTIM'S ReadWriteLock\n\x00...", verdictBUG. Reacting to inotify redirected the first acquire in 77 of 77 runs. A plain unlink/link busy loop with no inotify also won on the first run. A process holding twoReadWriteLockinstances on one database keeps an idle descriptor; a swap done a full second before the second acquire redirected it. The-walre-plant landed on the first attempt in 20 of 20 runs. The journal-unlink variant needs no race.Our verification: the reproducer prints
BUGon the scanned commitb831496and on the 4.2.0 image (e2e7d70). On the PR #834 head it printsOK: all 20victimruns exit 0 with no error and the target stays1000 0o600 22bytes with its original content. The independent review also ran the sticky-directory-walvariant (a0600database owned byvictimin a1777directory, other user plants<db>-wal). 4.2.0 raisesOperationalError: disk I/O error; the PR head takes read and write locks with the file still present.Impact
Another local user who can write the non-sticky directory holding the database makes the lock user's process act on a file other than the lock database, with the lock user's privileges, on Linux. The process writes bytes of the other user's choosing from offset 0 into any non-empty regular file the lock user can write, resized to a whole number of pages, provided the other user can create
<file>-journalbeside it (the lock user's own0600files in/tmpqualify). Without a journal the process creates an empty file at any path it may create, or converts a WAL-mode SQLite database of its own to rollback mode with no error. A journal beside a swapped-in database makes the process unlink one regular file of the other user's choosing per journal; the acquire succeeds and raises nothing. A process insidewrite_lock()stops excluding the lock user's other processes fromread_lock(), and the holder can die from aSIGBUSthat arrives inrelease().It is not reachable for a database the lock user owns in
/tmpwithfs.protected_regular1 or 2. There only the planted-waldenial of service remains: every acquire raises an undocumentedsqlite3.OperationalErrorthatTimeoutandOSErrorhandlers do not catch. It is a bypass of the fix for GHSA-j8f7-rjxc-mr56 (4.0.9), which refused the symlink at the database path but followed it at<database> (deleted). Windows does not take this path.Workarounds
ReadWriteLockdatabase in a directory that only the locking account (or root) can write, with no group or other write bit. Make sure every ancestor belongs to that account or root and is either not writable by others or sticky. Do not share oneReadWriteLockdatabase between accounts through a0777or group-writable directory. The lock creates its database0600, and leaving it that way keeps other accounts from supplying its content.fs.protected_regularat 1 or 2 andfs.protected_hardlinks=1(distribution defaults). Treat a suddensqlite3.OperationalError: disk I/O erroron every acquire as a foreign<db>-walbeside the database that the file's owner or root has to remove.Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/834
Credits
Reported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.
filelock-20261015-005of commitb831496b0273)Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:HReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
filelock: On Windows an oplock on a soft lock's marker holds SoftFileLock.acquire() past its timeout and blocks the stale break
CVE-2026-112907 / GHSA-567q-5qvw-pf92
More information
Details
On Windows the soft locks (
SoftFileLock,MarkerSoftFileLock,SoftFileLease, their async forms, and aFileLockthat fell back to a soft lock) read their marker by path. The marker is the lock file whose presence says a process holds the lock. Eachacquire()attempt that finds a marker opens it withos.opento read the holder's process id, and the lock renames and deletes a marker it judges stale, by path. On Windows each of these calls waits for an oplock. An oplock (opportunistic lock) is a request by a process that holds a handle on a file to be told before another process opens, renames or deletes that file; the other process waits until the holder answers the break or closes its handle.Another local account that can open the marker for reading can take a Level 1 oplock on it while no process holds it open, and leave the break unanswered. Every
acquire()by the user running the lock (below: the lock user) then waits inside the open, past itstimeout,blocking=Falseandcancel_check, and a stale marker under that oplock is not broken. Mutual exclusion holds and the lock touches no other file. Affected versions are filelock 3.22.0 through 4.2.0 on Windows. The maintainers found this while reviewing the fix for the report Anthropic's OSS Scanner sent on 15 October 2026 (review round 4, finding F4); it is not among the scanner's findings.Severity
CVSS 3.1
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:Lscores 2.5 (Low).AV:L(local): the other account needs a logon on the same host.AC:H(high attack complexity): Windows grants a Level 1 or batch oplock only to the sole open handle on a file, so the marker must have no holder handle when the other account asks. That account cannot bring this about; it waits for a holder that died, closed its descriptor, or left a marker past itslifetime.PR:L(low privileges): an ordinary account with read access to the marker through the directory's inherited ACL (access control list).UI:N(no user interaction).S:U(scope unchanged).C:NandI:N: that account can read the marker's content (process id and host name) anyway, and mutual exclusion holds.A:L(partial availability impact): one thread, or the event loop withrun_in_executor=False, waits until the holder answers; 10 s per attempt in the two-account runs, no crash. Metrics in doubt take the lower reading.Classification from the review: security, conditional on an unheld marker that another account can open.
Scope and preconditions
SoftFileLock,MarkerSoftFileLock,SoftFileLease,AsyncSoftFileLockand the other async soft locks, andFileLockafter itsENOSYSfallback to a soft lock.WindowsFileLockopens its lock file on a separate code path that this advisory does not cover.os.open, which on Windows applies no more ofmodethan the read-only attribute (GHSA-8mx5-7m5m-r77g). The marker carries the lock directory's inheritable entries. Another account that those entries grant read access to new files can open the marker with a read handle that shares read, write and delete.lifetimehas passed. While the holder keeps its handle, the request fails.DeviceIoControland never acknowledges the break. Windows cancels a thread's pending request when the thread exits, so the holder thread stays alive. A same-account process that answers slowly, such as an indexer, has the same effect for its answer time.SoftFileLock.acquire(timeout=1)returnedTimeoutafter 10.02 s with the second account's handle. In the same-account probeos.openwithO_RDONLYwas still running after 15 s and returned once the holder closed. The retry loop consultstimeout,blocking=Falseandcancel_checkbetween attempts only. WithAsyncSoftFileLock(run_in_executor=False)the event loop waits.break_lock_filerenames and unlinks by path. Both calls wait in the same way, so a stale marker under such an oplock stays, and no process acquires the lock until the holder closes.Affected code
Scanned commit
b831496b0273: theos.openin_read_lock_file(function), called from_try_break_stale_lockon every attempt that finds a marker, and from_unprobeable_holder,pidandis_lock_held_by_us.break_lock_filerenames at L116 and unlinks at L118 and L122, by path._poll_until_acquiredand_check_give_uptest the deadline,blockingandcancel_checkonly between attempts.Introduced
e35c3af "feat(soft): detect and break stale locks" (#476, 2026-02-13), first tag 3.22.0 (verified with
git tag --contains). Before it a soft lock created its marker withO_CREAT | O_EXCLand never opened an existing one. #476 read the marker on Windows throughCreateFileWand broke a stale one withPath.renameandPath.unlink. These wait for an oplock asos.opendoes, because none of them passesFILE_COMPLETE_IF_OPLOCKED, anNtCreateFileoption that returns instead of waiting. The read became theos.openin_read_lock_filewith bfbfa76 (#548, 3.29.1) and the break moved intobreak_lock_filewith d244c6d (#551, 3.29.2); the by-path calls stayed.Mechanism
Each attempt of
_acquirefirst callsos.open(path, O_WRONLY | O_CREAT | O_EXCL). On an existing marker that fails withEEXIST(orEACCESon Windows) at once; the same-account probe shows the failed exclusive create returning at once under an oplock too._acquirethen calls_try_break_stale_lock, which calls_read_lock_file:os.lstat, thenos.open(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK). On WindowsO_NOFOLLOWandO_NONBLOCKdo not exist (the code reads them withgetattr(os, ..., 0)), andos.opencallsCreateFileW. When another handle holds a Level 1 or batch oplock on the file, that call sends the holder a break request and returns only when the holder acknowledges it or closes its handle._check_give_uptests the deadline after the attempt returns, so the wait overrunstimeout,blocking=Falseandcancel_checkby its length. Once the open returns, the attempt ends and_check_give_upraisesTimeout, because the deadline has passed.A marker whose holder is gone takes the second path.
break_lock_filerenames it to a break name and unlinks it, by path. On Windows the rename and the delete open the file with delete access, which breaks a Level 1 or batch oplock the same way; the same-account probe showsos.renameandos.unlinkstill running after 15 s and returning once the holder closed. Thelifetimebreak at L123 runs the same function. A stale marker under such an oplock is therefore not removed, and every attempt by every process waits inside the read. The other account can request the oplock again as soon as the lock user's handle closes, between two attempts or twoacquire()calls, as in the Linux lease case of GHSA-7hmp-gf99-57hx. Nothing in the retry loop bounds this wait.Reproduction
The review's reproducers, quoted as received.
oplock_vm.pysection[2]is this finding; sections[1a]and[1b]belong to therelease()advisory.oplock_semantics_vm.pyis the same-account probe of which path operations wait. They ran on a Windows 11 VM (NTFS, CPython 3.14.8) against the head of PR #835 at3e4c5ef, where_read_lock_fileandbreak_lock_fileare the same code as atb831496b0273apart from import changes. The second accountfltesteris a member ofUsers; the directory grants it(OI)(CI)(M).oplock_vm.pyRecorded output of section
[2]:oplock_semantics_vm.pyThe review kept this probe's result as a sentence rather than its printed lines: under a same-account Level 1 or batch oplock,
os.open(path, os.O_RDONLY),os.renameandos.unlinkwere still running after 15 s and returned only once the holder closed its handle;os.utimeby path and a failedO_CREAT | O_EXCLcreate returned at once.Failure sequence
sequenceDiagram participant A as other local account participant M as soft.lock marker, no handle open participant L as lock user: SoftFileLock(path).acquire(timeout=1) participant W as Windows (NTFS) A->>W: CreateFileW(soft.lock, GENERIC_READ, share read/write/delete, FILE_FLAG_OVERLAPPED) A->>W: DeviceIoControl(FSCTL_REQUEST_OPLOCK_LEVEL_1), granted, break never acknowledged L->>W: os.open(soft.lock, O_CREAT|O_EXCL) fails with EEXIST at once L->>W: os.lstat(soft.lock) regular file rect rgb(255, 214, 214) L->>W: os.open(soft.lock, O_RDONLY) inside _read_lock_file Note over L,W: CreateFileW asks A to break the oplock and waits#59; the 1 s deadline, blocking=False and cancel_check are not consulted W-->>L: handle after 10.02 s (two-account run), or when A closes (same-account probe) end L->>L: _check_give_up: past the deadline, raise Timeout after 10.02 s Note over A,M: a stale marker takes the same wait in Path.rename and Path.unlink, so it is not brokenEvidence
Section
[2]ofoplock_vm.pyacquiredsoft.lockas the test account, closed the holder's descriptor so the marker stayed with a live process id and no open handle, took a Level 1 oplock through the second account's read handle (oplock granted: True), and ranSoftFileLock(marker, is_singleton=False).acquire(timeout=1)on a thread. The call returnedTimeoutafter 10.02 s, before the holder closed. The review did not establish what ends the wait at 10 s in the two-account runs; section[1a]of the same script, with the second account's handle, shows the same 10 s. In the same-account probe (oplock_semantics_vm.py) the open returned only when the holder closed, still running at 15 s. The probe also shows that the failed exclusive create andos.utimeby path return at once, so the wait is in the read and the break, not in the create attempt.Impact
Each
acquire()of a soft lock that finds a marker another account holds an oplock on runs past itstimeout,blocking=Falseandcancel_checkuntil the holder answers or closes, 10 s per attempt in the recorded two-account runs. A stale marker under that oplock is not broken, so no process acquires the lock while the holder keeps its handle. WithAsyncSoftFileLock(run_in_executor=False)the wait is on the event loop thread and stops every task in the process; with the default executor one worker thread waits. The documentation names a soft lock as the option for network filesystems, after verification on the mount, andFileLockis a soft lock on builds withoutfcntland after itsENOSYSfallback. Mutual exclusion is not affected and nothing other than the marker is read, written or deleted.Workarounds
SYSTEMandAdministratorsgranted (icacls <dir> /inheritance:r /grant:r "<owner>:(OI)(CI)F" "SYSTEM:(OI)(CI)F" "Administrators:(OI)(CI)F").modedoes not do this on Windows.AsyncSoftFileLockwith the default executor rather thanrun_in_executor=False, so that only a worker thread waits.timeout,blockingorcancel_checkvalue bounds the wait.Patched version
4.2.1.
Fix: https://github.com/tox-dev/filelock/pull/835
Credits
Found by the maintainers while reviewing the fix for the report Anthropic's OSS Scanner sent on 15 October 2026 (review round 4, finding F4).