Explain Bambu Cloud's CAPTCHA challenge instead of repeating it (#2790)

A reporter tried to connect to Bambu Cloud and got "We need you to confirm you
are not a robot" as an error toast, with no CAPTCHA anywhere to answer and
nothing to click. That sentence is Bambu's, not ours. Their anti-abuse layer had
flagged the network and was answering the sign-in with HTTP 418 and a challenge
body: {"captchaId": "...", "error": "We need you to confirm you are not a
robot"}.

Bambuddy had no idea what that was. The reply is well-formed JSON, so
_detect_cloudflare_challenge -- which triggers on an unparseable body, CF
markers, 403+cf-mitigated or 503+cf-ray -- never fired on it, and login_request
fell through to its generic error path, which lifts data["message"] or
data["error"] out and hands it to the UI verbatim. The user was left to conclude
their password was wrong or that Bambuddy was broken. Four sign-in attempts
inside eighteen seconds appear in their log, each one more evidence for the
thing that had flagged them.

is_captcha_challenge matches on the 418 status plus a challenge marker in the
body -- captchaId is the reliable one, the wording is matched too because Bambu
has shipped it under more than one phrasing. A bare 418 with no marker is
has shipped it under more than one phrasing. A bare 418 with no marker is
deliberately NOT reported as a CAPTCHA: telling someone to solve a challenge
that was never offered is the exact confusion this issue is about.

login_request, verify_code and verify_totp now return reason="captcha" with an
explanation covering the three things the reporter had no way to find out: the
credentials are not the problem, the block is keyed to the public IP address
rather than the account, and it clears by itself within a few hours.

Sign-in requests are then held back for 300s so Bambuddy stops deepening the
block. Keyed per origin, not per service: TOTP verification posts to
bambulab.com while everything else posts to api.bambulab.com, and a challenge
seen on one must not strand somebody halfway through a two-factor sign-in on the
other. Entries expire on read, so the map cannot grow past one per region. The
token endpoint is deliberately left ungated -- it is the way out.

The UI shows a persistent panel rather than a toast. A toast names a problem the
user cannot act on and then vanishes; this one stays put and carries a one-click
route to "Use access token instead", which is the only thing that works while
the challenge lasts, since that path does not touch the challenged endpoint.

MakerWorld meets the same challenge from the same edge and now shares the
detection. It used to require the literal word "robot" in the error text and
reported any other wording as an unexplained block.

The System Health scanner gets a bambu-cloud-captcha signature. The reporter's
bundle came back with zero findings while their log was full of the failure.

Its advice for a failed FTPS handshake was corrected at the same time: it still
blamed firewalls and outdated firmware, which the #2780 investigation ruled out
last release -- it is the printer's own file service wedging, and the fix is to
restart the printer. The wiki said so already; the health panel did not.
This commit is contained in:
maziggy
2026-08-08 10:04:55 +02:00
parent 14d0d14365
commit 604fa44593
27 changed files with 710 additions and 61 deletions
+168
View File
@@ -124,6 +124,110 @@ def _detect_cloudflare_challenge(response) -> str | None:
return None
# Bambu's own anti-abuse layer — distinct from the Cloudflare edge above —
# answers a request it has flagged with HTTP 418 and a challenge body:
#
# {"captchaId": "...", "error": "We need you to confirm you are not a robot"}
#
# The flag is keyed to the source IP and covers api.bambulab.com as a whole:
# the same 418 turns up on the login endpoint and on the design-service
# endpoints MakerWorld imports use. It clears on its own after a few hours of
# quiet traffic, and there is no server-side solve — a CAPTCHA is designed to be
# unanswerable without a real browser, and the challenge id is of no use to us
# because we have nowhere to render the widget.
#
# It reaches ``login_request`` as a perfectly well-formed JSON body, so
# ``_detect_cloudflare_challenge`` above never fires on it. Before #2790 the
# generic error path then lifted Bambu's sentence out of ``error`` and showed it
# as a bare toast: the reporter saw "We need you to confirm you are not a robot"
# with no challenge, no explanation and nothing to click, and filed it as a
# Bambuddy bug.
_CAPTCHA_HTTP_STATUS = 418
# Markers that identify a 418 as the CAPTCHA challenge rather than some other
# refusal. ``captchaId`` is the reliable one; the wording is matched too because
# Bambu has shipped the challenge under more than one phrasing.
_CAPTCHA_BODY_MARKERS = ("captchaid", "captcha", "robot")
CAPTCHA_USER_MESSAGE = (
"Bambu Cloud is challenging this network with a CAPTCHA before it will accept a sign-in, "
"and there is no way to answer it from Bambuddy. Your email and password are not the "
"problem. The block is tied to your public IP address and normally clears by itself within "
"a few hours — retrying repeatedly extends it. To sign in now, use 'Use access token "
"instead' and paste a token taken from a browser session."
)
# How long to stop sending sign-in requests to a Bambu region after it answered
# with a CAPTCHA challenge. The reporter's log shows four attempts in eighteen
# seconds, which is exactly the traffic pattern that deepens the block: every
# extra request is more evidence for the thing that flagged us. Five minutes is
# short against the hours the block itself lasts — the point is not to wait it
# out here, only to stop Bambuddy from making it worse while the user reads the
# explanation.
_CAPTCHA_COOLOFF_SECONDS = 300.0
# API base URL -> monotonic time its cool-off expires. Keyed by base URL because
# the block lives at the edge in front of one region: being challenged on
# api.bambulab.com says nothing about api.bambulab.cn.
_captcha_blocked_until: dict[str, float] = {}
def is_captcha_challenge(response) -> bool:
"""Whether Bambu answered with an anti-abuse CAPTCHA challenge.
Requires the 418 status *and* a challenge marker in the body, so an
unrelated 418 is not reported to the user as "solve a CAPTCHA" — that would
send them looking for a widget that was never there, which is the exact
confusion #2790 is about. Callers that want to say something about a bare
418 must handle it themselves.
Shared by the Bambu Cloud and MakerWorld services: same edge, same body.
"""
try:
status = int(getattr(response, "status_code", 0) or 0)
except (TypeError, ValueError):
return False
if status != _CAPTCHA_HTTP_STATUS:
return False
try:
data = response.json()
except Exception:
data = None
if isinstance(data, dict):
# Field *names* count as well as their text: the challenge is
# identified by carrying a ``captchaId`` at all, whatever it says.
parts = [str(key) for key in data]
parts += [str(data[key]) for key in ("captchaId", "error", "message", "detail") if data.get(key)]
haystack = " ".join(parts).lower()
else:
# Not JSON (or not an object) — fall back to the raw body so a
# challenge served as HTML is still recognised rather than reported as
# an unexplained failure.
try:
haystack = (response.text or "").lower()
except Exception:
return False
return any(marker in haystack for marker in _CAPTCHA_BODY_MARKERS)
def captcha_cooloff_active(base_url: str) -> bool:
"""Whether sign-in requests to ``base_url`` are still held back after a
CAPTCHA challenge. Expired entries are dropped on the way past, so the dict
cannot grow past one entry per region."""
deadline = _captcha_blocked_until.get(base_url)
if deadline is None:
return False
if time.monotonic() >= deadline:
del _captcha_blocked_until[base_url]
return False
return True
def note_captcha_challenge(base_url: str) -> None:
"""Start the cool-off for ``base_url`` after a challenge was seen."""
_captcha_blocked_until[base_url] = time.monotonic() + _CAPTCHA_COOLOFF_SECONDS
# The `/v1/iot-service/api/slicer/setting` endpoint subtree — the plural GET
# for the list, the singular GET/DELETE for a specific preset by setting_id, and
# the POST for create — requires a `version` query parameter in the XX.YY.ZZ.WW
@@ -317,12 +421,62 @@ class BambuCloudService:
headers["Authorization"] = f"Bearer {self.access_token}"
return headers
def _captcha_refusal(self) -> dict:
"""The result every sign-in call returns while Bambu is challenging us.
``reason`` is what lets the UI tell this apart from a wrong password and
render the explanation next to the access-token route, instead of
flashing Bambu's own one-liner as a toast that then disappears (#2790).
"""
return {
"success": False,
"needs_verification": False,
"reason": "captcha",
"message": CAPTCHA_USER_MESSAGE,
}
def _captcha_cooloff_holds(self, origin: str | None = None) -> bool:
"""Whether to refuse a sign-in locally because Bambu just challenged us.
Keyed by the origin the call actually goes to. The TOTP step talks to
``bambulab.com`` while everything else talks to ``api.bambulab.com``, and
a challenge seen on one must not strand a user halfway through a
two-factor sign-in on the other.
"""
origin = origin or self.base_url
if not captcha_cooloff_active(origin):
return False
logger.warning(
"Bambu Cloud is challenging this network with a CAPTCHA — not sending the sign-in to %s. "
"The challenge cannot be answered from Bambuddy and normally clears within a few hours.",
origin,
)
return True
def _note_captcha(self, response, origin: str | None = None) -> bool:
"""Record and log a CAPTCHA challenge. Returns whether it was one."""
if not is_captcha_challenge(response):
return False
origin = origin or self.base_url
logger.warning(
"Bambu Cloud is challenging this network with a CAPTCHA (HTTP %s from %s). Sign-in cannot "
"complete until the challenge clears; pausing sign-in requests for %.0fs so retries do not "
"extend the block.",
response.status_code,
origin,
_CAPTCHA_COOLOFF_SECONDS,
)
note_captcha_challenge(origin)
return True
async def login_request(self, email: str, password: str) -> dict:
"""
Initiate login - this will trigger either email verification or TOTP prompt.
Returns dict with login status, verification type, and tfaKey if needed.
"""
if self._captcha_cooloff_holds():
return self._captcha_refusal()
try:
response = await self._client.post(
f"{self.base_url}/v1/user-service/user/login",
@@ -333,6 +487,9 @@ class BambuCloudService:
},
)
if self._note_captcha(response):
return self._captcha_refusal()
try:
data = response.json()
except Exception as json_err:
@@ -388,6 +545,8 @@ class BambuCloudService:
"""
Complete login with email verification code.
"""
if self._captcha_cooloff_holds():
return self._captcha_refusal()
try:
response = await self._client.post(
f"{self.base_url}/v1/user-service/user/login",
@@ -398,6 +557,9 @@ class BambuCloudService:
},
)
if self._note_captcha(response):
return self._captcha_refusal()
try:
data = response.json()
except Exception as json_err:
@@ -472,6 +634,9 @@ class BambuCloudService:
web_origin = "https://bambulab.cn" if "bambulab.cn" in self.base_url else "https://bambulab.com"
tfa_url = f"{web_origin}/api/sign-in/tfa"
if self._captcha_cooloff_holds(web_origin):
return self._captcha_refusal()
# #2696: the web origin is CSRF-protected (double submit). Without
# both halves the endpoint 403s before it ever evaluates the code,
# which surfaced to users as a permanent, misleading "Invalid code".
@@ -509,6 +674,9 @@ class BambuCloudService:
f"TOTP verify response: status={response.status_code}, body={response.text[:200] if response.text else '(empty)'}"
)
if self._note_captcha(response, web_origin):
return self._captcha_refusal()
# Handle empty response
if not response.text or not response.text.strip():
logger.warning("TOTP verification returned empty response (status %s)", response.status_code)