From 8b8fc62449057888e20c74314b8e2924a6c02adf Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:05:58 +0000 Subject: [PATCH] fix(docker): require the patched Debian Brotli decoder (#6956) * fix(docker): require the patched Debian Brotli decoder Refresh the signed Debian native Brotli package in a shared build-stage base and fail builds below the security backport floor. Verify the actual selected library, native fix marker, valid decoding, and distro Node linkage before accepting the runtime. * fix(docker): guard HTTPS trust and accept Bookworm Brotli Check the inherited system CA store before the shared base makes its first HTTPS package request. Accept the exact fixed Debian 12 marker alongside Debian 13 while retaining package floors, decoder ownership, and valid decoding controls. * fix(docker): preserve Brotli gate diagnostics --- Dockerfile | 21 ++- scripts/check_brotli.py | 122 ++++++++++++++++ tests/test_brotli_image_gate.py | 248 ++++++++++++++++++++++++++++++++ 3 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 scripts/check_brotli.py create mode 100644 tests/test_brotli_image_gate.py diff --git a/Dockerfile b/Dockerfile index 936f35b90..fe6a6792f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,22 @@ +# Both stages need the fixed native decoder, including curl in the builder and +# distro Node in the runtime. A cached Python base may predate the Debian fix. +FROM python:3.14.9-slim AS brotli-base +COPY scripts/check_brotli.py /usr/local/bin/check-brotli.py +COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py +# Check inherited trust before the first HTTPS package request, retaining Debian's +# signed archive verification. Refresh only the explicit native dependency. +RUN python /usr/local/bin/check-ca-trust.py --system \ + && sed -i 's|http://deb.debian.org/|https://deb.debian.org/|g' /etc/apt/sources.list.d/debian.sources \ + && apt-get update && apt-get install -y --no-install-recommends libbrotli1 \ + && python /usr/local/bin/check-brotli.py \ + && rm -rf /var/lib/apt/lists/* + # ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ---- # basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'], # which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so # the final image / Cookbook never has to compile the broken sdists. See # docker/build-realesrgan-wheels.sh for the full rationale. -FROM python:3.14.9-slim AS realesrgan-wheels +FROM brotli-base AS realesrgan-wheels RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py @@ -14,7 +27,7 @@ RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: -- COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels -FROM python:3.14.9-slim +FROM brotli-base # System deps. tmux is required by Cookbook for background downloads/serves. # openssh-client is required for Cookbook remote server tests, setup, probes, @@ -52,6 +65,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libglib2.0-0t64 \ libxcb1 \ libmagic1 \ + && python /usr/local/bin/check-brotli.py --node /usr/bin/node \ && rm -rf /var/lib/apt/lists/* # Refresh both Python trust bundles before installing application dependencies. @@ -132,7 +146,8 @@ RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \ # Check gRPC's effective defaults after all Python dependencies are installed. # The application minimum fixes its bundled roots; the checked override also # keeps the effective Docker selection consistent with system trust. -RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc +RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc \ + && python /usr/local/bin/check-brotli.py --node /usr/bin/node # Copy app code COPY . . diff --git a/scripts/check_brotli.py b/scripts/check_brotli.py new file mode 100644 index 000000000..3e8a52346 --- /dev/null +++ b/scripts/check_brotli.py @@ -0,0 +1,122 @@ +"""Fail image builds that select an unfixed Debian native Brotli decoder. + +Debian Node links libbrotlidec rather than bundling upstream Node's decoder. +Use Debian's backport revision, not BrotliDecoderVersion() or Python wheel +metadata, to establish the package floor for CVE-2026-111497: +https://security-tracker.debian.org/tracker/CVE-2026-111497 +""" + +import argparse +import ctypes +from pathlib import Path +import platform +import re +import subprocess +import sys + + +DEBIAN_FIXED = {"12": "1.0.9-2+deb12u1", "13": "1.1.0-2+deb13u1"} +DECODER_SONAME = "libbrotlidec.so.1" + + +def command(*args): + return subprocess.check_output(args, text=True).strip() + + +def check_package(): + release = platform.freedesktop_os_release() + floor = DEBIAN_FIXED.get(release.get("VERSION_ID")) + if release.get("ID") != "debian" or floor is None: + raise ValueError("Brotli package floor is not defined for this distribution") + record = command( + "dpkg-query", "-W", "-f=${Status}\n${Version}", "libbrotli1" + ).splitlines() + if len(record) != 2 or any(not value.strip() for value in record): + raise ValueError("invalid libbrotli1 package record: expected status and version") + status, version = record + if status != "install ok installed": + raise ValueError(f"libbrotli1 is not fully installed (status: {status})") + if subprocess.run( + ["dpkg", "--compare-versions", version, "ge", floor], check=False + ).returncode != 0: + raise ValueError(f"libbrotli1 {version} does not meet Debian's fixed floor {floor}") + owned = { + Path(line).resolve() + for line in command("dpkg-query", "-L", "libbrotli1").splitlines() + if Path(line).name == DECODER_SONAME + } + if not owned: + raise ValueError("libbrotli1 has no packaged native decoder") + return version, owned + + +def loaded_path(library): + # Inspect the library actually selected by the dynamic loader. An installed + # fixed package alone would not catch LD_LIBRARY_PATH or /usr/local shadows. + class DlInfo(ctypes.Structure): + _fields_ = [("filename", ctypes.c_char_p), ("base", ctypes.c_void_p), + ("symbol", ctypes.c_char_p), ("address", ctypes.c_void_p)] + + dladdr = ctypes.CDLL(None).dladdr + dladdr.argtypes = [ctypes.c_void_p, ctypes.POINTER(DlInfo)] + dladdr.restype = ctypes.c_int + info = DlInfo() + if not dladdr(ctypes.cast(library.BrotliDecoderDecompress, ctypes.c_void_p), ctypes.byref(info)): + raise ValueError("cannot identify the loaded Brotli decoder") + return Path(info.filename.decode()).resolve() + + +def check_decoder(library): + # The fix adds error -17. Older decoders return INVALID here, including + # upstream 1.1.0 and 1.2.0. Fixed Debian 12 and 13 backports expose the same + # marker with different error-string prefixes. + # No malformed attacker-controlled stream is needed for this build check. + library.BrotliDecoderErrorString.argtypes = [ctypes.c_int] + library.BrotliDecoderErrorString.restype = ctypes.c_char_p + if library.BrotliDecoderErrorString(-17) not in (b"BLOCK_SWITCH", b"_ERROR_FORMAT_BLOCK_SWITCH"): + raise ValueError("native decoder lacks the CVE-2026-111497 BLOCK_SWITCH fix marker") + + library.BrotliDecoderDecompress.argtypes = [ + ctypes.c_size_t, ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t), ctypes.c_void_p + ] + library.BrotliDecoderDecompress.restype = ctypes.c_int + # A small, valid uncompressed Brotli stream must still decode correctly. + encoded = bytes.fromhex("0b058068656c6c6f20776f726c6403") + output = ctypes.create_string_buffer(11) + size = ctypes.c_size_t(len(output)) + result = library.BrotliDecoderDecompress(len(encoded), encoded, ctypes.byref(size), output) + if result != 1 or size.value != 11 or output.raw != b"hello world": + raise ValueError("native Brotli decoder failed the valid-stream control") + + +def check_node(node, decoder): + # ldd inspects the distro executable and its libnode dependency without + # executing JavaScript or relying on process.versions.brotli. + linked = command("ldd", str(node)) + match = re.search(r"libbrotlidec\.so\.1\s+=>\s+(/\S+)", linked) + if not match or Path(match[1]).resolve() != decoder: + raise ValueError(f"{node} does not select the checked Debian Brotli decoder") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--node", type=Path, help="also verify this distro Node executable's decoder") + args = parser.parse_args(argv) + try: + version, owned = check_package() + library = ctypes.CDLL(DECODER_SONAME) + decoder = loaded_path(library) + if decoder not in owned: + raise ValueError(f"selected decoder is outside libbrotli1: {decoder}") + check_decoder(library) + if args.node: + check_node(args.node, decoder) + except (ValueError, OSError, AttributeError, subprocess.CalledProcessError) as exc: + print(f"Brotli acceptance failed: {exc}", file=sys.stderr) + return 1 + print(f"Brotli accepted: Debian libbrotli1 {version}, decoder {decoder}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_brotli_image_gate.py b/tests/test_brotli_image_gate.py new file mode 100644 index 000000000..6c60f8353 --- /dev/null +++ b/tests/test_brotli_image_gate.py @@ -0,0 +1,248 @@ +"""Offline image acceptance regressions for Debian's native Brotli backport.""" + +import contextlib +import ctypes +import io +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from tests.helpers.cli_loader import load_script + + +checker = load_script("check_brotli.py") +ROOT = Path(__file__).resolve().parents[1] + + +class PackageFloorTests(unittest.TestCase): + @unittest.skipUnless(shutil.which("dpkg"), "Debian version comparisons require dpkg") + def test_debian_backport_floors_use_real_dpkg_comparisons(self): + cases = [ + ("12", "1.0.9-2", False), + ("12", "1.0.9-2+deb12u1", True), + ("12", "1.0.9-2+deb12u2", True), + ("13", "1.1.0-2+b7", False), + ("13", "1.1.0-2+deb13u1~pre1", False), + ("13", "1.1.0-2+deb13u1", True), + ("13", "1.1.0-2+deb13u1+b1", True), + ("13", "1.1.0-2+deb13u2", True), + ] + for release, version, accepted in cases: + with self.subTest(release=release, version=version), patch.object( + checker.platform, "freedesktop_os_release", return_value={"ID": "debian", "VERSION_ID": release} + ), patch.object(checker, "command", side_effect=[ + f"install ok installed\n{version}", "/usr/lib/example/libbrotlidec.so.1" + ]): + if accepted: + self.assertEqual(checker.check_package()[0], version) + else: + with self.assertRaisesRegex(ValueError, "fixed floor"): + checker.check_package() + + def test_unknown_distro_and_release_fail_closed(self): + for release in ({"ID": "ubuntu", "VERSION_ID": "13"}, + {"ID": "debian", "VERSION_ID": "14"}, {}): + with self.subTest(release=release), patch.object( + checker.platform, "freedesktop_os_release", return_value=release + ), self.assertRaisesRegex(ValueError, "not defined"): + checker.check_package() + + def test_incomplete_or_missing_package_records_are_rejected(self): + for record in ("deinstall ok config-files\n1.1.0-2+deb13u1", "", "install ok installed"): + with self.subTest(record=record), patch.object( + checker.platform, "freedesktop_os_release", return_value={"ID": "debian", "VERSION_ID": "13"} + ), patch.object(checker, "command", return_value=record), self.assertRaises(ValueError): + checker.check_package() + + +@unittest.skipUnless(shutil.which("sh") and shutil.which("dpkg"), "diagnostics require sh and dpkg") +class DiagnosticTests(unittest.TestCase): + def run_checker(self, body="raise SystemExit(checker.main([]))", **overrides): + # Keep the real subprocess/CLI diagnostic boundary; only external command + # responses and distro identity are controlled by this offline fixture. + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + query = directory / "dpkg-query" + query.write_text('''#!/bin/sh +case "$1" in + -W) printf '%s' "$BROTLI_TEST_RECORD" + printf '%s' "$BROTLI_TEST_QUERY_STDERR" >&2 + exit "$BROTLI_TEST_QUERY_EXIT" ;; + -L) printf '%s' '/usr/lib/example/libbrotlidec.so.1' + printf '%s' "$BROTLI_TEST_LIST_STDERR" >&2 + exit "$BROTLI_TEST_LIST_EXIT" ;; +esac +exit 2 +''') + query.chmod(0o755) + ldd = directory / "ldd" + ldd.write_text('#!/bin/sh\nprintf "%s" "ldd: cannot inspect executable" >&2\nexit 1\n') + ldd.chmod(0o755) + env = dict(os.environ, PATH=tmp + os.pathsep + os.environ.get("PATH", os.defpath), + BROTLI_TEST_RECORD="install ok installed\n1.1.0-2+deb13u1", + BROTLI_TEST_QUERY_STDERR="", BROTLI_TEST_QUERY_EXIT="0", + BROTLI_TEST_LIST_STDERR="", BROTLI_TEST_LIST_EXIT="0") + env.update(overrides) + program = ( + "from pathlib import Path\n" + "import subprocess\n" + "from tests.helpers.cli_loader import load_script\n" + "checker = load_script('check_brotli.py')\n" + "checker.platform.freedesktop_os_release = lambda: {'ID': 'debian', 'VERSION_ID': '13'}\n" + + body + ) + return subprocess.run([sys.executable, "-c", program], cwd=ROOT, env=env, + capture_output=True, text=True) + + def test_failed_package_queries_preserve_stderr(self): + for overrides, diagnostic in [ + ({"BROTLI_TEST_QUERY_EXIT": "1", "BROTLI_TEST_QUERY_STDERR": + "dpkg-query: no packages found matching libbrotli1"}, "no packages found"), + ({"BROTLI_TEST_LIST_EXIT": "2", "BROTLI_TEST_LIST_STDERR": + "dpkg-query: cannot read package file list"}, "cannot read package file list"), + ]: + with self.subTest(overrides=overrides): + result = self.run_checker(**overrides) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn(diagnostic, result.stderr) + self.assertIn("Brotli acceptance failed:", result.stderr) + + def test_ldd_failure_preserves_stderr(self): + result = self.run_checker(body=( + "try:\n" + " checker.check_node(Path('/usr/bin/node'), Path('/usr/lib/example/libbrotlidec.so.1'))\n" + "except subprocess.CalledProcessError:\n" + " raise SystemExit(1)\n" + )) + self.assertEqual(result.returncode, 1) + self.assertIn("ldd: cannot inspect executable", result.stderr) + + def test_package_query_warning_does_not_corrupt_record(self): + result = self.run_checker(body="print(checker.check_package()[0])", + BROTLI_TEST_QUERY_STDERR="dpkg-query: warning: package metadata warning\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "1.1.0-2+deb13u1") + self.assertIn("package metadata warning", result.stderr) + + def test_invalid_package_status_has_its_own_diagnostic(self): + for status in ("deinstall ok config-files", "install ok unpacked", "install reinstreq half-installed"): + with self.subTest(status=status): + result = self.run_checker(BROTLI_TEST_RECORD=status + "\n1.1.0-2+deb13u1") + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("libbrotli1 is not fully installed", result.stderr) + self.assertIn(status, result.stderr) + self.assertNotIn("fixed floor", result.stderr) + + def test_malformed_package_records_have_actionable_diagnostic(self): + for record in ("", "install ok installed", "install ok installed\n", "\n1.1.0-2+deb13u1", + "install ok installed\n1.1.0-2+deb13u1\nextra"): + with self.subTest(record=record): + result = self.run_checker(BROTLI_TEST_RECORD=record) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("invalid libbrotli1 package record: expected status and version", result.stderr) + self.assertNotIn("unpack", result.stderr) + self.assertNotIn("fixed floor", result.stderr) + + +def decoder(marker=b"_ERROR_FORMAT_BLOCK_SWITCH", result=1, decoded=b"hello world"): + def decompress(encoded_size, encoded, size, output): + # Check the valid fixture as well as the caller's output handling. + assert encoded_size == len(encoded) == 15 + ctypes.memmove(output, decoded, len(decoded)) + ctypes.cast(size, ctypes.POINTER(ctypes.c_size_t))[0] = len(decoded) + return result + + return SimpleNamespace(BrotliDecoderErrorString=lambda code: marker, + BrotliDecoderDecompress=decompress) + + +class DecoderTests(unittest.TestCase): + def test_fixed_debian_12_plain_marker_is_accepted(self): + checker.check_decoder(decoder(marker=b"BLOCK_SWITCH")) + + def test_fixed_native_decoder_is_accepted_without_upstream_version_metadata(self): + # A Debian 1.1.0 backport has no new upstream version requirement. + checker.check_decoder(decoder()) + + def test_unpatched_native_error_table_is_rejected(self): + for marker in (b"INVALID", b"_ERROR_FORMAT_DISTANCE", b"BLOCK_SWITCH_EXTRA", None): + with self.subTest(marker=marker), self.assertRaisesRegex(ValueError, "BLOCK_SWITCH"): + checker.check_decoder(decoder(marker=marker)) + + def test_fix_marker_does_not_replace_valid_decoding_control(self): + for marker in (b"BLOCK_SWITCH", b"_ERROR_FORMAT_BLOCK_SWITCH"): + for native in (decoder(marker=marker, result=0), decoder(marker=marker, decoded=b"wrong bytes"), + decoder(marker=marker, decoded=b"hello")): + with self.subTest(marker=marker, native=native), self.assertRaisesRegex(ValueError, "valid-stream"): + checker.check_decoder(native) + + def test_node_must_link_the_same_decoder(self): + native = Path("/usr/lib/example/libbrotlidec.so.1") + for linked, accepted in [ + (f"libbrotlidec.so.1 => {native} (0x1234)\n", True), + ("libbrotlidec.so.1 => /usr/local/lib/libbrotlidec.so.1 (0x1234)", False), + ("libbrotlidec.so.1 => not found", False), + ("statically linked", False), + ]: + with self.subTest(linked=linked), patch.object(checker, "command", return_value=linked): + if accepted: + checker.check_node(Path("/usr/bin/node"), native) + else: + with self.assertRaisesRegex(ValueError, "does not select"): + checker.check_node(Path("/usr/bin/node"), native) + + def test_a_shadow_decoder_is_rejected_even_with_a_fixed_package_installed(self): + with patch.object(checker, "check_package", return_value=("1.1.0-2+deb13u1", {Path("/usr/lib/decoder")})), \ + patch.object(checker.ctypes, "CDLL"), \ + patch.object(checker, "loaded_path", return_value=Path("/usr/local/lib/decoder")), \ + contextlib.redirect_stderr(io.StringIO()) as stderr: + self.assertEqual(checker.main([]), 1) + self.assertIn("outside libbrotli1", stderr.getvalue()) + + +class ImageRecipeTests(unittest.TestCase): + @unittest.skipUnless(shutil.which("sh"), "Docker RUN guard requires a POSIX shell") + def test_base_ca_guard_failure_stops_before_package_network_access(self): + base = (ROOT / "Dockerfile").read_text(encoding="utf-8").split("# ---- builder:", 1)[0] + # Execute the actual first RUN with command stubs. A failed CA check must + # stop the shell before either changing sources or invoking APT. + command = base.split("\nRUN ", 1)[1].split("\n\n", 1)[0].replace("\\\n", "") + self.assertLess(base.index("COPY scripts/check_ca_trust.py"), base.index("\nRUN ")) + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + trace = directory / "commands" + for name in ("python", "sed", "apt-get"): + stub = directory / name + stub.write_text('#!/bin/sh\nprintf "%s\\n" "$0 $*" >> "$TRACE"\n' + + ("exit 1\n" if name == "python" else "exit 0\n")) + stub.chmod(0o755) + result = subprocess.run([shutil.which("sh"), "-c", command], + env={"PATH": tmp, "TRACE": str(trace)}, capture_output=True, text=True) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(trace.read_text().splitlines(), + [f"{directory}/python /usr/local/bin/check-ca-trust.py --system"]) + + def test_builder_and_runtime_inherit_the_guarded_package_layer(self): + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + # This declarative inheritance is not exercised by Python unit tests. + _, base, builder, runtime = dockerfile.split("\nFROM ") + self.assertIn("AS brotli-base", base) + self.assertIn("apt-get install -y --no-install-recommends libbrotli1", base) + self.assertIn("python /usr/local/bin/check-brotli.py", base) + self.assertTrue(builder.startswith("brotli-base AS realesrgan-wheels\n")) + self.assertTrue(runtime.startswith("brotli-base\n")) + # Recheck Node's effective decoder after system and Python dependencies. + gates = runtime.split("python /usr/local/bin/check-brotli.py --node /usr/bin/node") + self.assertEqual(len(gates), 3) + self.assertIn("nodejs", gates[0]) + self.assertIn("pip install", gates[1]) + + +if __name__ == "__main__": + unittest.main()