test_cloud_totp_csrf.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. """Tests for the CSRF handshake on Bambu Cloud TOTP sign-in (#2696).
  2. Bambu added double-submit CSRF protection to the ``bambulab.com`` web origin,
  3. which is where — and only where — this service posts. Verified against the live
  4. endpoint while diagnosing the report:
  5. POST /api/sign-in/tfa (bare) 403 {"reason":"missing_cookie"}
  6. GET /api/csrf 204 + Set-Cookie: bbl_csrf_token
  7. POST /api/sign-in/tfa (cookie only) 403 {"reason":"missing_header"}
  8. POST /api/sign-in/tfa (cookie + x-bbl-csrf-token) 400 {"code":5,"error":"Login failed"}
  9. The last line is the endpoint reaching application logic with a deliberately
  10. invalid key — i.e. CSRF satisfied. Four header spellings were tried;
  11. ``x-bbl-csrf-token`` is the only one accepted, so the exact name is pinned here.
  12. Landing on the sign-in page first does not help: it sets only Cloudflare's
  13. ``__cf_bm``.
  14. """
  15. from __future__ import annotations
  16. import json
  17. from unittest.mock import AsyncMock, MagicMock
  18. import pytest
  19. from backend.app.services.bambu_cloud import BambuCloudService
  20. def _response(status: int, body: str, *, cookies: dict | None = None) -> MagicMock:
  21. response = MagicMock()
  22. response.status_code = status
  23. response.text = body
  24. response.json.return_value = json.loads(body) if body else {}
  25. response.cookies = cookies or {}
  26. return response
  27. def _service(*, csrf_token: str | None = "csrf-abc123", region: str = "global") -> BambuCloudService:
  28. service = BambuCloudService(region=region)
  29. client = MagicMock()
  30. client.get = AsyncMock(return_value=_response(204, ""))
  31. client.post = AsyncMock(return_value=_response(200, '{"accessToken": "tok"}'))
  32. jar = MagicMock()
  33. jar.get.return_value = csrf_token
  34. client.cookies = jar
  35. service._client = client
  36. return service
  37. class TestCsrfHandshake:
  38. @pytest.mark.asyncio
  39. async def test_fetches_the_token_before_posting_the_code(self):
  40. service = _service()
  41. result = await service.verify_totp("tfa-key", "123456")
  42. assert result["success"] is True
  43. service._client.get.assert_awaited_once()
  44. assert service._client.get.await_args.args[0] == "https://bambulab.com/api/csrf"
  45. @pytest.mark.asyncio
  46. async def test_echoes_the_cookie_in_the_x_bbl_csrf_token_header(self):
  47. service = _service(csrf_token="csrf-abc123")
  48. await service.verify_totp("tfa-key", "123456")
  49. headers = service._client.post.await_args.kwargs["headers"]
  50. # Pinned deliberately: every other spelling tried against the live
  51. # endpoint still returned "missing_header".
  52. assert headers["x-bbl-csrf-token"] == "csrf-abc123"
  53. @pytest.mark.asyncio
  54. async def test_posts_to_the_tfa_endpoint_with_the_key_and_code(self):
  55. service = _service()
  56. await service.verify_totp("tfa-key", "123456")
  57. assert service._client.post.await_args.args[0] == "https://bambulab.com/api/sign-in/tfa"
  58. assert service._client.post.await_args.kwargs["json"] == {"tfaKey": "tfa-key", "tfaCode": "123456"}
  59. @pytest.mark.asyncio
  60. async def test_uses_the_china_origin_for_the_china_region(self):
  61. service = _service(region="china")
  62. await service.verify_totp("tfa-key", "123456")
  63. assert service._client.get.await_args.args[0] == "https://bambulab.cn/api/csrf"
  64. assert service._client.post.await_args.args[0] == "https://bambulab.cn/api/sign-in/tfa"
  65. @pytest.mark.asyncio
  66. async def test_does_not_post_the_code_when_no_token_could_be_obtained(self):
  67. service = _service(csrf_token=None)
  68. result = await service.verify_totp("tfa-key", "123456")
  69. assert result["success"] is False
  70. assert "security token" in result["message"]
  71. # Sending the code without CSRF would burn a one-shot TOTP window on a
  72. # request Bambu is guaranteed to refuse.
  73. service._client.post.assert_not_awaited()
  74. @pytest.mark.asyncio
  75. async def test_a_failing_csrf_fetch_is_reported_not_swallowed(self):
  76. service = _service()
  77. service._client.get = AsyncMock(side_effect=RuntimeError("connection reset"))
  78. result = await service.verify_totp("tfa-key", "123456")
  79. assert result["success"] is False
  80. assert "security token" in result["message"]
  81. service._client.post.assert_not_awaited()
  82. class TestCsrfRejectionMessage:
  83. """A CSRF refusal must not read as a wrong code — that misdiagnosis is what
  84. sent the reporter chasing clock drift and leading-zero parsing."""
  85. @pytest.mark.asyncio
  86. @pytest.mark.parametrize("reason", ["missing_cookie", "missing_header"])
  87. async def test_csrf_rejection_says_the_code_was_never_checked(self, reason):
  88. service = _service()
  89. body = json.dumps({"error": f"CSRF error: {reason}", "reason": reason})
  90. service._client.post = AsyncMock(return_value=_response(403, body))
  91. result = await service.verify_totp("tfa-key", "123456")
  92. assert result["success"] is False
  93. assert "before checking your code" in result["message"]
  94. assert "Invalid" not in result["message"]
  95. @pytest.mark.asyncio
  96. async def test_a_genuinely_wrong_code_still_reports_bambus_own_message(self):
  97. service = _service()
  98. service._client.post = AsyncMock(return_value=_response(400, '{"code":5,"error":"Login failed"}'))
  99. result = await service.verify_totp("tfa-key", "000000")
  100. assert result["success"] is False
  101. assert result["message"] == "Login failed"
  102. @pytest.mark.asyncio
  103. async def test_expired_session_keeps_its_dedicated_message(self):
  104. service = _service()
  105. service._client.post = AsyncMock(return_value=_response(400, '{"message":"tfaKey expired"}'))
  106. result = await service.verify_totp("tfa-key", "123456")
  107. assert result["success"] is False
  108. assert "expired" in result["message"].lower()