Browse Source

fix(notifications): honest UA + Cloudflare-challenge detection on ntfy (#1534)

  The notification service's httpx client was the only outbound client in
  the codebase still leaking python-httpx/<version> as User-Agent; all
  other clients identify as Bambuddy/1.0 since the May 2026 compliance
  pass. Bring it in line.

  The reporter's ntfy server was behind a Cloudflare Tunnel and CF returned
  its JS challenge page (Just a moment...) to every API request — confirmed
  by reproducing the same 403 with curl. Cloudflare can't be solved from a
  backend, so add detection for the challenge shape (Server: cloudflare or
  cf-mitigated header, or <!DOCTYPE html>...Just a moment... body) and
  return an actionable error message that points at the real fix on the
  user's CF side instead of dumping the raw HTML.

  Normal 403s (auth failures with plain text bodies) still surface the
  original body so genuine errors stay debuggable.
maziggy 2 ngày trước cách đây
mục cha
commit
4343bd60b1

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
CHANGELOG.md


+ 55 - 3
backend/app/services/notification_service.py

@@ -20,6 +20,47 @@ from backend.app.models.notification_template import NotificationTemplate
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+# Honest User-Agent — matches the convention used by every other outbound
+# httpx client in the codebase (bambu_cloud, makerworld, firmware_check,
+# inventory). Previously this client leaked python-httpx/<version>, which
+# was both inconsistent with the rest of the project and a more obvious
+# bot signature for upstream WAFs.
+_USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
+
+
+def _looks_like_cloudflare_challenge(response: httpx.Response) -> bool:
+    """Return True if ``response`` looks like a Cloudflare mitigation
+    interstitial (JS challenge / managed challenge / block page) rather
+    than a legitimate response passed through Cloudflare.
+
+    Self-hosted servers behind Cloudflare (Tunnel, "Bot Fight Mode", or
+    "Under Attack" mode) intercept non-browser clients at the edge and
+    return a challenge HTML page instead of forwarding to the origin —
+    so we never reach the user's actual ntfy / webhook backend.
+    Cloudflare cannot be defeated from a Python client; the user has to
+    add a security-skip rule on their side. We detect the shape so the
+    UI can tell them that, instead of dumping the raw HTML.
+
+    Detection deliberately does NOT rely on ``Server: cloudflare`` alone
+    — Cloudflare adds that header to every response it proxies (success
+    AND legitimate origin errors), so a real 401 "wrong token" from a
+    CF-fronted ntfy would false-positive into a misleading "your CF is
+    blocking" message. Reliable signals: the ``cf-mitigated`` header
+    (set only when CF actively mitigates) and the challenge body shape.
+    """
+    if response.headers.get("cf-mitigated"):
+        return True
+    content_type = (response.headers.get("content-type") or "").lower()
+    if "html" not in content_type:
+        return False
+    body = (response.text or "")[:1024].lower()
+    # "Just a moment..." is Cloudflare's universal challenge-page title
+    # (managed challenge, JS challenge, Under Attack mode). Combined with
+    # an HTML content-type this is unambiguous — no legitimate ntfy or
+    # webhook backend returns HTML with that title. ``cf-chl-*`` and
+    # ``challenge-platform`` cover newer / non-default CF templates.
+    return "just a moment" in body or "cf-chl-bypass" in body or "cf-chl-opt" in body or "challenge-platform" in body
+
 
 
 class NotificationService:
 class NotificationService:
     """Service for sending notifications through various providers."""
     """Service for sending notifications through various providers."""
@@ -33,7 +74,10 @@ class NotificationService:
     async def _get_client(self) -> httpx.AsyncClient:
     async def _get_client(self) -> httpx.AsyncClient:
         """Get or create HTTP client."""
         """Get or create HTTP client."""
         if self._http_client is None or self._http_client.is_closed:
         if self._http_client is None or self._http_client.is_closed:
-            self._http_client = httpx.AsyncClient(timeout=30.0)
+            self._http_client = httpx.AsyncClient(
+                timeout=30.0,
+                headers={"User-Agent": _USER_AGENT},
+            )
         return self._http_client
         return self._http_client
 
 
     async def close(self):
     async def close(self):
@@ -264,8 +308,16 @@ class NotificationService:
 
 
         if response.status_code in (200, 204):
         if response.status_code in (200, 204):
             return True, "Message sent successfully"
             return True, "Message sent successfully"
-        else:
-            return False, f"HTTP {response.status_code}: {response.text[:200]}"
+        if _looks_like_cloudflare_challenge(response):
+            return False, (
+                f"HTTP {response.status_code} — ntfy server is behind a Cloudflare "
+                "challenge. Bambuddy was served the JS challenge page instead of "
+                "reaching ntfy. Cloudflare cannot be solved from a backend; add a "
+                "Cloudflare security-skip rule for this hostname, disable Bot "
+                "Fight Mode, or front the server with Cloudflare Access using a "
+                "service token. (#1534)"
+            )
+        return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
 
     async def _send_pushover(
     async def _send_pushover(
         self, config: dict, title: str, message: str, image_data: bytes | None = None
         self, config: dict, title: str, message: str, image_data: bytes | None = None

+ 146 - 0
backend/tests/unit/services/test_notification_service.py

@@ -2057,3 +2057,149 @@ class TestFirstLayerCompleteNotifications:
             mock_send.assert_called_once()
             mock_send.assert_called_once()
             call_kwargs = mock_send.call_args
             call_kwargs = mock_send.call_args
             assert call_kwargs.kwargs.get("image_data") == fake_image
             assert call_kwargs.kwargs.get("image_data") == fake_image
+
+
+class TestNtfyOutbound:
+    """Regression for #1534 — UA hygiene and Cloudflare-challenge detection."""
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @pytest.mark.asyncio
+    async def test_notification_client_sets_honest_user_agent(self, service):
+        """Default httpx UA leaks `python-httpx/<version>` — every other
+        outbound client in the codebase identifies as Bambuddy. The
+        notification client must too."""
+        client = await service._get_client()
+        try:
+            assert client.headers.get("user-agent") == "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
+        finally:
+            await service.close()
+
+    @pytest.mark.asyncio
+    async def test_ntfy_cloudflare_challenge_returns_actionable_error(self, service):
+        """When ntfy is fronted by Cloudflare and CF returns its JS
+        challenge, the user must see a message that points at the actual
+        fix (CF security skip), not the raw HTML."""
+        import httpx
+
+        challenge_html = (
+            '<!DOCTYPE html><html lang="en-US"><head><title>Just a moment...</title>'
+            '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">'
+        )
+        mock_response = httpx.Response(
+            403,
+            content=challenge_html.encode(),
+            headers={"server": "cloudflare", "content-type": "text/html; charset=UTF-8"},
+        )
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
+            ok, detail = await service._send_ntfy(
+                {"server": "https://ntfy.example", "topic": "alerts", "auth_token": "tk_xxx"},
+                title="t",
+                message="m",
+            )
+
+        assert ok is False
+        assert "Cloudflare" in detail
+        assert "security-skip" in detail or "Bot Fight Mode" in detail
+        # The raw HTML must not be the dominant content shown to the user.
+        assert "<!DOCTYPE" not in detail
+
+    @pytest.mark.asyncio
+    async def test_ntfy_normal_403_still_surfaces_body(self, service):
+        """A non-Cloudflare 403 (e.g. ntfy auth fail) must keep showing
+        the original body so the user can debug the real error — we
+        only intercept the Cloudflare-challenge shape."""
+        import httpx
+
+        mock_response = httpx.Response(
+            403,
+            content=b"forbidden: invalid auth token",
+            headers={"content-type": "text/plain"},
+        )
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
+            ok, detail = await service._send_ntfy(
+                {"server": "https://ntfy.sh", "topic": "alerts", "auth_token": "bad"},
+                title="t",
+                message="m",
+            )
+
+        assert ok is False
+        assert "Cloudflare" not in detail
+        assert "invalid auth token" in detail
+        assert detail.startswith("HTTP 403:")
+
+    @pytest.mark.asyncio
+    async def test_ntfy_origin_error_through_cloudflare_is_not_misclassified(self, service):
+        """Cloudflare adds Server: cloudflare to EVERY proxied response,
+        including legitimate origin errors. A real 401 "wrong token"
+        from an ntfy server that happens to sit behind Cloudflare must
+        still surface the origin's actual error body — we must not flip
+        every CF-fronted 4xx into a "your Cloudflare is blocking" message.
+        """
+        import httpx
+
+        mock_response = httpx.Response(
+            401,
+            content=b'{"code":40101,"http":401,"error":"unauthorized"}',
+            headers={
+                "server": "cloudflare",
+                "cf-ray": "abc123-FRA",
+                "content-type": "application/json",
+                # No cf-mitigated — CF just proxied the origin response.
+            },
+        )
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
+            ok, detail = await service._send_ntfy(
+                {"server": "https://ntfy.example", "topic": "alerts", "auth_token": "wrong"},
+                title="t",
+                message="m",
+            )
+
+        assert ok is False
+        assert "Cloudflare" not in detail
+        assert "unauthorized" in detail
+        assert detail.startswith("HTTP 401:")
+
+    @pytest.mark.asyncio
+    async def test_ntfy_cloudflare_cf_mitigated_header_alone_triggers(self, service):
+        """The cf-mitigated header on its own is enough — that's the
+        canonical CF "I actively blocked this" signal, even if the
+        response body shape changes between CF challenge generations."""
+        import httpx
+
+        mock_response = httpx.Response(
+            403,
+            content=b"<html>some future CF block page</html>",
+            headers={
+                "server": "cloudflare",
+                "cf-mitigated": "challenge",
+                "content-type": "text/html",
+            },
+        )
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
+            ok, detail = await service._send_ntfy(
+                {"server": "https://ntfy.example", "topic": "alerts"},
+                title="t",
+                message="m",
+            )
+
+        assert ok is False
+        assert "Cloudflare" in detail

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác