fix(firmware-check): bypass Cloudflare TLS-fingerprint gate via curl_cffi (#1666)

Cloudflare on bambulab.com now serves cf-mitigated=challenge to plain
  Python TLS handshakes. Use curl_cffi.AsyncSession with impersonate="chrome"
  for the two bambulab.com fetches (index page + per-model JSON); wiki and
  CDN paths stay on httpx. HTTP User-Agent stays honest "Bambuddy/1.0" —
  only TLS-handshake bytes match Chrome, per the compliance commitment.

  Soft dependency — falls back to httpx with a startup warning when
  curl_cffi isn't importable; wiki-based version detection still works.
This commit is contained in:
maziggy
2026-06-06 13:30:13 +02:00
parent b362f159ac
commit 4bcab89eee
4 changed files with 230 additions and 99 deletions
+77 -29
View File
@@ -164,9 +164,10 @@ async def test_get_available_versions_sorts_newest_first():
@pytest.mark.asyncio
async def test_client_headers_identify_honestly_and_send_browser_accept():
"""
The httpx client must identify as Bambuddy (no Chrome impersonation) and
must send Accept + Accept-Language so Cloudflare on bambulab.com doesn't
403 us for looking like a bare scraper (#1350).
The httpx client (used for the Bambu wiki and other non-CF-gated paths)
must identify as Bambuddy at the HTTP layer and must send Accept +
Accept-Language so Cloudflare on bambulab.com doesn't 403 us for
looking like a bare scraper (#1350).
"""
svc = FirmwareCheckService()
headers = svc._client.headers
@@ -176,17 +177,72 @@ async def test_client_headers_identify_honestly_and_send_browser_accept():
assert "Accept-Language" in headers
@pytest.mark.asyncio
async def test_bambulab_curl_cffi_session_keeps_honest_user_agent():
"""
The curl_cffi session impersonates Chrome at the TLS layer (required to
pass Cloudflare's JA3 challenge on bambulab.com per #1666), but the
HTTP-layer User-Agent MUST stay 'Bambuddy/...'. A future refactor that
drops the headers= override would silently revert to curl_cffi's
Chrome-default UA and break our compliance commitment to identify
honestly at the application layer.
Skipped when curl_cffi is not installed (constrained-platform fallback
path — covered by the import-guard test below).
"""
from backend.app.services import firmware_check as fc_module
if not fc_module._CURL_CFFI_AVAILABLE:
pytest.skip("curl_cffi not installed in this environment")
svc = FirmwareCheckService()
client = svc._get_bambulab_client()
assert client is not None
# curl_cffi's AsyncSession exposes the configured default headers on
# `.headers`. The exact attribute is part of curl_cffi's public API
# since 0.7.x — if a future upgrade renames it, this test will surface
# the rename rather than silently passing.
session_headers = client.headers # type: ignore[attr-defined]
ua = session_headers.get("User-Agent", "")
assert ua.startswith("Bambuddy/"), f"curl_cffi session UA leaked Chrome default: {ua!r}"
assert "Chrome" not in ua
assert "Mozilla" not in ua
@pytest.mark.asyncio
async def test_bambulab_get_falls_back_to_httpx_when_curl_cffi_missing(monkeypatch):
"""
When curl_cffi can't be imported (constrained platforms, alpine without
wheels, etc.), the service must still attempt the fetch via httpx so
wiki-based version detection isn't accidentally taken down with the
download-URL path. The httpx attempt will likely 403 against CF — that's
OK; the user still gets the version badge and a clear error in the UI.
"""
from backend.app.services import firmware_check as fc_module
monkeypatch.setattr(fc_module, "_CURL_CFFI_AVAILABLE", False)
monkeypatch.setattr(fc_module, "_CurlCffiAsyncSession", None)
svc = FirmwareCheckService()
mock_resp = MagicMock(status_code=403, text="<html>Forbidden</html>")
with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
status, body = await svc._bambulab_get("https://bambulab.com/whatever")
assert status == 403
assert "Forbidden" in body
@pytest.mark.asyncio
async def test_build_id_is_persisted_to_disk(tmp_path, monkeypatch):
"""Successful buildId fetch writes to disk so it survives restart (#1350)."""
monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path)
svc = FirmwareCheckService()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = 'window.__data = {"buildId":"abc123xyz","other":"stuff"}'
body = 'window.__data = {"buildId":"abc123xyz","other":"stuff"}'
with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
with patch.object(svc, "_bambulab_get", AsyncMock(return_value=(200, body))):
build_id = await svc._get_build_id()
assert build_id == "abc123xyz"
@@ -200,7 +256,7 @@ async def test_build_id_is_persisted_to_disk(tmp_path, monkeypatch):
@pytest.mark.asyncio
async def test_build_id_falls_back_to_disk_on_403(tmp_path, monkeypatch):
"""
When bambulab.com 403s (Cloudflare block reported in #1350) we must
When bambulab.com 403s (Cloudflare block reported in #1350, #1666) we must
fall back to the disk-cached buildId from the previous successful fetch.
Without this the user's screenshots happen: wiki version is detected
but the download URL stays empty forever.
@@ -213,11 +269,8 @@ async def test_build_id_falls_back_to_disk_on_403(tmp_path, monkeypatch):
(cache_dir / "build_id.json").write_text(json.dumps({"build_id": "cached_id_42", "fetched_at": 1000.0}))
svc = FirmwareCheckService()
mock_resp = MagicMock()
mock_resp.status_code = 403
mock_resp.text = "<html>Access denied</html>"
with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
with patch.object(svc, "_bambulab_get", AsyncMock(return_value=(403, "<html>Access denied</html>"))):
build_id = await svc._get_build_id()
assert build_id == "cached_id_42"
@@ -233,11 +286,7 @@ async def test_download_page_unreachable_flag_set_on_403_json(tmp_path, monkeypa
svc._build_id = "stale_id"
svc._build_id_time = 9999999999.0 # never expires for this test
mock_resp = MagicMock()
mock_resp.status_code = 403
mock_resp.text = "Forbidden"
with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
with patch.object(svc, "_bambulab_get", AsyncMock(return_value=(403, "Forbidden"))):
result = await svc._fetch_all_versions_from_download_page("x1")
assert result == []
@@ -256,12 +305,8 @@ async def test_download_page_retries_once_when_buildid_stale(tmp_path, monkeypat
svc._build_id = "stale_id"
svc._build_id_time = 9999999999.0
# First call (with stale buildId) → 404
# Second call (with fresh buildId after refresh) → 200 with versions
stale_resp = MagicMock(status_code=404, text="not found")
fresh_resp = MagicMock(status_code=200)
fresh_resp.json = MagicMock(
return_value={
fresh_json_body = json.dumps(
{
"pageProps": {
"printerMap": {
"x1": {
@@ -278,13 +323,16 @@ async def test_download_page_retries_once_when_buildid_stale(tmp_path, monkeypat
}
}
)
# Page fetch for the buildId refresh
page_resp = MagicMock(status_code=200)
page_resp.text = 'foo "buildId":"fresh_id" bar'
# Sequence: stale 404 → page refresh 200 → fresh 200
get = AsyncMock(side_effect=[stale_resp, page_resp, fresh_resp])
with patch.object(svc._client, "get", get):
# Sequence: stale JSON 404 → page refresh 200 (carries fresh buildId) → fresh JSON 200
get = AsyncMock(
side_effect=[
(404, "not found"),
(200, 'foo "buildId":"fresh_id" bar'),
(200, fresh_json_body),
]
)
with patch.object(svc, "_bambulab_get", get):
result = await svc._fetch_all_versions_from_download_page("x1")
assert len(result) == 1