test_outbound_url_ssrf_guards.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. """Outbound-URL SSRF policy: two tiers, applied consistently.
  2. Bambuddy makes outbound HTTP requests to hosts the operator configures. Which
  3. policy applies is a property of the *service*, not the caller:
  4. - LAN-service (Spoolman, ntfy, Bark, webhooks, Home Assistant, Obico ML, the
  5. slicer sidecars) — loopback and RFC-1918 MUST stay reachable, because
  6. self-hosting those next to Bambuddy is the normal topology. Blocking them
  7. would break most installs, which is why a blanket private-IP blocklist is
  8. the wrong fix here.
  9. - Public-internet (OIDC issuer and icon URLs) — a private address cannot be a
  10. real IdP, so it is a probe.
  11. Both tiers reject what is dangerous under any topology: non-HTTP schemes,
  12. numeric-encoded IPs, cloud-metadata endpoints, multicast/unspecified, and
  13. IPv4-mapped IPv6 encodings of the above.
  14. The separate concern covered here is *response-body echo*. Notification
  15. provider URLs are writable by anyone holding ``NOTIFICATIONS_CREATE`` — which
  16. the default Operators group carries and which does NOT imply
  17. ``SETTINGS_UPDATE`` — and ``POST /notifications/test-config`` takes the URL
  18. from the request body without persisting it. Returning the upstream body there
  19. made an intended reachability check into an authenticated read primitive
  20. against anything the process can reach. Providers whose host Bambuddy pins
  21. (Pushover, Telegram, CallMeBot, Discord) may still echo, since the caller
  22. cannot influence the destination.
  23. """
  24. from __future__ import annotations
  25. import inspect
  26. import re
  27. import httpx
  28. import pytest
  29. from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
  30. from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
  31. from backend.app.api.routes._url_safety import assert_safe_lan_service_url
  32. from backend.app.schemas.auth import OIDCProviderCreate, OIDCProviderUpdate
  33. from backend.app.schemas.settings import LAN_SERVICE_URL_SETTINGS, AppSettingsUpdate
  34. from backend.app.services import notification_service as ns
  35. # Dangerous under any topology — both tiers must reject all of these.
  36. UNIVERSALLY_BLOCKED = [
  37. "file:///etc/passwd",
  38. "gopher://127.0.0.1:6379/_INFO",
  39. "ftp://internal.example.com/",
  40. "http://169.254.169.254/latest/meta-data/",
  41. "http://100.100.100.200/",
  42. "http://[fd00:ec2::254]/",
  43. "http://2130706433/",
  44. "http://0x7f000001/",
  45. "http://[::ffff:169.254.169.254]/",
  46. "http://0.0.0.0/",
  47. "http://239.255.255.250/",
  48. ]
  49. # The normal self-hosted topology — the LAN tier must permit all of these.
  50. LAN_ALLOWED = [
  51. "http://127.0.0.1:7912/",
  52. "http://localhost:3003",
  53. "http://192.168.1.50:8123",
  54. "http://10.0.0.7:3333",
  55. "http://172.16.4.9:8080",
  56. "https://ntfy.example.com/",
  57. "http://spoolman.lan:7912",
  58. ]
  59. # ---------------------------------------------------------------------------
  60. # The LAN-service tier
  61. # ---------------------------------------------------------------------------
  62. @pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
  63. def test_lan_tier_rejects_universally_dangerous_targets(url: str):
  64. with pytest.raises(ValueError):
  65. assert_safe_lan_service_url(url, label="Test URL")
  66. @pytest.mark.parametrize("url", LAN_ALLOWED)
  67. def test_lan_tier_permits_the_normal_self_hosted_topology(url: str):
  68. """A blanket private-IP block here would break most real installs."""
  69. assert_safe_lan_service_url(url, label="Test URL")
  70. def test_lan_tier_names_the_field_in_its_error():
  71. with pytest.raises(ValueError, match="ntfy server URL"):
  72. assert_safe_lan_service_url("file:///etc/passwd", label="ntfy server URL")
  73. def test_spoolman_wrapper_keeps_its_user_facing_wording():
  74. """The wording is asserted by pre-existing tests; delegation must not change it."""
  75. with pytest.raises(ValueError, match="^Spoolman URL must use http or https$"):
  76. assert_safe_spoolman_url("file:///etc/passwd")
  77. with pytest.raises(ValueError, match="^Spoolman URL must not point to a cloud metadata endpoint$"):
  78. assert_safe_spoolman_url("http://169.254.169.254/")
  79. # ---------------------------------------------------------------------------
  80. # The public-internet tier
  81. # ---------------------------------------------------------------------------
  82. @pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
  83. def test_public_tier_rejects_universally_dangerous_targets(url: str):
  84. with pytest.raises(ValueError):
  85. assert_safe_public_https_url(url)
  86. @pytest.mark.parametrize(
  87. "url",
  88. [
  89. "https://127.0.0.1/",
  90. "https://192.168.1.5/",
  91. "https://10.1.2.3/",
  92. "https://[fe80::1]/",
  93. "https://[::ffff:127.0.0.1]/",
  94. "http://accounts.google.com/", # scheme must be https
  95. ],
  96. )
  97. def test_public_tier_additionally_rejects_private_and_plain_http(url: str):
  98. with pytest.raises(ValueError):
  99. assert_safe_public_https_url(url)
  100. # ---------------------------------------------------------------------------
  101. # OIDC issuer_url — the encoding bypasses the hand-rolled validator missed
  102. # ---------------------------------------------------------------------------
  103. @pytest.mark.parametrize(
  104. "url",
  105. [
  106. "https://2130706433/", # decimal-encoded 127.0.0.1
  107. "https://0x7f000001/", # hex-encoded 127.0.0.1
  108. "https://[::ffff:127.0.0.1]/", # IPv4-mapped loopback
  109. "https://[::ffff:169.254.169.254]/", # IPv4-mapped IMDS
  110. "https://0.0.0.0/",
  111. "https://239.255.255.250/",
  112. "https://169.254.169.254/",
  113. "https://127.0.0.1/",
  114. "https://192.168.1.5/",
  115. "http://idp.example.com/",
  116. ],
  117. )
  118. def test_issuer_url_rejects_encoded_and_private_targets(url: str):
  119. with pytest.raises(ValueError):
  120. OIDCProviderCreate(
  121. name="SSO",
  122. issuer_url=url,
  123. client_id="cid",
  124. client_secret="secret",
  125. )
  126. def test_issuer_url_update_is_guarded_too():
  127. """The update path matters most: it can change the issuer while the stored
  128. client_secret stays, which is the shape that would exfiltrate a real secret."""
  129. with pytest.raises(ValueError):
  130. OIDCProviderUpdate(issuer_url="https://[::ffff:127.0.0.1]/")
  131. def test_issuer_url_error_names_the_field_not_the_icon():
  132. with pytest.raises(ValueError, match="issuer_url"):
  133. OIDCProviderUpdate(issuer_url="https://127.0.0.1/")
  134. def test_a_real_idp_still_validates():
  135. provider = OIDCProviderCreate(
  136. name="SSO",
  137. issuer_url="https://accounts.google.com",
  138. client_id="cid",
  139. client_secret="secret",
  140. )
  141. assert provider.issuer_url == "https://accounts.google.com"
  142. # ---------------------------------------------------------------------------
  143. # Settings URLs
  144. # ---------------------------------------------------------------------------
  145. # Imported from the schema rather than duplicated, so the backstop below cannot
  146. # silently disagree with what is actually validated.
  147. LAN_SERVICE_SETTINGS = LAN_SERVICE_URL_SETTINGS
  148. @pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
  149. @pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
  150. def test_settings_urls_reject_dangerous_targets(field: str, url: str):
  151. with pytest.raises(ValueError):
  152. AppSettingsUpdate(**{field: url})
  153. @pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
  154. @pytest.mark.parametrize("url", LAN_ALLOWED)
  155. def test_settings_urls_permit_lan_hosts(field: str, url: str):
  156. assert AppSettingsUpdate(**{field: url})
  157. @pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
  158. @pytest.mark.parametrize("empty", ["", " "])
  159. def test_settings_urls_accept_empty_meaning_not_configured(field: str, empty: str):
  160. """Empty is the documented "fall back to the env var" value for all four."""
  161. assert AppSettingsUpdate(**{field: empty})
  162. @pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
  163. @pytest.mark.parametrize(
  164. "legacy",
  165. [
  166. "192.168.1.10:3333", # urlparse: scheme='', netloc='', hostname=None
  167. "localhost:3003", # urlparse: scheme='localhost' (!), hostname=None
  168. "obico.local:3333", # same trap, with dots
  169. "192.168.1.10",
  170. ],
  171. )
  172. def test_settings_urls_do_not_newly_reject_scheme_less_legacy_values(field: str, legacy: str):
  173. """Compatibility guard, not an endorsement.
  174. The settings inputs are plain text with no scheme enforcement, so values
  175. like these are already in the wild. They are inert — httpx raises
  176. UnsupportedProtocol, so no request is issued — and they were storable
  177. before the validator existed. Rejecting them now would block saves of
  178. unrelated fields bundled in the same request (the Obico panel auto-saves
  179. obico_ml_url alongside every other Obico setting).
  180. """
  181. assert AppSettingsUpdate(**{field: legacy})
  182. @pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
  183. def test_settings_urls_still_reject_a_real_non_http_scheme(field: str):
  184. """The leniency above is scoped to strings that are not URLs at all."""
  185. with pytest.raises(ValueError):
  186. AppSettingsUpdate(**{field: "file:///etc/passwd"})
  187. def test_every_url_setting_is_either_guarded_or_explicitly_exempt():
  188. """CI backstop: a new outbound-URL setting can't land unvalidated.
  189. Any new ``*_url`` field on AppSettingsUpdate must be added to the
  190. validator's field tuple or listed as exempt here with a reason. This
  191. catches the failure mode the original report correctly identified — guards
  192. added per-incident rather than to the whole class of fields.
  193. """
  194. exempt = {
  195. # Bambuddy's own public address, not a destination it requests. It is
  196. # rendered into notification bodies and OIDC redirect URIs, and handed
  197. # to Obico's ML server as the `img` parameter for that server to fetch
  198. # (obico_detection.py builds `{external_url}/api/v1/obico/cached-frame/
  199. # {nonce}`). Pointing it at a private address only breaks Bambuddy's own
  200. # links; it cannot make Bambuddy request anything it otherwise wouldn't.
  201. "external_url",
  202. # Guarded by assert_safe_spoolman_url at each consumer (spoolman.py,
  203. # location_service.py, inventory.py, spoolbuddy.py,
  204. # spoolman_inventory.py) rather than in the schema, keeping its
  205. # established user-facing "Spoolman URL ..." error wording.
  206. "spoolman_url",
  207. # Not an HTTP URL: ldap:// or ldaps://, handed to an LDAP client, never
  208. # to httpx. The LAN-service guard requires http/https and would reject
  209. # every valid value. It also cannot reach a cloud-metadata endpoint,
  210. # since IMDS only speaks HTTP.
  211. "ldap_server_url",
  212. }
  213. url_fields = {name for name in AppSettingsUpdate.model_fields if name.endswith("_url")}
  214. unguarded = url_fields - set(LAN_SERVICE_SETTINGS) - exempt
  215. assert not unguarded, (
  216. f"New outbound URL setting(s) {sorted(unguarded)} are not covered by a "
  217. f"SSRF guard. Add them to AppSettingsUpdate._LAN_SERVICE_URL_FIELDS (or "
  218. f"the public-internet guard), or add them to `exempt` above with a reason."
  219. )
  220. # ---------------------------------------------------------------------------
  221. # Notification providers: URL guard + no response-body echo
  222. # ---------------------------------------------------------------------------
  223. def _response(status: int = 500, body: str = "root:x:0:0:root:/root:/bin/bash") -> httpx.Response:
  224. return httpx.Response(status_code=status, text=body, request=httpx.Request("POST", "http://10.0.0.1/"))
  225. SECRET_BODY = "root:x:0:0:root:/root:/bin/bash"
  226. def test_opaque_failure_does_not_return_the_response_body():
  227. message = ns._opaque_http_failure(_response(), label="webhook endpoint")
  228. assert SECRET_BODY not in message
  229. assert "500" in message, "the status code is still useful and is not sensitive"
  230. assert "webhook endpoint" in message
  231. def test_opaque_failure_logs_the_body_for_the_operator(caplog):
  232. """The body stays available to whoever administers the host — via logs,
  233. not via the API response."""
  234. with caplog.at_level("DEBUG", logger=ns.__name__):
  235. ns._opaque_http_failure(_response(), label="ntfy server")
  236. assert SECRET_BODY in caplog.text
  237. @pytest.mark.parametrize(
  238. "provider_label",
  239. ["ntfy server", "Bark server", "webhook endpoint", "Home Assistant endpoint"],
  240. )
  241. def test_user_supplied_host_providers_use_the_opaque_path(provider_label: str):
  242. """Guards the mapping itself: each user-supplied-host provider must route
  243. its HTTP failure through _opaque_http_failure rather than formatting the
  244. body inline."""
  245. src = inspect.getsource(ns)
  246. assert f'_opaque_http_failure(response, label="{provider_label}")' in src
  247. def test_no_user_supplied_host_provider_formats_the_body_inline():
  248. """Any remaining ``response.text[:200]`` must belong to a host-pinned provider.
  249. Pushover/Telegram/CallMeBot/Discord all target hardcoded hosts (Discord via
  250. a webhook-prefix allowlist), so there is no trust boundary to cross.
  251. """
  252. src = inspect.getsource(ns).split("\n")
  253. host_pinned = {"_send_callmebot", "_send_pushover", "_send_telegram", "_send_discord"}
  254. current = None
  255. offenders = []
  256. for line in src:
  257. match = re.match(r"\s+async def (_send_\w+)", line)
  258. if match:
  259. current = match.group(1)
  260. if "response.text[:200]" in line and current not in host_pinned:
  261. offenders.append(current)
  262. assert not offenders, (
  263. f"{offenders} echo the upstream response body but do not target a "
  264. f"hardcoded host. Route the failure through _opaque_http_failure."
  265. )
  266. @pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
  267. def test_provider_url_guard_rejects_dangerous_targets(url: str):
  268. assert ns._assert_safe_provider_url(url, label="Webhook URL") is not None
  269. @pytest.mark.parametrize("url", LAN_ALLOWED)
  270. def test_provider_url_guard_permits_self_hosted_servers(url: str):
  271. assert ns._assert_safe_provider_url(url, label="ntfy server URL") is None
  272. @pytest.mark.asyncio
  273. @pytest.mark.parametrize(
  274. ("provider_type", "config"),
  275. [
  276. ("ntfy", {"server": "http://169.254.169.254", "topic": "t"}),
  277. ("bark", {"server": "http://169.254.169.254", "device_key": "k"}),
  278. ("webhook", {"webhook_url": "http://169.254.169.254/latest/meta-data/"}),
  279. ],
  280. )
  281. async def test_test_config_refuses_metadata_targets_without_a_request(provider_type: str, config: dict, monkeypatch):
  282. """The end-to-end shape of the reported attack: an unsaved config aimed at
  283. IMDS via the test endpoint. It must be refused before any HTTP call."""
  284. called = False
  285. async def _fail_if_called(*_a, **_kw):
  286. nonlocal called
  287. called = True
  288. raise AssertionError("outbound request should not have been attempted")
  289. service = ns.NotificationService()
  290. monkeypatch.setattr(service, "_get_client", _fail_if_called)
  291. success, message = await service.send_test_notification(provider_type, config)
  292. assert success is False
  293. assert called is False
  294. assert "cloud metadata" in message