mirror of
https://github.com/daijro/camoufox.git
synced 2026-09-08 16:01:00 +00:00
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)
This commit is contained in:
@@ -14,6 +14,14 @@ class MissingRelease(Exception):
|
||||
...
|
||||
|
||||
|
||||
class CorruptedDownload(Exception):
|
||||
"""
|
||||
Raised when a downloaded asset does not match its expected sha256 digest.
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class UnsupportedArchitecture(Exception):
|
||||
"""
|
||||
Raised when the architecture is not supported.
|
||||
|
||||
@@ -32,7 +32,7 @@ from ..multiversion import (
|
||||
save_repo_cache,
|
||||
set_active,
|
||||
)
|
||||
from ..pkgman import RepoConfig, unzip, webdl
|
||||
from ..pkgman import RepoConfig, unzip, verify_sha256, webdl
|
||||
|
||||
# Workers
|
||||
|
||||
@@ -74,8 +74,12 @@ class DownloadWorker(Worker):
|
||||
|
||||
with tempfile.NamedTemporaryFile() as f:
|
||||
webdl(self.version.url, buffer=f, bar=False, progress_callback=self._progress)
|
||||
self.status.emit("Extracting...")
|
||||
self.status.emit("Verifying...")
|
||||
self.progress.emit(-1)
|
||||
verify_sha256(
|
||||
f, self.version.sha256, desc=f"Camoufox v{self.version.version.full_string}"
|
||||
)
|
||||
self.status.emit("Extracting...")
|
||||
unzip(f, str(path), bar=False)
|
||||
|
||||
(path / 'version.json').write_bytes(orjson.dumps(self.version.to_metadata()))
|
||||
|
||||
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
|
||||
import orjson
|
||||
import rich_click as click
|
||||
|
||||
from .pkgman import INSTALL_DIR, OS_NAME, Version, rprint, unzip
|
||||
from .pkgman import INSTALL_DIR, OS_NAME, Version, rprint, unzip, verify_sha256
|
||||
|
||||
BROWSERS_DIR: Path = INSTALL_DIR / "browsers"
|
||||
CONFIG_FILE: Path = INSTALL_DIR / "config.json"
|
||||
@@ -420,6 +420,14 @@ def install_versioned(fetcher, replace: bool = False) -> bool:
|
||||
|
||||
with tempfile.NamedTemporaryFile() as temp_file:
|
||||
fetcher.download_file(temp_file, fetcher.url)
|
||||
|
||||
expected_sha = (
|
||||
fetcher._selected_version.sha256
|
||||
if fetcher._selected_version
|
||||
else getattr(fetcher, "installed_sha256", None)
|
||||
)
|
||||
verify_sha256(temp_file, expected_sha, desc=f"Camoufox v{fetcher.verstr}")
|
||||
|
||||
rprint(f'Extracting Camoufox: {install_path}')
|
||||
unzip(temp_file, str(install_path))
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
@@ -31,6 +32,7 @@ from yaml import CLoader, load
|
||||
from .__version__ import CONSTRAINTS
|
||||
from .exceptions import (
|
||||
CamoufoxNotInstalled,
|
||||
CorruptedDownload,
|
||||
MissingRelease,
|
||||
ProfileDirectoryError,
|
||||
UnsupportedArchitecture,
|
||||
@@ -950,6 +952,33 @@ def webdl(
|
||||
return buffer
|
||||
|
||||
|
||||
def verify_sha256(buffer: DownloadBuffer, expected: Optional[str], desc: str = "asset") -> None:
|
||||
"""
|
||||
Check a downloaded buffer against its expected sha256 digest.
|
||||
|
||||
Raises CorruptedDownload on mismatch. Skips silently when no digest is
|
||||
known, so installs from sources that publish no digest still work.
|
||||
"""
|
||||
if not expected:
|
||||
rprint(f"Warning: no sha256 published for {desc}; skipping verification.", fg="yellow")
|
||||
return
|
||||
|
||||
buffer.seek(0)
|
||||
digest = hashlib.sha256()
|
||||
for block in iter(lambda: buffer.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
buffer.seek(0)
|
||||
|
||||
actual = digest.hexdigest()
|
||||
if actual != expected.lower():
|
||||
raise CorruptedDownload(
|
||||
f"Checksum mismatch for {desc}.\n"
|
||||
f" expected sha256: {expected.lower()}\n"
|
||||
f" actual sha256: {actual}\n"
|
||||
"The download was corrupted or tampered with. Installation aborted."
|
||||
)
|
||||
|
||||
|
||||
def unzip(
|
||||
zip_file: DownloadBuffer,
|
||||
extract_path: str,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user