test_makerworld_s3_tls.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. """Tests for the S3 presigned-download path in ``services/makerworld.py``.
  2. MakerWorld hands back an AWS presigned URL for the 3MF, and we fetch that one
  3. with ``urllib.request`` rather than httpx — httpx re-encodes the query string
  4. and invalidates the S3 signature. That choice silently changed the trust
  5. store: urllib verifies against the OS CA store, httpx against the bundled
  6. ``certifi`` bundle. On Windows the two disagree and the download dies with
  7. ``CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate`` (#2562).
  8. These tests pin the fix (the opener carries a certifi-backed TLS context) and
  9. the two properties the fix must not break: the no-redirect SSRF guard, and the
  10. URL reaching the transport byte-for-byte.
  11. """
  12. from __future__ import annotations
  13. import ssl
  14. from datetime import datetime, timedelta, timezone
  15. from unittest.mock import MagicMock, patch
  16. import certifi
  17. import pytest
  18. from cryptography import x509
  19. from cryptography.hazmat.primitives import hashes, serialization
  20. from cryptography.hazmat.primitives.asymmetric import ec
  21. from cryptography.x509.oid import NameOID
  22. from backend.app.services import makerworld as mw
  23. # A presigned URL in the shape Bambu Cloud actually mints: the signature is
  24. # computed over these exact query-string bytes, so any re-encoding breaks it.
  25. S3_URL = (
  26. "https://s3.us-west-2.amazonaws.com/bbl-prod/models/benchy.3mf"
  27. "?X-Amz-Algorithm=AWS4-HMAC-SHA256"
  28. "&X-Amz-Credential=AKIA%2F20260714%2Fus-west-2%2Fs3%2Faws4_request"
  29. "&X-Amz-Date=20260714T070000Z&X-Amz-Expires=300"
  30. "&X-Amz-Signature=abc123&X-Amz-SignedHeaders=host"
  31. )
  32. def _write_test_ca(path) -> str:
  33. """Write a throwaway self-signed CA to ``path`` and return its CN.
  34. Lets a test assert the opener's TLS context was loaded from *certifi's*
  35. bundle specifically, rather than from the OS store or any other source:
  36. we point ``certifi.where()`` at this file and then check the context
  37. trusts exactly this one cert.
  38. """
  39. key = ec.generate_private_key(ec.SECP256R1())
  40. common_name = "Bambuddy Test Root CA"
  41. subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
  42. now = datetime.now(timezone.utc)
  43. cert = (
  44. x509.CertificateBuilder()
  45. .subject_name(subject)
  46. .issuer_name(subject)
  47. .public_key(key.public_key())
  48. .serial_number(x509.random_serial_number())
  49. .not_valid_before(now - timedelta(days=1))
  50. .not_valid_after(now + timedelta(days=3650))
  51. .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
  52. .sign(key, hashes.SHA256())
  53. )
  54. path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
  55. return common_name
  56. class _FakeResponse:
  57. """Stand-in for the ``http.client.HTTPResponse`` urllib hands back."""
  58. def __init__(self, body: bytes, status: int = 200):
  59. self.status = status
  60. self._body = body
  61. self._offset = 0
  62. def __enter__(self):
  63. return self
  64. def __exit__(self, *exc):
  65. return False
  66. def read(self, size: int) -> bytes:
  67. chunk = self._body[self._offset : self._offset + size]
  68. self._offset += len(chunk)
  69. return chunk
  70. class _OpenerCapture:
  71. """Captures the handlers ``build_opener`` was called with, and the Request
  72. the resulting opener was asked to open."""
  73. def __init__(self, response: _FakeResponse | None = None, raises: BaseException | None = None):
  74. self.handlers: tuple = ()
  75. self.request = None
  76. self._response = response or _FakeResponse(b"3MF")
  77. self._raises = raises
  78. def build_opener(self, *handlers):
  79. self.handlers = handlers
  80. opener = MagicMock()
  81. opener.open = self._open
  82. return opener
  83. def _open(self, request, timeout=None):
  84. self.request = request
  85. if self._raises is not None:
  86. raise self._raises
  87. return self._response
  88. def https_handler(self):
  89. for handler in self.handlers:
  90. if isinstance(handler, mw_https_handler_type()):
  91. return handler
  92. return None
  93. def mw_https_handler_type():
  94. from urllib.request import HTTPSHandler
  95. return HTTPSHandler
  96. def _patched_opener(capture: _OpenerCapture):
  97. """``_download_s3_urllib`` imports ``build_opener`` from ``urllib.request``
  98. at call time, so patching the module attribute is enough."""
  99. return patch("urllib.request.build_opener", side_effect=capture.build_opener)
  100. class TestS3TrustStore:
  101. """The regression under test: urllib must not fall back to the OS CA store."""
  102. @pytest.mark.asyncio
  103. async def test_opener_gets_an_https_handler(self):
  104. """Without an explicit HTTPSHandler, urllib builds its own from the OS
  105. trust store — which is exactly what fails on Windows (#2562)."""
  106. capture = _OpenerCapture()
  107. with _patched_opener(capture):
  108. await mw._download_s3_urllib(S3_URL, "benchy.3mf")
  109. handler = capture.https_handler()
  110. assert handler is not None, "opener was built without an HTTPSHandler — falls back to the OS trust store"
  111. assert isinstance(handler._context, ssl.SSLContext)
  112. @pytest.mark.asyncio
  113. async def test_context_is_loaded_from_certifi(self, tmp_path, monkeypatch):
  114. """Point certifi at a bundle holding one throwaway root; the opener's
  115. context must trust exactly that root and nothing else. Proves the CAs
  116. come from certifi rather than the system store."""
  117. ca_pem = tmp_path / "test-cacert.pem"
  118. common_name = _write_test_ca(ca_pem)
  119. monkeypatch.setattr(mw.certifi, "where", lambda: str(ca_pem))
  120. capture = _OpenerCapture()
  121. with _patched_opener(capture):
  122. await mw._download_s3_urllib(S3_URL, "benchy.3mf")
  123. loaded = capture.https_handler()._context.get_ca_certs()
  124. assert len(loaded) == 1, f"expected only the certifi bundle's cert, got {len(loaded)}"
  125. subject_values = [value for rdn in loaded[0]["subject"] for _, value in rdn]
  126. assert common_name in subject_values
  127. @pytest.mark.asyncio
  128. async def test_context_verifies_and_checks_hostname(self):
  129. """certifi swaps the CA source, not the verification policy — a context
  130. with verification off would 'fix' #2562 by disabling TLS security."""
  131. capture = _OpenerCapture()
  132. with _patched_opener(capture):
  133. await mw._download_s3_urllib(S3_URL, "benchy.3mf")
  134. context = capture.https_handler()._context
  135. assert context.verify_mode == ssl.CERT_REQUIRED
  136. assert context.check_hostname is True
  137. def test_real_context_trusts_the_certifi_bundle(self):
  138. """Sanity-check the un-mocked helper against the shipped bundle: it must
  139. load a real-world number of roots, not an empty set."""
  140. context = mw._s3_ssl_context()
  141. assert len(context.get_ca_certs()) == len(ssl.create_default_context(cafile=certifi.where()).get_ca_certs())
  142. assert len(context.get_ca_certs()) > 50
  143. class TestS3DownloadUnchanged:
  144. """Properties the TLS fix must not regress."""
  145. @pytest.mark.asyncio
  146. async def test_redirects_are_still_refused(self):
  147. """The host allowlist is only enforced on the initial URL, so following
  148. a 302 off S3 would bypass it. The no-redirect handler must survive."""
  149. capture = _OpenerCapture()
  150. with _patched_opener(capture):
  151. await mw._download_s3_urllib(S3_URL, "benchy.3mf")
  152. from urllib.request import HTTPRedirectHandler
  153. # build_opener takes handler classes *or* instances; the redirect
  154. # blocker is passed as a class, so normalise before probing it.
  155. blockers = []
  156. for handler in capture.handlers:
  157. instance = handler() if isinstance(handler, type) else handler
  158. if isinstance(instance, HTTPRedirectHandler):
  159. if instance.redirect_request(None, None, None, None, None) is None:
  160. blockers.append(instance)
  161. assert blockers, "no redirect-blocking handler passed to build_opener"
  162. @pytest.mark.asyncio
  163. async def test_url_reaches_the_transport_verbatim(self):
  164. """The whole reason this path uses urllib: S3 signs the exact
  165. query-string bytes. Any normalisation yields SignatureDoesNotMatch."""
  166. capture = _OpenerCapture()
  167. with _patched_opener(capture):
  168. await mw._download_s3_urllib(S3_URL, "benchy.3mf")
  169. assert capture.request.full_url == S3_URL
  170. @pytest.mark.asyncio
  171. async def test_returns_body_and_filename(self):
  172. capture = _OpenerCapture(response=_FakeResponse(b"PK\x03\x04payload"))
  173. with _patched_opener(capture):
  174. data, filename = await mw._download_s3_urllib(S3_URL, "benchy.3mf")
  175. assert data == b"PK\x03\x04payload"
  176. assert filename == "benchy.3mf"
  177. @pytest.mark.asyncio
  178. async def test_non_200_raises_unavailable(self):
  179. capture = _OpenerCapture(response=_FakeResponse(b"", status=403))
  180. with _patched_opener(capture), pytest.raises(mw.MakerWorldUnavailableError, match="HTTP 403"):
  181. await mw._download_s3_urllib(S3_URL, "benchy.3mf")
  182. @pytest.mark.asyncio
  183. async def test_size_cap_enforced(self, monkeypatch):
  184. monkeypatch.setattr(mw, "_MAX_3MF_BYTES", 1024)
  185. capture = _OpenerCapture(response=_FakeResponse(b"x" * 4096))
  186. with _patched_opener(capture), pytest.raises(mw.MakerWorldUnavailableError, match="exceeds"):
  187. await mw._download_s3_urllib(S3_URL, "benchy.3mf")
  188. @pytest.mark.asyncio
  189. async def test_tls_failure_still_surfaces_as_s3_download_failed(self):
  190. """If verification fails for a genuine reason (expired cert, MITM proxy),
  191. the user must still get the actionable wrapped error — the fix removes
  192. the spurious failures, it doesn't swallow the real ones."""
  193. verify_error = ssl.SSLCertVerificationError("certificate verify failed: unable to get local issuer certificate")
  194. capture = _OpenerCapture(raises=verify_error)
  195. with _patched_opener(capture), pytest.raises(mw.MakerWorldUnavailableError, match="S3 download failed"):
  196. await mw._download_s3_urllib(S3_URL, "benchy.3mf")