new file mode 100644
@@ -0,0 +1,382 @@
+From 4d45e83caf7e31f9a12fb44526bdc07f70114f04 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= <gaborjbernat@gmail.com>
+Date: Thu, 1 Oct 2026 13:07:38 -0700
+Subject: [PATCH] Merge commit from fork
+
+ReadWriteLock resolved its path with Path.resolve() and handed it to
+sqlite3.connect(), so a symlink planted in a shared lock directory made
+the victim open, lock, or create the link's target, and repointing the
+link between two constructions let two writers hold the lock at once.
+
+Resolve only the parent directory and open the database through a
+descriptor that refuses a symlink: O_NOFOLLOW and a regular-file check
+on POSIX, with SQLite connecting via /proc/self/fd or /dev/fd, and on
+Windows a handle that refuses reparse points and denies delete sharing.
+Holding that descriptor for the connection's life keeps the name pinned
+to the file that was checked.
+
+GHSA-j8f7-rjxc-mr56
+
+CVE: CVE-2026-112936
+Upstream-Status: Backport [https://github.com/tox-dev/filelock/commit/4d45e83caf7e31f9a12fb44526bdc07f70114f04]
+
+Backport Changes:
+- Adapt parent-only canonicalization and descriptor opening to the
+ 3.25.2 constructor without changing public method signatures.
+- Keep compatibility helpers in the new private _rw_security.py.
+- Adapt the later upstream inode-probe pool as a prerequisite for
+ the following shared-path fix and long-lived peer connections.
+- Validate each opened database once and retain its inode identity.
+- Close descriptors after failed fstat; defer unknown-inode closes
+ until all tracked connections close to preserve POSIX locks.
+- Add failed-fstat, non-regular path, symlink and peer-lock cases
+ to a dedicated test_read_write_security.py regression file.
+- Preserve optional SQLite imports in the regression module.
+- Preserve existing Windows opening behavior; omit new handle APIs
+ and newer fork tests because this recipe targets POSIX systems.
+- Omit standalone changelog fragments and newer documentation
+ context not present in the 3.25.2 release source.
+
+Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
+---
+ src/filelock/_read_write.py | 6 +-
+ src/filelock/_rw_security.py | 171 ++++++++++++++++++++++++++++++
+ tests/test_read_write_security.py | 120 +++++++++++++++++++++
+ 3 files changed, 295 insertions(+), 2 deletions(-)
+ create mode 100644 src/filelock/_rw_security.py
+ create mode 100644 tests/test_read_write_security.py
+
+diff --git a/src/filelock/_read_write.py b/src/filelock/_read_write.py
+index 9e26cbf..e8b6420 100644
+--- a/src/filelock/_read_write.py
++++ b/src/filelock/_read_write.py
+@@ -13,6 +13,7 @@ from weakref import WeakValueDictionary
+
+ from ._api import AcquireReturnProxy
+ from ._error import Timeout
++from ._rw_security import canonical, connect
+
+ if TYPE_CHECKING:
+ from collections.abc import Generator
+@@ -79,7 +80,7 @@ class _ReadWriteLockMeta(type):
+ if not is_singleton:
+ return super().__call__(lock_file, timeout, blocking=blocking, is_singleton=is_singleton)
+
+- normalized = pathlib.Path(lock_file).resolve()
++ normalized = canonical(lock_file)
+ with cls._instances_lock:
+ if normalized not in cls._instances:
+ instance = super().__call__(lock_file, timeout, blocking=blocking, is_singleton=is_singleton)
+@@ -154,7 +155,8 @@ class ReadWriteLock(metaclass=_ReadWriteLockMeta):
+ self._lock_level = 0
+ self._current_mode: Literal["read", "write"] | None = None
+ self._write_thread_id: int | None = None
+- self._con = sqlite3.connect(self.lock_file, check_same_thread=False)
++ self._canonical_path = canonical(lock_file)
++ self._con = connect(os.fspath(self._canonical_path))
+ with _all_connections_lock:
+ _all_connections.add(self._con)
+
+diff --git a/src/filelock/_rw_security.py b/src/filelock/_rw_security.py
+new file mode 100644
+index 0000000..6916212
+--- /dev/null
++++ b/src/filelock/_rw_security.py
+@@ -0,0 +1,171 @@
++from __future__ import annotations
++
++import os
++import pathlib
++import sqlite3
++import stat
++import sys
++import threading
++from collections import deque
++from contextlib import suppress
++from dataclasses import dataclass, field
++from itertools import count
++from typing import NamedTuple
++
++_DatabaseIdentity = tuple[int, int]
++_DB_OPEN_FLAGS = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
++_FD_DIR = "/proc/self/fd" if sys.platform == "linux" else "/dev/fd"
++
++
++def canonical(database: str | os.PathLike[str]) -> pathlib.Path:
++ # Resolve only the parent, so a symlink at the lock path stays its own key instead of aliasing its target.
++ path = pathlib.Path(database)
++ return path.parent.resolve() / path.name
++
++
++class _Probe(NamedTuple):
++ identity: _DatabaseIdentity
++ fd: int
++ # One per connection, so a release applied again after an interrupt kept it queued counts once.
++ lease: int
++
++
++@dataclass
++class _InodeProbes:
++ leases: set[int] = field(default_factory=set)
++ descriptors: list[int] = field(default_factory=list)
++ idle: list[int] = field(default_factory=list)
++
++
++# POSIX locks belong to the process and the inode, so closing any descriptor on the inode drops the locks every other
++# connection of this process holds there. SQLite defers its own closes for this reason (section 2.2 of
++# https://www.sqlite.org/howtocorrupt.html); the probes stay open until the last connection on their inode closes.
++class _ProbeDescriptors:
++ def __init__(self) -> None:
++ self._lock = threading.Lock()
++ self._inodes: dict[_DatabaseIdentity, _InodeProbes] = {}
++ self._unclaimed: list[int] = []
++ self._releases: deque[_Probe] = deque()
++ self._leases = count()
++
++ def adopt(self, database: str, claimed: list[_Probe]) -> _Probe | None:
++ try:
++ status = os.lstat(database)
++ except OSError:
++ return None
++ identity = status.st_dev, status.st_ino
++ with self._lock:
++ probes = self._inodes.get(identity)
++ if not stat.S_ISREG(status.st_mode) or probes is None or not probes.idle:
++ return None
++ claimed.append(adopted := _Probe(identity, probes.idle.pop(), next(self._leases)))
++ probes.leases.add(adopted.lease)
++ return adopted
++
++ def retain(self, fd: int, identity: _DatabaseIdentity, claimed: list[_Probe]) -> _Probe:
++ with self._lock:
++ probes = self._inodes.setdefault(identity, _InodeProbes())
++ probes.descriptors.append(fd)
++ claimed.append(retained := _Probe(identity, fd, next(self._leases)))
++ probes.leases.add(retained.lease)
++ return retained
++
++ def close_unclaimed(self, fd: int) -> None:
++ # If fstat failed, the inode is unknown. Closing it beside a live SQLite connection could release that
++ # connection's POSIX locks, so defer the close until no tracked connection remains.
++ with self._lock:
++ if self._inodes:
++ self._unclaimed.append(fd)
++ else:
++ with suppress(OSError):
++ os.close(fd)
++
++ def release(self, probe: _Probe) -> None:
++ # A finalizer may run this on a thread already inside the lock, so it never waits; the lock holder drains.
++ self._releases.append(probe)
++ self.drain()
++
++ def drain(self) -> None:
++ while self._releases and self._lock.acquire(blocking=False):
++ try:
++ while self._releases:
++ self._release_locked(self._releases[0])
++ self._releases.popleft()
++ finally:
++ self._lock.release()
++
++ def _release_locked(self, probe: _Probe) -> None:
++ if (probes := self._inodes.get(probe.identity)) is None:
++ return
++ if probe.lease in probes.leases:
++ probes.leases.remove(probe.lease)
++ if probes.leases:
++ probes.idle.append(probe.fd)
++ if probes.leases:
++ return
++ probes.idle.clear()
++ # Closing under the lock keeps a concurrent connect from taking a lock on this inode before the close.
++ while probes.descriptors:
++ fd = probes.descriptors.pop()
++ with suppress(OSError):
++ os.close(fd)
++ del self._inodes[probe.identity]
++ if not self._inodes:
++ while self._unclaimed:
++ fd = self._unclaimed.pop()
++ with suppress(OSError):
++ os.close(fd)
++
++
++_PROBE_DESCRIPTORS = _ProbeDescriptors()
++
++
++class _Connection(sqlite3.Connection):
++ _probe: _Probe | None = None
++ database_identity: _DatabaseIdentity | None = None
++
++ def close(self) -> None:
++ try:
++ super().close()
++ finally:
++ if (probe := self._probe) is not None:
++ self._probe = None
++ _PROBE_DESCRIPTORS.release(probe)
++
++ def __del__(self) -> None:
++ with suppress(Exception):
++ self.close()
++
++
++def _open_lock_database(database: str) -> tuple[int, _DatabaseIdentity]:
++ fd = os.open(database, _DB_OPEN_FLAGS, 0o600)
++ try:
++ status = os.fstat(fd)
++ except BaseException:
++ _PROBE_DESCRIPTORS.close_unclaimed(fd)
++ raise
++ if not stat.S_ISREG(status.st_mode):
++ os.close(fd)
++ msg = f"refusing a non-regular lock database: {database!r}"
++ raise OSError(msg)
++ return fd, (status.st_dev, status.st_ino)
++
++
++def connect(database: str) -> sqlite3.Connection:
++ if sys.platform == "win32":
++ # Windows handle hardening is not part of the POSIX recipe backport.
++ return sqlite3.connect(database, check_same_thread=False)
++ claimed: list[_Probe] = []
++ try:
++ if (probe := _PROBE_DESCRIPTORS.adopt(database, claimed)) is None:
++ fd, identity = _open_lock_database(database)
++ probe = _PROBE_DESCRIPTORS.retain(fd, identity, claimed)
++ _PROBE_DESCRIPTORS.drain()
++ connection = sqlite3.connect(f"{_FD_DIR}/{probe.fd}", check_same_thread=False, factory=_Connection)
++ connection._probe = probe
++ connection.database_identity = probe.identity
++ claimed.clear()
++ return connection
++ finally:
++ for probe in claimed:
++ _PROBE_DESCRIPTORS.release(probe)
+diff --git a/tests/test_read_write_security.py b/tests/test_read_write_security.py
+new file mode 100644
+index 0000000..e2b862a
+--- /dev/null
++++ b/tests/test_read_write_security.py
+@@ -0,0 +1,120 @@
++from __future__ import annotations
++
++import errno
++import os
++import subprocess
++import sys
++from pathlib import Path
++from collections.abc import Generator
++
++import pytest
++
++pytest.importorskip("sqlite3")
++
++import sqlite3
++
++from filelock import ReadWriteLock
++from filelock import _rw_security
++from filelock._read_write import _cleanup_connections
++
++pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX read-write lock security backport")
++
++
++@pytest.fixture(autouse=True)
++def _close_read_write_connections() -> Generator[None]:
++ _cleanup_connections()
++ ReadWriteLock._instances.clear()
++ yield
++ _cleanup_connections()
++ ReadWriteLock._instances.clear()
++
++
++def test_read_write_lock_refuses_a_symlinked_path(tmp_path: Path) -> None:
++ target = tmp_path / "target.db"
++ target.write_bytes(b"unchanged")
++ database = tmp_path / "lock.db"
++ database.symlink_to(target)
++ with pytest.raises(OSError):
++ ReadWriteLock(database, is_singleton=False)
++ assert target.read_bytes() == b"unchanged"
++
++
++def test_read_write_lock_refuses_a_non_regular_path(tmp_path: Path) -> None:
++ database = tmp_path / "lock.db"
++ os.mkfifo(database)
++ with pytest.raises(OSError, match="non-regular"):
++ ReadWriteLock(database, is_singleton=False)
++
++
++def test_closing_a_peer_keeps_another_connections_lock(tmp_path: Path) -> None:
++ database = tmp_path / "lock.db"
++ holder = ReadWriteLock(database, is_singleton=False)
++ peer = ReadWriteLock(database, is_singleton=False)
++ try:
++ holder.acquire_write()
++ peer.close()
++ result = subprocess.run(
++ [sys.executable, "-c", "import sqlite3,sys; c=sqlite3.connect(sys.argv[1], timeout=0); c.execute('BEGIN EXCLUSIVE')", str(database)],
++ capture_output=True,
++ text=True,
++ check=False,
++ )
++ assert result.returncode != 0
++ assert "database is locked" in result.stderr
++ finally:
++ holder.close()
++
++
++def _fail_opened_database_fstat(database: Path, monkeypatch: pytest.MonkeyPatch) -> list[int]:
++ opened: list[int] = []
++ real_open = os.open
++ real_fstat = os.fstat
++ def open_database(path: str | os.PathLike[str], flags: int, mode: int = 0o777, **kwargs: object) -> int:
++ fd = real_open(path, flags, mode, **kwargs)
++ if os.fspath(path) == os.fspath(database):
++ opened.append(fd)
++ return fd
++ def fail_fstat(fd: int) -> os.stat_result:
++ if fd in opened:
++ raise OSError(errno.EIO, "database fstat failed")
++ return real_fstat(fd)
++ monkeypatch.setattr(os, "open", open_database)
++ monkeypatch.setattr(os, "fstat", fail_fstat)
++ return opened
++
++
++def test_failed_database_fstat_closes_an_unowned_descriptor(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
++ database = tmp_path / "lock.db"
++ with monkeypatch.context() as patch:
++ opened = _fail_opened_database_fstat(database, patch)
++ with pytest.raises(OSError, match="database fstat failed"):
++ ReadWriteLock(database, is_singleton=False)
++ assert len(opened) == 1
++ with pytest.raises(OSError) as caught:
++ os.fstat(opened[0])
++ assert caught.value.errno == errno.EBADF
++
++
++def test_failed_database_fstat_preserves_a_peer_lock(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
++ database = tmp_path / "lock.db"
++ holder = ReadWriteLock(database, is_singleton=False)
++ try:
++ holder.acquire_write()
++ with monkeypatch.context() as patch:
++ opened = _fail_opened_database_fstat(database, patch)
++ with pytest.raises(OSError, match="database fstat failed"):
++ ReadWriteLock(database, is_singleton=False)
++ assert len(opened) == 1
++ # The unidentified descriptor must remain open while closing it could drop the holder's POSIX locks.
++ os.fstat(opened[0])
++ result = subprocess.run(
++ [sys.executable, "-c", "import sqlite3,sys; c=sqlite3.connect(sys.argv[1], timeout=0); c.execute('BEGIN EXCLUSIVE')", str(database)],
++ capture_output=True, text=True, check=False,
++ )
++ assert result.returncode != 0
++ assert "database is locked" in result.stderr
++ finally:
++ holder.close()
++ with pytest.raises(OSError) as caught:
++ os.fstat(opened[0])
++ assert caught.value.errno == errno.EBADF
@@ -14,6 +14,7 @@ SRC_URI += " file://run-ptest \
file://CVE-2026-112935_p3.patch \
file://CVE-2026-112909_p1.patch \
file://CVE-2026-112909_p2.patch \
+ file://CVE-2026-112936.patch \
"
SRC_URI[sha256sum] = "b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"
@@ -33,6 +34,7 @@ RDEPENDS:${PN} += " \
"
RDEPENDS:${PN}-ptest += " \
+ python3-sqlite3 \
python3-pytest-asyncio \
python3-pytest-mock \
python3-virtualenv \