Files
camoufox/pythonlib/tests/test_download_integrity.py
T
luandhgt b2d842177a Verify sha256 of downloaded release assets before extracting
check_asset() already reads the asset's digest from the GitHub API and
stores it as installed_sha256, and AvailableVersion carries a sha256
field through to version.json. Nothing compared either against the
bytes that were downloaded: every sha256 equality check in the package
compares metadata to metadata when selecting an installed version, and
hashlib appeared only in utils.py to key a config cache.

So the archive that gets extracted over the install directory, and then
chmod 755'd and executed, was accepted on transport security alone. The
digest needed to catch a substituted or truncated asset was already in
hand and unused.

Add verify_sha256() and call it between download and extraction on both
install paths -- install_versioned() for the CLI and InstallWorker for
the GUI. It hashes in 1 MiB blocks so a multi-hundred-megabyte asset
does not have to be held in memory, and rewinds the buffer afterwards
so unzip() still reads from the start.

When no digest is published the install proceeds with a warning rather
than failing: some sources publish no digest, and refusing to install
from them would be a regression, not a fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 65cda21b4f)
2026-09-05 14:26:37 -06:00

85 lines
3.0 KiB
Python

"""Guards for the integrity of downloaded release assets.
`webdl()` streams a release asset straight into a buffer that `unzip()` then
extracts over the install directory. The GitHub API already hands us the
asset's `digest` field, and `check_asset()` parses it into `installed_sha256`
-- but nothing ever compared it against the bytes on disk, so a corrupted or
substituted archive was extracted and executed unchallenged.
`verify_sha256()` closes that gap. These tests pin the behaviour that matters:
a mismatch must abort the install, and a verified buffer must still be
readable from position 0 so the extraction step keeps working.
"""
import hashlib
from io import BytesIO
import pytest
from camoufox.exceptions import CorruptedDownload
from camoufox.pkgman import verify_sha256
# Large enough to span several read() blocks, so a single-shot read()
# regression cannot pass by accident.
PAYLOAD = b"camoufox release asset" * 100_000
DIGEST = hashlib.sha256(PAYLOAD).hexdigest()
def test_matching_digest_is_accepted():
verify_sha256(BytesIO(PAYLOAD), DIGEST, "asset")
def test_digest_comparison_is_case_insensitive():
"""GitHub returns lowercase hex, but a hand-pinned digest may not be."""
verify_sha256(BytesIO(PAYLOAD), DIGEST.upper(), "asset")
@pytest.mark.parametrize(
"mutate",
[
pytest.param(lambda b: bytes([b[0] ^ 0xFF]) + b[1:], id="first-byte-flipped"),
pytest.param(lambda b: b[:-1] + bytes([b[-1] ^ 0x01]), id="last-bit-flipped"),
pytest.param(lambda b: b[:-1], id="truncated"),
pytest.param(lambda b: b + b"\x00", id="appended"),
pytest.param(lambda b: b"", id="empty"),
],
)
def test_tampered_payload_aborts_the_install(mutate):
"""Any deviation must raise -- extraction never gets to run."""
with pytest.raises(CorruptedDownload):
verify_sha256(BytesIO(mutate(PAYLOAD)), DIGEST, "asset")
def test_error_names_both_digests():
"""The message has to be actionable when someone hits this in the wild."""
with pytest.raises(CorruptedDownload) as exc:
verify_sha256(BytesIO(b"wrong"), DIGEST, "Camoufox v1.2.3")
msg = str(exc.value)
assert "Camoufox v1.2.3" in msg
assert DIGEST in msg
assert hashlib.sha256(b"wrong").hexdigest() in msg
@pytest.mark.parametrize("absent", [None, ""])
def test_missing_digest_does_not_block_the_install(absent):
"""Sources that publish no digest must stay installable, not hard-fail."""
verify_sha256(BytesIO(PAYLOAD), absent, "asset")
def test_buffer_is_rewound_for_extraction():
"""unzip() reads the same buffer next; leaving it at EOF yields an
empty archive rather than a loud failure."""
buf = BytesIO(PAYLOAD)
verify_sha256(buf, DIGEST, "asset")
assert buf.tell() == 0
assert buf.read() == PAYLOAD
def test_verifies_a_real_temporary_file(tmp_path):
"""The install path passes a NamedTemporaryFile, not a BytesIO."""
path = tmp_path / "asset.zip"
path.write_bytes(PAYLOAD)
with open(path, "rb") as f:
verify_sha256(f, DIGEST, "asset")
assert f.tell() == 0