diff mbox series

[meta-python,wrynose,5/9] python3-filelock: fix CVE-2026-112915

Message ID 20261111052938.3691463-6-dkelaiya@cisco.com
State New
Headers show
Series python3-filelock: Security fixes | expand

Commit Message

From: Darsh Kelaiya <dkelaiya@cisco.com>

Backport the schema-work guards from [1], as identified by advisory
[2]. Bound SQLite schema size and query work before acquisition so a
crafted lock database cannot cause unbounded schema processing.

Retain 3.25.2's persistent connections and timeout behavior. Use the
SQLite limits available in Wrynose's Python 3.14, disable statement
caching for guarded work and retain both upstream malformed-schema
regressions. The shared-path changes follow in the CVE-2026-112940 fix.

[1] https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d
[2] https://github.com/tox-dev/filelock/security/advisories/GHSA-cwqr-hh33-gh44

Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
---
 .../python3-filelock/CVE-2026-112915.patch    | 280 ++++++++++++++++++
 .../python/python3-filelock_3.25.2.bb         |   1 +
 2 files changed, 281 insertions(+)
 create mode 100644 meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112915.patch
diff mbox series

Patch

diff --git a/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112915.patch b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112915.patch
new file mode 100644
index 0000000000..47aa3a3aba
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-filelock/CVE-2026-112915.patch
@@ -0,0 +1,280 @@ 
+From 5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= <gaborjbernat@gmail.com>
+Date: Sat, 17 Oct 2026 03:39:07 -0700
+Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(read-write):=20keep=20SQLite?=
+ =?UTF-8?q?=20off=20shared=20names,=20bound=20schema=20(#834)?=
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+CVE: CVE-2026-112915
+Upstream-Status: Backport [https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d]
+
+Backport Changes:
+- Split the schema-loading guard from the shared-path changes,
+  which are applied separately for CVE-2026-112940.
+- Place the guard in _rw_security.py and wrap the existing 3.25.2
+  configure/begin path instead of newer operation-context classes.
+- Keep the per-instance connection and existing timeout semantics.
+- Disable statement caching so guarded statements are prepared
+  within the schema budget.
+- Use sqlite3.Connection.setlimit supplied by Wrynose Python 3.14;
+  this ReadWriteLock backport requires Python 3.11 or newer.
+- Retain both upstream malformed-schema fixtures in the dedicated
+  regression file and explicitly close persistent connections.
+- Omit unrelated path, Windows, fork and coverage-task changes
+  from this split patch; applicable path changes follow separately.
+
+Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
+---
+ src/filelock/_read_write.py       |  45 ++++++-------
+ src/filelock/_rw_security.py      |  34 ++++++++--
+ tests/test_read_write_security.py | 101 ++++++++++++++++++++++++++++++
+ 3 files changed, 154 insertions(+), 26 deletions(-)
+
+diff --git a/src/filelock/_read_write.py b/src/filelock/_read_write.py
+index e8b6420..66a4664 100644
+--- a/src/filelock/_read_write.py
++++ b/src/filelock/_read_write.py
+@@ -13,7 +13,7 @@ from weakref import WeakValueDictionary
+ 
+ from ._api import AcquireReturnProxy
+ from ._error import Timeout
+-from ._rw_security import canonical, connect
++from ._rw_security import bounded_schema, canonical, connect
+ 
+ if TYPE_CHECKING:
+     from collections.abc import Generator
+@@ -189,27 +189,28 @@ class ReadWriteLock(metaclass=_ReadWriteLockMeta):
+     def _configure_and_begin(
+         self, mode: Literal["read", "write"], timeout: float, *, blocking: bool, start_time: float
+     ) -> None:
+-        waited = time.perf_counter() - start_time
+-        timeout_ms = timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited)
+-        self._con.execute(f"PRAGMA busy_timeout={timeout_ms};").close()
+-        # Use legacy journal mode (not WAL) because WAL does not block readers when a concurrent EXCLUSIVE
+-        # write transaction is active, making read-write locking impossible without modifying table data.
+-        # MEMORY is safe here since no actual writes happen — crashes cannot corrupt the DB.
+-        # See https://sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions
+-        #
+-        # Set here (not in __init__) because this pragma itself may block on a locked database,
+-        # so it must run after busy_timeout is configured above.
+-        self._con.execute("PRAGMA journal_mode=MEMORY;").close()
+-        # Recompute remaining timeout after the potentially blocking journal_mode pragma.
+-        waited = time.perf_counter() - start_time
+-        if (recomputed := timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited)) != timeout_ms:
+-            self._con.execute(f"PRAGMA busy_timeout={recomputed};").close()
+-        stmt = "BEGIN EXCLUSIVE TRANSACTION;" if mode == "write" else "BEGIN TRANSACTION;"
+-        self._con.execute(stmt).close()
+-        if mode == "read":
+-            # A SELECT is needed to force SQLite to actually acquire the SHARED lock on the database.
+-            # https://www.sqlite.org/lockingv3.html#transaction_control
+-            self._con.execute("SELECT name FROM sqlite_schema LIMIT 1;").close()
++        with bounded_schema(self._con, self.lock_file):
++            waited = time.perf_counter() - start_time
++            timeout_ms = timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited)
++            self._con.execute(f"PRAGMA busy_timeout={timeout_ms};").close()
++            # Use legacy journal mode (not WAL) because WAL does not block readers when a concurrent EXCLUSIVE
++            # write transaction is active, making read-write locking impossible without modifying table data.
++            # MEMORY is safe here since no actual writes happen — crashes cannot corrupt the DB.
++            # See https://sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions
++            #
++            # Set here (not in __init__) because this pragma itself may block on a locked database,
++            # so it must run after busy_timeout is configured above.
++            self._con.execute("PRAGMA journal_mode=MEMORY;").close()
++            # Recompute remaining timeout after the potentially blocking journal_mode pragma.
++            waited = time.perf_counter() - start_time
++            if (recomputed := timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited)) != timeout_ms:
++                self._con.execute(f"PRAGMA busy_timeout={recomputed};").close()
++            stmt = "BEGIN EXCLUSIVE TRANSACTION;" if mode == "write" else "BEGIN TRANSACTION;"
++            self._con.execute(stmt).close()
++            if mode == "read":
++                # A SELECT is needed to force SQLite to actually acquire the SHARED lock on the database.
++                # https://www.sqlite.org/lockingv3.html#transaction_control
++                self._con.execute("SELECT name FROM sqlite_schema LIMIT 1;").close()
+ 
+     def _acquire(self, mode: Literal["read", "write"], timeout: float, *, blocking: bool) -> AcquireReturnProxy:
+         opposite = "write" if mode == "read" else "read"
+diff --git a/src/filelock/_rw_security.py b/src/filelock/_rw_security.py
+index 6916212..9e4f92f 100644
+--- a/src/filelock/_rw_security.py
++++ b/src/filelock/_rw_security.py
+@@ -7,10 +7,13 @@ import stat
+ import sys
+ import threading
+ from collections import deque
+-from contextlib import suppress
++from contextlib import contextmanager, suppress
+ from dataclasses import dataclass, field
+ from itertools import count
+-from typing import NamedTuple
++from typing import TYPE_CHECKING, NamedTuple
++
++if TYPE_CHECKING:
++    from collections.abc import Generator
+ 
+ _DatabaseIdentity = tuple[int, int]
+ _DB_OPEN_FLAGS = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
+@@ -154,14 +157,14 @@ def _open_lock_database(database: str) -> tuple[int, _DatabaseIdentity]:
+ 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)
++        return sqlite3.connect(database, check_same_thread=False, cached_statements=0)
+     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 = sqlite3.connect(f"{_FD_DIR}/{probe.fd}", check_same_thread=False, factory=_Connection, cached_statements=0)
+         connection._probe = probe
+         connection.database_identity = probe.identity
+         claimed.clear()
+@@ -169,3 +172,26 @@ def connect(database: str) -> sqlite3.Connection:
+     finally:
+         for probe in claimed:
+             _PROBE_DESCRIPTORS.release(probe)
++
++
++@contextmanager
++def bounded_schema(connection: sqlite3.Connection, database: str) -> Generator[None]:
++    # The first statement loads the file's complete schema, which no timeout bounds, and a lock database has none.
++    # Waiting for a lock runs no virtual machine steps, so a step budget bounds only that load.
++    calls = count()
++    max_calls = 10
++    connection.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, 4096)
++    connection.set_progress_handler(lambda: next(calls) >= max_calls, 1000)
++    try:
++        yield
++    except sqlite3.DatabaseError as error:
++        # An interrupted schema load reports a malformed schema rather than the interrupt.
++        if isinstance(error, sqlite3.DataError) or next(calls) > max_calls:
++            msg = (
++                f"refusing a lock database whose schema is too large to load: {database!r}; "
++                "give ReadWriteLock a file of its own"
++            )
++            raise ValueError(msg) from error
++        raise
++    finally:
++        connection.set_progress_handler(None, 0)
+diff --git a/tests/test_read_write_security.py b/tests/test_read_write_security.py
+index e2b862a..a995c80 100644
+--- a/tests/test_read_write_security.py
++++ b/tests/test_read_write_security.py
+@@ -2,6 +2,8 @@ from __future__ import annotations
+ 
+ import errno
+ import os
++import struct
++from typing import Literal
+ import subprocess
+ import sys
+ from pathlib import Path
+@@ -65,6 +67,105 @@ def test_closing_a_peer_keeps_another_connections_lock(tmp_path: Path) -> None:
+         holder.close()
+ 
+ 
++_PAGE_SIZE = 4096
++
++
++_TABLE_LEAF_PAGE = 0x0D
++
++
++@pytest.mark.timeout(60)
++@pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")])
++def test_read_write_lock_refuses_a_schema_that_never_finishes_loading(
++    tmp_path: Path, mode: Literal["read", "write"]
++) -> None:
++    # Every interior page leads to the next one through each of its 660 cells, so the schema has 661**4 rows to scan.
++    database = tmp_path / "lock.db"
++    pages = [
++        _btree_page(
++            0x05,  # table interior page
++            struct.pack(">IB", number + 1, 1),
++            660,
++            right_child=struct.pack(">I", number + 1),
++            header_at=100 if number == 1 else 0,
++        )
++        for number in range(1, 5)
++    ]
++    pages.append(_btree_page(_TABLE_LEAF_PAGE, bytes([6, 1, 6, 0, 0, 0, 0, 0]), 1))
++    pages[0][:100] = _file_header(len(pages))
++    database.write_bytes(b"".join(pages))
++    lock = ReadWriteLock(database, is_singleton=False)
++
++    try:
++        with pytest.raises(ValueError, match="schema"):
++            (lock.acquire_read if mode == "read" else lock.acquire_write)(timeout=1)
++    finally:
++        lock.close()
++
++
++@pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")])
++def test_read_write_lock_refuses_a_schema_row_longer_than_a_lock_needs(
++    tmp_path: Path, mode: Literal["read", "write"]
++) -> None:
++    # The row's text continues on an overflow page that names itself as the next one, so one page of disk reads as a
++    # megabyte, far more schema text than a lock database holds and below SQLite's one-billion-byte string limit.
++    long_sql_length = 1_000_000
++    database = tmp_path / "lock.db"
++    statement = b"CREATE TABLE application (value) --"
++    columns = [b"table", b"application", b"application", b"\x02"]
++    serial_types = [13 + 2 * len(column) for column in columns[:3]] + [1, 13 + 2 * long_sql_length]
++    record_header = b"".join(_varint(serial_type) for serial_type in serial_types)
++    record_start = _varint(len(record_header) + 1) + record_header + b"".join(columns) + statement
++    record_length = len(record_start) + long_sql_length - len(statement)
++    # How much of an overflowing record a table leaf keeps on its own page.
++    local = (minimum := (_PAGE_SIZE - 12) * 32 // 255 - 23) + (record_length - minimum) % (_PAGE_SIZE - 4)
++    cell = _varint(record_length) + _varint(1) + record_start.ljust(local, b"-") + struct.pack(">I", 2)
++    leaf = _btree_page(_TABLE_LEAF_PAGE, cell, 1, header_at=100)
++    page_count = long_sql_length // _PAGE_SIZE + 2
++    leaf[:100] = _file_header(page_count)
++    database.write_bytes(leaf + struct.pack(">I", 2).ljust(_PAGE_SIZE, b"-"))
++    # SQLite refuses a record longer than the file, which a hole stretches at no cost.
++    os.truncate(database, page_count * _PAGE_SIZE)
++    lock = ReadWriteLock(database, is_singleton=False)
++
++    try:
++        with pytest.raises(ValueError, match="schema"):
++            (lock.acquire_read if mode == "read" else lock.acquire_write)(timeout=1)
++    finally:
++        lock.close()
++
++
++def _btree_page(kind: int, cell: bytes, cell_count: int, *, right_child: bytes = b"", header_at: int = 0) -> bytearray:
++    page = bytearray(_PAGE_SIZE)
++    cell_at = _PAGE_SIZE - len(cell)
++    page[cell_at:] = cell
++    # Every slot of the cell pointer array names the one cell.
++    header = struct.pack(">BHHHB", kind, 0, cell_count, cell_at, 0) + right_child
++    pointers = struct.pack(">H", cell_at) * cell_count
++    page[header_at : header_at + len(header) + len(pointers)] = header + pointers
++    return page
++
++
++def _file_header(page_count: int) -> bytes:
++    # Rollback journal format, a matching change counter and version-valid-for, so SQLite trusts the page count.
++    return struct.pack(
++        ">16sH6B12I20s2I",
++        b"SQLite format 3\0",
++        _PAGE_SIZE,
++        *(1, 1, 0, 64, 32, 32),
++        *(1, page_count, 0, 0, 1, 4, 0, 0, 1, 0, 0, 0),
++        b"",
++        1,
++        3040001,
++    )
++
++
++def _varint(value: int) -> bytes:
++    groups = [value & 0x7F]
++    while value := value >> 7:
++        groups.insert(0, value & 0x7F | 0x80)
++    return bytes(groups)
++
++
+ def _fail_opened_database_fstat(database: Path, monkeypatch: pytest.MonkeyPatch) -> list[int]:
+     opened: list[int] = []
+     real_open = os.open
diff --git a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb
index db7f9187de..4f059e438e 100644
--- a/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb
+++ b/meta-python/recipes-devtools/python/python3-filelock_3.25.2.bb
@@ -15,6 +15,7 @@  SRC_URI += " file://run-ptest \
     file://CVE-2026-112909_p1.patch \
     file://CVE-2026-112909_p2.patch \
     file://CVE-2026-112936.patch \
+    file://CVE-2026-112915.patch \
 "
 SRC_URI[sha256sum] = "b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"