From 68e5a73d0f4d226323312017dc105bbaf9823909 Mon Sep 17 00:00:00 2001 From: nelsonduarte Date: Mon, 5 Oct 2026 14:59:00 +0100 Subject: [PATCH] fix: raise the pypdf and urllib3 security floors pypdf 6.16.2 through 6.18.1 carry some or all of PYSEC-2026-4153 to 4160, all memory or runtime exhaustion on crafted PDFs; 6.19.0 is the first release clear of all eight. The requirements.txt floor moves from 6.16.2 to 6.19.0, and so does the Flatpak pin (requirements-pinned.txt plus the wheel URL and sha256 in python-modules.yml). The scheduled security-deps.yml run on main (run 37313643981, 2026-10-05) failed its strict gate on the Flatpak pin pypdf==6.16.2, with eight findings; this commit fixes it. The requirements.txt half of that gate passed on the same run although its floor admitted every affected release: "pip-audit -r" audits the newest release that satisfies ">=", not the floor. The floor still matters because builds resolve at build time: build.yml installed pypdf 6.18.1 for every v1.15.0 binary (run 34720950897). PYSEC-2026-4157 is reachable from the app: the editor's form tab sends every field back through PdfWriter.update_page_form_field_values (app/editor/tab.py:1399) with auto_regenerate=True and flatten=False, which still regenerates the appearance stream of /Tx and /Ch fields, although the advisory says it "requires updating form field values with flattening enabled". A 1.4 MB PDF whose multiline /Tx value has 100,000 lines held the GUI thread for 51 s on 6.18.1 against 1.5 s on 6.19.0; the cost grows quadratically with the line count. 4159 and 4160 are not reachable: app/ never calls PdfReader.page_labels or .attachments, the only entry points to the fixed code. The urllib3 floor moves from 2.7.0 to 2.8.0 for PYSEC-2026-4175 / 4176 / 4177 (published 2026-10-01, all affecting 2.7.0). The Flatpak build does not install urllib3. The requirements.txt comments called urllib3 a transitive dependency of cryptography and requests, and idna one of urllib3 and requests. In a venv installed from requirements.txt alone, pip show reports an empty Required-by for both, urllib3 does not depend on idna, and app/ imports neither; only requests, pulled in by pip-audit from requirements-dev.txt, depends on them. The comments now say so. Whether the two entries belong in requirements-dev.txt is left open. tests/test_dependency_security_floors.py fails when a requirements.txt floor for pypdf, cryptography, urllib3, lxml or idna admits a release below the first one clear of every advisory OSV listed on 2026-10-05, or admits a release listed as affected. It also rejects a floor conditioned by an environment marker, since pip skips that line wherever the marker is false. It only knows the advisories it lists, and as of 2026-10-05 no workflow on main runs pytest. Co-Authored-By: Claude Opus 5.5 (1M context) --- flatpak/python-modules.yml | 4 +- flatpak/requirements-pinned.txt | 2 +- requirements.txt | 33 ++++- tests/test_dependency_security_floors.py | 177 +++++++++++++++++++++++ 4 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 tests/test_dependency_security_floors.py diff --git a/flatpak/python-modules.yml b/flatpak/python-modules.yml index 3392210..7f4e35a 100644 --- a/flatpak/python-modules.yml +++ b/flatpak/python-modules.yml @@ -27,8 +27,8 @@ sources: only-arches: - x86_64 - type: file - url: https://files.pythonhosted.org/packages/13/f1/a2da3b55acd4ab737bf728c97edaaed5ec1d3c1236acb639dcdfa97e42c7/pypdf-6.16.2-py3-none-any.whl - sha256: c8b09a59399062fb45a1b8156c18a787a10a3dae03ac9674397a226712c94604 + url: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl + sha256: 7e5d6e730e7dae87d560a2cee218b852f6498c8be61966f3cd02ead971e48d14 - type: file url: https://files.pythonhosted.org/packages/7a/33/8312d7ce74670c9d39a532b2c246a853861120486be9443eebf048043637/pytesseract-0.3.13-py3-none-any.whl sha256: 7a99c6c2ac598360693d83a416e36e0b33a67638bb9d77fdcac094a3589d4b34 diff --git a/flatpak/requirements-pinned.txt b/flatpak/requirements-pinned.txt index a07bb3e..158ba87 100644 --- a/flatpak/requirements-pinned.txt +++ b/flatpak/requirements-pinned.txt @@ -1,6 +1,6 @@ PySide6==6.11.1 shiboken6==6.11.1 -pypdf==6.16.2 +pypdf==6.19.0 cryptography==50.0.1 qtawesome==1.4.2 pillow==12.3.0 diff --git a/requirements.txt b/requirements.txt index 43c85ea..cb61f7b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,21 @@ PySide6>=6.11.1 # PYSEC-2026-3655 / PYSEC-2026-3656 (fixed in 6.15.0), CVE-2026-82398 # (6.15.0), CVE-2026-84309 (6.16.0) and CVE-2026-84310 / CVE-2026-84311 -# (6.16.1). 6.16.1 is the first release clear of all six, so the floor -# is the current 6.16.2 rather than the 6.15.0 the earliest advisory -# alone would suggest. -pypdf>=6.16.2 +# (6.16.1). Then PYSEC-2026-4158 (6.17.0), PYSEC-2026-4153 (6.18.0), +# PYSEC-2026-4154 / PYSEC-2026-4155 / PYSEC-2026-4156 (6.18.1) and +# PYSEC-2026-4157 / PYSEC-2026-4159 / PYSEC-2026-4160 (6.19.0), all +# memory or runtime exhaustion on crafted PDFs. 6.19.0 is the first +# release clear of all fourteen; 6.18.1, which build.yml installed for +# every v1.15.0 binary it built (run 34720950897), still carries three. +# "pip-audit -r" audits the newest release that satisfies ">=", not the +# floor, so a floor that admits vulnerable releases audits clean for as +# long as a fixed release exists. +# tests/test_dependency_security_floors.py fails if the floor drops +# below 6.19.0, but only where pytest runs (as of 2026-10-05 no +# workflow on main runs pytest). An advisory published later against +# the floor turns nothing that audits this file red once a fixed +# release exists. Do not lower it. +pypdf>=6.19.0 # GHSA-537c-gmf6-5ccf: signature verification bypass in legacy OpenSSL # providers fixed in 48.0.1, and PYSEC-2026-3552 / CVE-2026-69247 # (Bleichenbacher oracle in PKCS#7 EnvelopedData decryption, introduced @@ -28,9 +39,15 @@ ebooklib>=0.20 # external entities by default, allowing crafted DOCX/PPTX/EPUB/PDF-XMP # files to read arbitrary local files). lxml>=6.1.1 -# Transitive of cryptography/requests; pinned to address advisories -# PYSEC-2026-141 / 142 / GHSA series for urllib3 < 2.7.0. -urllib3>=2.7.0 -# Transitive of urllib3/requests; PYSEC-2026-215 for idna < 3.15. +# No package in this file requires urllib3 and app/ does not import it; +# only requests, pulled in by pip-audit from requirements-dev.txt, +# depends on it. Floor for advisories +# PYSEC-2026-141 / 142 / GHSA series for urllib3 < 2.7.0, then +# PYSEC-2026-4175 / PYSEC-2026-4176 / PYSEC-2026-4177 (published +# 2026-10-01, all affecting 2.7.0), fixed in 2.8.0. +urllib3>=2.8.0 +# Same as urllib3: no package in this file requires idna (urllib3 does +# not either) and app/ does not import it; only requests depends on it. +# Floor for PYSEC-2026-215, idna < 3.15. idna>=3.18 pyinstaller>=6.21.0 diff --git a/tests/test_dependency_security_floors.py b/tests/test_dependency_security_floors.py new file mode 100644 index 0000000..1be28e5 --- /dev/null +++ b/tests/test_dependency_security_floors.py @@ -0,0 +1,177 @@ +"""Security floors in requirements.txt must exclude known-affected releases. + +Why this is a test and not left to pip-audit: ``security-deps.yml`` runs +``pip-audit -r requirements.txt --strict``, and pip-audit resolves every +``>=`` requirement to the newest matching release before auditing it. +A floor that still admits a vulnerable release therefore audits clean +for as long as a fixed release exists upstream. Measured on 2026-10-05 +with pip-audit 2.10.1: ``pypdf>=6.16.2`` reported no vulnerabilities +(it audited 6.19.0), while ``pypdf==6.16.2`` reported eight. + +The floor still matters because every build resolves at build time: +the v1.15.0 binaries (build.yml run 34720950897, all four jobs) shipped +pypdf 6.18.1, which the 6.16.2 floor admitted and which carries three of +the advisories listed below. + +What this does NOT catch: an advisory published after the table below +was measured. The test only knows the advisories it lists. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from packaging.requirements import Requirement +from packaging.specifiers import Specifier, SpecifierSet +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version + +ROOT = Path(__file__).resolve().parent.parent +REQUIREMENTS = ROOT / "requirements.txt" + +# PEP 503 name -> (first release from which every later release is clear +# of every advisory OSV lists for the package, +# releases OSV lists as affected that the floor must reject) +# Measured against api.osv.dev on 2026-10-05. +SECURITY_FLOORS = { + # PYSEC-2026-4158 (fixed 6.17.0), PYSEC-2026-4153 (6.18.0), + # PYSEC-2026-4154 / 4155 / 4156 (6.18.1), + # PYSEC-2026-4157 / 4159 / 4160 (6.19.0). + "pypdf": ("6.19.0", ("6.16.2", "6.17.0", "6.18.0", "6.18.1")), + # GHSA-537c-gmf6-5ccf (fixed 48.0.1), PYSEC-2026-3553 / 3554 (49.0.0), + # PYSEC-2026-3552 (introduced 44.0.0, fixed 50.0.0). + "cryptography": ("50.0.0", ("48.0.0", "48.0.1", "49.0.0")), + # PYSEC-2026-4175 / 4176 / 4177, all affecting 2.7.0, all fixed 2.8.0. + "urllib3": ("2.8.0", ("2.6.2", "2.6.3", "2.7.0")), + # PYSEC-2026-87 (XXE, fixed 6.1.0). + "lxml": ("6.1.0", ("6.0.2", "6.0.4")), + # PYSEC-2026-215 (fixed 3.15). + "idna": ("3.15", ("3.13", "3.14")), +} + +# pip strips "#" to end of line when it starts the line or follows +# whitespace; "pypdf>=6.19.0 # note" is a valid requirements line. +_INLINE_COMMENT = re.compile(r"(^|\s)#.*$") + + +def _parse_line(line: str) -> Requirement | None: + s = _INLINE_COMMENT.sub("", line).strip() + return Requirement(s) if s else None + + +def _requirements(text: str | None = None) -> dict[str, Requirement]: + if text is None: + text = REQUIREMENTS.read_text(encoding="utf-8") + out: dict[str, Requirement] = {} + for line in text.splitlines(): + req = _parse_line(line) + if req is None: + continue + name = canonicalize_name(req.name) + assert name not in out, f"{req.name} listed twice" + out[name] = req + return out + + +def _lowest_admitted(clause: Specifier) -> Version | None: + """Lowest version ``clause`` can admit, or None if it has no floor. + + ``==X.*`` admits X's dev and pre-releases, which sort below X, so its + floor is ``X.dev0``. Every other bounding operator admits nothing + below its own version (``>X`` admits nothing at or below X). + """ + if clause.operator in ("<", "<=", "!="): + return None + ver = clause.version + if ver.endswith(".*"): + ver = ver[:-2] + ".dev0" + try: + return Version(ver) + except InvalidVersion: + return None # "===" with a non-PEP 440 string + + +def _rejects_everything_below(spec: SpecifierSet, minimum: Version) -> bool: + """True when some clause of ``spec`` admits nothing below ``minimum``. + + Clauses are ANDed, so one clause with a high enough floor suffices. + This is operator-agnostic on purpose: ``==``, ``~=`` and ``>`` are as + safe as ``>=`` when their floor clears the minimum. + """ + floors = [f for f in map(_lowest_admitted, spec) if f is not None] + return any(f >= minimum for f in floors) + + +def _assert_floor(req: Requirement, minimum: Version) -> None: + # A marker makes pip skip the line wherever it evaluates false, so the + # floor would not hold there, whatever the specifier says. + assert req.marker is None, ( + f"requirements.txt '{req}' carries an environment marker; the " + f"security floor must apply in every environment" + ) + assert _rejects_everything_below(req.specifier, minimum), ( + f"requirements.txt '{req}' admits releases below {minimum}, the " + f"first release clear of every known {req.name} advisory" + ) + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("pypdf>=6.19.0", True), + ("pypdf>=6.19.0,<7", True), + ("pypdf==6.19.0", True), + ("pypdf~=6.19", True), + ("pypdf>6.19.0", True), + ("pypdf>=6.19.0 # pinned for PYSEC-2026-4157", True), + ("pypdf>=6.20", True), + ("pypdf[crypto]>=6.19.0", True), + ("pypdf>=6.16.2", False), + ("pypdf>=6.18.1", False), + ("pypdf>6.18.1", False), # still admits 6.18.1.post1 + ("pypdf>=6.19.0rc1", False), + ("pypdf==6.19.*", False), # admits 6.19.0rc1 + ("pypdf<7", False), + ("pypdf!=6.18.1", False), + ("pypdf!=6.20.0", False), # excludes one release, admits all others + ("pypdf", False), + ('pypdf>=6.19.0 ; python_version<"3"', False), + ('pypdf>=6.19.0 ; sys_platform=="win32"', False), + ('pypdf>=6.19.0 ; python_version>="3"', False), + ], +) +def test_floor_check_accepts_safe_forms_and_rejects_unsafe_ones(line, expected): + req = _parse_line(line) + if expected: + _assert_floor(req, Version("6.19.0")) + else: + with pytest.raises(AssertionError): + _assert_floor(req, Version("6.19.0")) + + +def test_requirement_names_are_pep503_normalised(): + reqs = _requirements("PyPDF>=6.19.0\nPython_Docx.x>=1.2.0 # c\n") + assert set(reqs) == {"pypdf", "python-docx-x"} + with pytest.raises(AssertionError, match="listed twice"): + _requirements("python-docx>=1.2.0\nPython_Docx>=1.2.0\n") + + +@pytest.mark.parametrize("name", sorted(SECURITY_FLOORS)) +def test_specifier_rejects_every_release_below_the_first_clean_one(name): + minimum, _affected = SECURITY_FLOORS[name] + req = _requirements().get(name) + assert req is not None, f"{name} missing from requirements.txt" + _assert_floor(req, Version(minimum)) + + +@pytest.mark.parametrize("name", sorted(SECURITY_FLOORS)) +def test_specifier_rejects_every_known_affected_release(name): + _minimum, affected = SECURITY_FLOORS[name] + req = _requirements().get(name) + assert req is not None, f"{name} missing from requirements.txt" + admitted = [v for v in affected if req.specifier.contains(v, prereleases=True)] + assert not admitted, ( + f"requirements.txt '{req}' still admits affected releases {admitted}" + )