test_outbound_url_ssrf_guards.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763
  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. from backend.app.services.homeassistant import HomeAssistantService
  36. from backend.app.services.rest_smart_plug import RESTSmartPlugService
  37. from backend.app.services.tasmota import TasmotaService
  38. # Dangerous under any topology — both tiers must reject all of these.
  39. UNIVERSALLY_BLOCKED = [
  40. "file:///etc/passwd",
  41. "gopher://127.0.0.1:6379/_INFO",
  42. "ftp://internal.example.com/",
  43. "http://169.254.169.254/latest/meta-data/",
  44. "http://100.100.100.200/",
  45. "http://[fd00:ec2::254]/",
  46. "http://2130706433/",
  47. "http://0x7f000001/",
  48. "http://[::ffff:169.254.169.254]/",
  49. "http://0.0.0.0/",
  50. "http://239.255.255.250/",
  51. # The DNS-name form of the same target. Neither tier resolves hostnames,
  52. # but these are a fixed literal set, so matching them costs no lookup.
  53. "http://metadata.google.internal/",
  54. "http://METADATA.GOOGLE.INTERNAL/computeMetadata/v1/",
  55. "http://metadata.goog/",
  56. # Classic inet_aton notations other than a single number: libc and HTTP
  57. # clients resolve them to the same addresses.
  58. "http://0xa9.0xfe.0xa9.0xfe/",
  59. "http://0xA9.254.169.254/",
  60. "http://169.254.43518/",
  61. "http://169.16689662/",
  62. "http://0251.0376.0251.0376/",
  63. "http://0169.254.169.254/",
  64. "http://0x7f.1/",
  65. # A trailing dot is the same host.
  66. "http://169.254.169.254./",
  67. "http://metadata.google.internal./",
  68. "http://0x7f000001./",
  69. # Other dot characters, fullwidth digits and enclosed letters: HTTP clients
  70. # map them to the plain forms before connecting.
  71. "http://169\u3002254\u3002169\u3002254/",
  72. "http://169\uff0e254\uff0e169\uff0e254/",
  73. "http://169\uff61254\uff61169\uff61254/",
  74. "http://\uff11\uff16\uff19.\uff12\uff15\uff14.\uff11\uff16\uff19.\uff12\uff15\uff14/",
  75. "http://\u24dcetadata.google.internal/",
  76. "http://metadata\u3002google\u3002internal/",
  77. "http://0xa9\u30020xfe\u30020xa9\u30020xfe/",
  78. # Names that reach the metadata endpoint without a lookup of ours: a short
  79. # name the cloud's resolver completes, and the providers' own aliases.
  80. "http://metadata/computeMetadata/v1/",
  81. "http://metadata.google/",
  82. "http://instance-data/latest/meta-data/",
  83. "http://instance-data.ec2.internal/latest/meta-data/",
  84. "http://INSTANCE-DATA./",
  85. "http://metadata.tencentyun.com/latest/meta-data/",
  86. # Other providers' metadata addresses.
  87. "http://169.254.0.23/",
  88. "http://169.254.42.42/conf",
  89. "http://[fd00:42::42]/",
  90. "http://[fd00:c1::a9fe:a9fe]/",
  91. "http://[fd20:ce::254]/computeMetadata/v1/",
  92. "http://[fd00:a9fe:a9fe::1]/",
  93. "http://[fd00:ec2::23]/v1/credentials",
  94. "http://[fe80::a9fe:a9fe]/openstack/",
  95. "http://[fe80::a9fe:a9fe%25eth0]/",
  96. "http://api.metadata.cloud.ibm.com/metadata/v1/instance",
  97. # A zone id on an address that is not link-local is ignored on connect.
  98. "http://[fd00:ec2::254%251]/",
  99. "http://[fd00:ec2::254%25eth0]/",
  100. "http://[fd00:ec2::254%1]/",
  101. ]
  102. # The normal self-hosted topology — the LAN tier must permit all of these.
  103. LAN_ALLOWED = [
  104. "http://127.0.0.1:7912/",
  105. "http://localhost:3003",
  106. "http://192.168.1.50:8123",
  107. "http://10.0.0.7:3333",
  108. "http://172.16.4.9:8080",
  109. "https://ntfy.example.com/",
  110. "http://spoolman.lan:7912",
  111. "http://3dprinter.lan:8080",
  112. "http://node-01:8080",
  113. "http://192.168.1.50.:8123",
  114. "http://[fd12:3456::1]:8123",
  115. "http://xn--drucker-kche-dlb.lan:8080",
  116. "http://[fe80::1%25eth0]:8123",
  117. ]
  118. # ---------------------------------------------------------------------------
  119. # The LAN-service tier
  120. # ---------------------------------------------------------------------------
  121. @pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
  122. def test_lan_tier_rejects_universally_dangerous_targets(url: str):
  123. with pytest.raises(ValueError):
  124. assert_safe_lan_service_url(url, label="Test URL")
  125. @pytest.mark.parametrize("url", LAN_ALLOWED)
  126. def test_lan_tier_permits_the_normal_self_hosted_topology(url: str):
  127. """A blanket private-IP block here would break most real installs."""
  128. assert_safe_lan_service_url(url, label="Test URL")
  129. def test_lan_tier_names_the_field_in_its_error():
  130. with pytest.raises(ValueError, match="ntfy server URL"):
  131. assert_safe_lan_service_url("file:///etc/passwd", label="ntfy server URL")
  132. def test_spoolman_wrapper_keeps_its_user_facing_wording():
  133. """The wording is asserted by pre-existing tests; delegation must not change it."""
  134. with pytest.raises(ValueError, match="^Spoolman URL must use http or https$"):
  135. assert_safe_spoolman_url("file:///etc/passwd")
  136. with pytest.raises(ValueError, match="^Spoolman URL must not point to a cloud metadata endpoint$"):
  137. assert_safe_spoolman_url("http://169.254.169.254/")
  138. # ---------------------------------------------------------------------------
  139. # The public-internet tier
  140. # ---------------------------------------------------------------------------
  141. @pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
  142. def test_public_tier_rejects_universally_dangerous_targets(url: str):
  143. with pytest.raises(ValueError):
  144. assert_safe_public_https_url(url)
  145. @pytest.mark.parametrize(
  146. "url",
  147. [
  148. "https://127.0.0.1/",
  149. "https://192.168.1.5/",
  150. "https://10.1.2.3/",
  151. "https://[fe80::1]/",
  152. "https://[::ffff:127.0.0.1]/",
  153. "http://accounts.google.com/", # scheme must be https
  154. ],
  155. )
  156. def test_public_tier_additionally_rejects_private_and_plain_http(url: str):
  157. with pytest.raises(ValueError):
  158. assert_safe_public_https_url(url)
  159. # ---------------------------------------------------------------------------
  160. # OIDC issuer_url — the encoding bypasses the hand-rolled validator missed
  161. # ---------------------------------------------------------------------------
  162. @pytest.mark.parametrize(
  163. "url",
  164. [
  165. "https://2130706433/", # decimal-encoded 127.0.0.1
  166. "https://0x7f000001/", # hex-encoded 127.0.0.1
  167. "https://[::ffff:127.0.0.1]/", # IPv4-mapped loopback
  168. "https://[::ffff:169.254.169.254]/", # IPv4-mapped IMDS
  169. "https://0.0.0.0/",
  170. "https://239.255.255.250/",
  171. "https://169.254.169.254/",
  172. "https://127.0.0.1/",
  173. "https://192.168.1.5/",
  174. "http://idp.example.com/",
  175. ],
  176. )
  177. def test_issuer_url_rejects_encoded_and_private_targets(url: str):
  178. with pytest.raises(ValueError):
  179. OIDCProviderCreate(
  180. name="SSO",
  181. issuer_url=url,
  182. client_id="cid",
  183. client_secret="secret",
  184. )
  185. def test_issuer_url_update_is_guarded_too():
  186. """The update path matters most: it can change the issuer while the stored
  187. client_secret stays, which is the shape that would exfiltrate a real secret."""
  188. with pytest.raises(ValueError):
  189. OIDCProviderUpdate(issuer_url="https://[::ffff:127.0.0.1]/")
  190. def test_issuer_url_error_names_the_field_not_the_icon():
  191. with pytest.raises(ValueError, match="issuer_url"):
  192. OIDCProviderUpdate(issuer_url="https://127.0.0.1/")
  193. def test_a_real_idp_still_validates():
  194. provider = OIDCProviderCreate(
  195. name="SSO",
  196. issuer_url="https://accounts.google.com",
  197. client_id="cid",
  198. client_secret="secret",
  199. )
  200. assert provider.issuer_url == "https://accounts.google.com"
  201. # ---------------------------------------------------------------------------
  202. # Settings URLs
  203. # ---------------------------------------------------------------------------
  204. # Imported from the schema rather than duplicated, so the backstop below cannot
  205. # silently disagree with what is actually validated.
  206. LAN_SERVICE_SETTINGS = LAN_SERVICE_URL_SETTINGS
  207. @pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
  208. @pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
  209. def test_settings_urls_reject_dangerous_targets(field: str, url: str):
  210. with pytest.raises(ValueError):
  211. AppSettingsUpdate(**{field: url})
  212. @pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
  213. @pytest.mark.parametrize("url", LAN_ALLOWED)
  214. def test_settings_urls_permit_lan_hosts(field: str, url: str):
  215. assert AppSettingsUpdate(**{field: url})
  216. @pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
  217. @pytest.mark.parametrize("empty", ["", " "])
  218. def test_settings_urls_accept_empty_meaning_not_configured(field: str, empty: str):
  219. """Empty is the documented "fall back to the env var" value for all four."""
  220. assert AppSettingsUpdate(**{field: empty})
  221. @pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
  222. @pytest.mark.parametrize(
  223. "legacy",
  224. [
  225. "192.168.1.10:3333", # urlparse: scheme='', netloc='', hostname=None
  226. "localhost:3003", # urlparse: scheme='localhost' (!), hostname=None
  227. "obico.local:3333", # same trap, with dots
  228. "192.168.1.10",
  229. ],
  230. )
  231. def test_settings_urls_do_not_newly_reject_scheme_less_legacy_values(field: str, legacy: str):
  232. """Compatibility guard, not an endorsement.
  233. The settings inputs are plain text with no scheme enforcement, so values
  234. like these are already in the wild. They are inert — httpx raises
  235. UnsupportedProtocol, so no request is issued — and they were storable
  236. before the validator existed. Rejecting them now would block saves of
  237. unrelated fields bundled in the same request (the Obico panel auto-saves
  238. obico_ml_url alongside every other Obico setting).
  239. """
  240. assert AppSettingsUpdate(**{field: legacy})
  241. @pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
  242. def test_settings_urls_still_reject_a_real_non_http_scheme(field: str):
  243. """The leniency above is scoped to strings that are not URLs at all."""
  244. with pytest.raises(ValueError):
  245. AppSettingsUpdate(**{field: "file:///etc/passwd"})
  246. def test_every_url_setting_is_either_guarded_or_explicitly_exempt():
  247. """CI backstop: a new outbound-URL setting can't land unvalidated.
  248. Any new ``*_url`` field on AppSettingsUpdate must be added to the
  249. validator's field tuple or listed as exempt here with a reason. This
  250. catches the failure mode the original report correctly identified — guards
  251. added per-incident rather than to the whole class of fields.
  252. """
  253. exempt = {
  254. # Bambuddy's own address, not a destination it requests. It is
  255. # rendered into notification bodies and OIDC redirect URIs, and handed
  256. # to Obico's ML server as the `img` parameter for that server to fetch
  257. # (obico_detection.py builds `{external_url}/api/v1/obico/cached-frame/
  258. # {nonce}`). Pointing it at a private address only breaks Bambuddy's own
  259. # links; it cannot make Bambuddy request anything it otherwise wouldn't.
  260. "external_url",
  261. "bambuddy_internal_url",
  262. # Guarded by assert_safe_spoolman_url at each consumer (spoolman.py,
  263. # location_service.py, inventory.py, spoolbuddy.py,
  264. # spoolman_inventory.py) rather than in the schema, keeping its
  265. # established user-facing "Spoolman URL ..." error wording.
  266. "spoolman_url",
  267. # Not an HTTP URL: ldap:// or ldaps://, handed to an LDAP client, never
  268. # to httpx. The LAN-service guard requires http/https and would reject
  269. # every valid value. It also cannot reach a cloud-metadata endpoint,
  270. # since IMDS only speaks HTTP.
  271. "ldap_server_url",
  272. }
  273. url_fields = {name for name in AppSettingsUpdate.model_fields if name.endswith("_url")}
  274. unguarded = url_fields - set(LAN_SERVICE_SETTINGS) - exempt
  275. assert not unguarded, (
  276. f"New outbound URL setting(s) {sorted(unguarded)} are not covered by a "
  277. f"SSRF guard. Add them to AppSettingsUpdate._LAN_SERVICE_URL_FIELDS (or "
  278. f"the public-internet guard), or add them to `exempt` above with a reason."
  279. )
  280. # ---------------------------------------------------------------------------
  281. # Notification providers: URL guard + no response-body echo
  282. # ---------------------------------------------------------------------------
  283. def _response(status: int = 500, body: str = "root:x:0:0:root:/root:/bin/bash") -> httpx.Response:
  284. return httpx.Response(status_code=status, text=body, request=httpx.Request("POST", "http://10.0.0.1/"))
  285. SECRET_BODY = "root:x:0:0:root:/root:/bin/bash"
  286. def test_opaque_failure_does_not_return_the_response_body():
  287. message = ns._opaque_http_failure(_response(), label="webhook endpoint")
  288. assert SECRET_BODY not in message
  289. assert "500" in message, "the status code is still useful and is not sensitive"
  290. assert "webhook endpoint" in message
  291. def test_opaque_failure_logs_the_body_for_the_operator(caplog):
  292. """The body stays available to whoever administers the host — via logs,
  293. not via the API response."""
  294. with caplog.at_level("DEBUG", logger=ns.__name__):
  295. ns._opaque_http_failure(_response(), label="ntfy server")
  296. assert SECRET_BODY in caplog.text
  297. @pytest.mark.parametrize(
  298. "provider_label",
  299. ["ntfy server", "Bark server", "Gotify server", "webhook endpoint", "Home Assistant endpoint"],
  300. )
  301. def test_user_supplied_host_providers_use_the_opaque_path(provider_label: str):
  302. """Guards the mapping itself: each user-supplied-host provider must route
  303. its HTTP failure through _opaque_http_failure rather than formatting the
  304. body inline."""
  305. src = inspect.getsource(ns)
  306. assert f'_opaque_http_failure(response, label="{provider_label}")' in src
  307. def test_no_user_supplied_host_provider_formats_the_body_inline():
  308. """Any remaining ``response.text[:200]`` must belong to a host-pinned provider.
  309. Pushover/Telegram/CallMeBot/Discord all target hardcoded hosts (Discord via
  310. a webhook-prefix allowlist), so there is no trust boundary to cross.
  311. """
  312. src = inspect.getsource(ns).split("\n")
  313. # _send_telegram_message is the api.telegram.org call itself (#3046);
  314. # _send_telegram wraps it.
  315. host_pinned = {
  316. "_send_callmebot",
  317. "_send_pushover",
  318. "_send_telegram",
  319. "_send_telegram_message",
  320. "_send_discord",
  321. }
  322. current = None
  323. offenders = []
  324. for line in src:
  325. match = re.match(r"\s+async def (_send_\w+)", line)
  326. if match:
  327. current = match.group(1)
  328. if "response.text[:200]" in line and current not in host_pinned:
  329. offenders.append(current)
  330. assert not offenders, (
  331. f"{offenders} echo the upstream response body but do not target a "
  332. f"hardcoded host. Route the failure through _opaque_http_failure."
  333. )
  334. @pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
  335. def test_provider_url_guard_rejects_dangerous_targets(url: str):
  336. assert ns._assert_safe_provider_url(url, label="Webhook URL") is not None
  337. @pytest.mark.parametrize("url", LAN_ALLOWED)
  338. def test_provider_url_guard_permits_self_hosted_servers(url: str):
  339. assert ns._assert_safe_provider_url(url, label="ntfy server URL") is None
  340. @pytest.mark.asyncio
  341. @pytest.mark.parametrize(
  342. ("provider_type", "config"),
  343. [
  344. ("ntfy", {"server": "http://169.254.169.254", "topic": "t"}),
  345. ("bark", {"server": "http://169.254.169.254", "device_key": "k"}),
  346. ("gotify", {"server": "http://169.254.169.254", "app_token": "t"}),
  347. ("webhook", {"webhook_url": "http://169.254.169.254/latest/meta-data/"}),
  348. ],
  349. )
  350. async def test_test_config_refuses_metadata_targets_without_a_request(provider_type: str, config: dict, monkeypatch):
  351. """The end-to-end shape of the reported attack: an unsaved config aimed at
  352. IMDS via the test endpoint. It must be refused before any HTTP call."""
  353. called = False
  354. async def _fail_if_called(*_a, **_kw):
  355. nonlocal called
  356. called = True
  357. raise AssertionError("outbound request should not have been attempted")
  358. service = ns.NotificationService()
  359. monkeypatch.setattr(service, "_get_client", _fail_if_called)
  360. success, message = await service.send_test_notification(provider_type, config)
  361. assert success is False
  362. assert called is False
  363. assert "cloud metadata" in message
  364. # ---------------------------------------------------------------------------
  365. # Smart plugs: the same request-body-URL shape as the notification test endpoint
  366. # ---------------------------------------------------------------------------
  367. #
  368. # POST /smart-plugs/{ha,rest}/test-connection take their URL from the request
  369. # body and never persist it, so the schema-layer validator on ``ha_url`` does
  370. # not apply. Both are reachable with only ``SMART_PLUGS_CONTROL``, which the
  371. # default Operators group carries and which does NOT imply ``SETTINGS_UPDATE``
  372. # — identical to the notification case above.
  373. #
  374. # Both previously used hand-rolled checks that got the policy wrong in both
  375. # directions: the REST one rejected a literal ``127.0.0.1`` while allowing
  376. # every non-literal hostname, and the HA one matched three literal strings and
  377. # never parsed the hostname as an IP at all.
  378. @pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
  379. def test_rest_plug_guard_rejects_dangerous_targets(url: str):
  380. assert RESTSmartPlugService._validate_url(url) is False
  381. @pytest.mark.parametrize("url", LAN_ALLOWED)
  382. def test_rest_plug_guard_permits_the_normal_self_hosted_topology(url: str):
  383. """Includes literal 127.0.0.1, which the previous implementation rejected
  384. while accepting the equivalent "localhost" — a plug bridge on the same
  385. host could only be configured by spelling it one particular way."""
  386. assert RESTSmartPlugService._validate_url(url) is True
  387. @pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
  388. def test_ha_guard_rejects_dangerous_targets(url: str):
  389. assert HomeAssistantService._validate_url(url) is None
  390. @pytest.mark.parametrize("url", LAN_ALLOWED)
  391. def test_ha_guard_permits_the_normal_self_hosted_topology(url: str):
  392. assert HomeAssistantService._validate_url(url) is not None
  393. def test_ha_guard_still_normalises_the_url_it_returns():
  394. """Delegating the policy must not change what the caller gets back:
  395. scheme+host+port+path, with query and fragment dropped."""
  396. assert HomeAssistantService._validate_url("http://192.168.1.5:8123/base?x=1#f") == "http://192.168.1.5:8123/base"
  397. assert HomeAssistantService._validate_url("http://ha.lan") == "http://ha.lan"
  398. def test_ha_guard_keeps_ipv6_literals_bracketed():
  399. """urlparse strips the brackets off an IPv6 host; re-emitting it without
  400. them yields an unparseable URL that httpx cannot dial."""
  401. assert HomeAssistantService._validate_url("http://[fd00::1]:8123/api") == "http://[fd00::1]:8123/api"
  402. @pytest.mark.parametrize(
  403. "ip",
  404. [
  405. "169.254.169.254",
  406. "100.100.100.200",
  407. "fd00:ec2::254",
  408. "fd00:ec2::254%1",
  409. "0.0.0.0", # nosec B104 — rejection fixture, not a bind address: the assertion below is that the guard refuses it
  410. "239.255.255.250",
  411. ],
  412. )
  413. def test_tasmota_guard_rejects_metadata_and_misuse_addresses(ip: str):
  414. """Tasmota keeps its own stricter rule (bare IP literals only, loopback
  415. rejected — a plug is always a separate LAN device), but must not miss the
  416. destinations that are dangerous regardless of topology."""
  417. assert TasmotaService._validate_ip(ip) is False
  418. @pytest.mark.parametrize("ip", ["::ffff:169.254.169.254", "::ffff:100.100.100.200"])
  419. def test_tasmota_guard_unwraps_ipv4_mapped_ipv6(ip: str):
  420. assert TasmotaService._validate_ip(ip) is False
  421. @pytest.mark.parametrize("ip", ["192.168.1.50", "10.0.0.7", "172.16.4.9"])
  422. def test_tasmota_guard_still_permits_a_normal_lan_plug(ip: str):
  423. assert TasmotaService._validate_ip(ip) is True
  424. @pytest.mark.parametrize("ip", ["127.0.0.1", "tasmota.local", "not-an-ip"])
  425. def test_tasmota_guard_keeps_failing_closed_on_non_lan_device_values(ip: str):
  426. """Deliberately stricter than the shared LAN guard, and unchanged here."""
  427. assert TasmotaService._validate_ip(ip) is False
  428. @pytest.mark.asyncio
  429. @pytest.mark.parametrize(
  430. "target",
  431. ["http://169.254.169.254/", "http://100.100.100.200/", "http://metadata.google.internal/"],
  432. )
  433. async def test_rest_test_connection_refuses_metadata_without_a_request(target: str, monkeypatch):
  434. """End-to-end shape of the reported attack, mirroring the notification
  435. test above: an unsaved URL aimed at IMDS via the test endpoint must be
  436. refused before any HTTP call is made."""
  437. def _fail_if_called(*_a, **_kw):
  438. raise AssertionError("outbound request should not have been attempted")
  439. monkeypatch.setattr(httpx, "AsyncClient", _fail_if_called)
  440. result = await RESTSmartPlugService().test_connection(target, "GET", None)
  441. assert result["success"] is False
  442. assert "cloud metadata" in result["error"]
  443. @pytest.mark.asyncio
  444. @pytest.mark.parametrize(
  445. "target",
  446. ["http://169.254.169.254", "http://100.100.100.200", "http://metadata.google.internal"],
  447. )
  448. async def test_ha_test_connection_refuses_metadata_without_a_request(target: str, monkeypatch):
  449. def _fail_if_called(*_a, **_kw):
  450. raise AssertionError("outbound request should not have been attempted")
  451. monkeypatch.setattr(httpx, "AsyncClient", _fail_if_called)
  452. result = await HomeAssistantService().test_connection(target, "token")
  453. assert result["success"] is False
  454. @pytest.mark.asyncio
  455. @pytest.mark.parametrize("target", ["http://169.254.169.254", "http://metadata.google.internal"])
  456. async def test_obico_test_connection_refuses_metadata_without_a_request(target: str, monkeypatch):
  457. """Same shape again: obico_ml_url is guarded when saved via settings, but
  458. this route takes the URL from the request body and echoes the response."""
  459. from backend.app.services.obico_detection import ObicoDetectionService
  460. def _fail_if_called(*_a, **_kw):
  461. raise AssertionError("outbound request should not have been attempted")
  462. monkeypatch.setattr(httpx, "AsyncClient", _fail_if_called)
  463. result = await ObicoDetectionService().test_connection(target)
  464. assert result["ok"] is False
  465. assert result["body"] is None
  466. assert "cloud metadata" in result["error"]
  467. # ---------------------------------------------------------------------------
  468. # Drift backstop, part 2: URLs that arrive in a request body
  469. # ---------------------------------------------------------------------------
  470. #
  471. # `test_every_url_setting_is_either_guarded_or_explicitly_exempt` above only
  472. # walks `AppSettingsUpdate`. That is why the notification test endpoint, and
  473. # then the two smart-plug test endpoints, each had to be found by hand: a URL
  474. # that arrives in a request body and is never persisted is not a settings
  475. # field, so nothing enumerated it. This walks the live route table instead.
  476. def _request_body_url_fields() -> set[tuple[str, str]]:
  477. """Every (model, field) pair on a mutating route whose body carries a URL."""
  478. from fastapi.routing import APIRoute
  479. from pydantic import BaseModel
  480. from backend.app.main import app
  481. found: set[tuple[str, str]] = set()
  482. for route in app.routes:
  483. if not isinstance(route, APIRoute) or not ({"POST", "PUT", "PATCH"} & set(route.methods or ())):
  484. continue
  485. for param in route.dependant.body_params:
  486. # FastAPI moved the resolved annotation from `type_` onto
  487. # `field_info.annotation`; read both so this can't silently
  488. # enumerate nothing (which would make the assertions vacuous).
  489. annotation = getattr(param, "type_", None)
  490. if annotation is None:
  491. annotation = getattr(getattr(param, "field_info", None), "annotation", None)
  492. if not (isinstance(annotation, type) and issubclass(annotation, BaseModel)):
  493. continue
  494. for field in annotation.model_fields:
  495. if field == "url" or field.endswith("_url"):
  496. found.add((annotation.__name__, field))
  497. return found
  498. # Guarded: the handler (or the service it calls) puts the value through one of
  499. # the two tiers before any request is issued.
  500. GUARDED_BODY_URLS = {
  501. ("AppSettingsUpdate", "bambu_studio_api_url"),
  502. ("AppSettingsUpdate", "ha_url"),
  503. ("AppSettingsUpdate", "obico_ml_url"),
  504. ("AppSettingsUpdate", "orcaslicer_api_url"),
  505. ("AppSettingsUpdate", "spoolman_url"), # assert_safe_spoolman_url at each consumer
  506. ("HATestConnectionRequest", "url"), # homeassistant._validate_url
  507. ("RESTTestConnectionRequest", "url"), # rest_smart_plug._validate_url
  508. ("TestConnectionRequest", "url"), # obico_detection.test_connection
  509. # Manyfold (#1471): manyfold.config.normalize_url applies the LAN tier on
  510. # save and test, and ManyfoldService._send re-checks every redirect hop.
  511. ("ManyfoldConfigUpdate", "url"),
  512. ("ManyfoldTestRequest", "url"),
  513. ("OIDCProviderCreate", "issuer_url"), # public tier, via schemas.auth
  514. ("OIDCProviderCreate", "icon_url"),
  515. ("OIDCProviderUpdate", "issuer_url"),
  516. ("OIDCProviderUpdate", "icon_url"),
  517. # Gitea/Forgejo derive their API base from this and request it with the
  518. # stored token, so it is a real fetch target — guarded in
  519. # github_backup._enforce_private_repo, which both POST and PATCH funnel through.
  520. ("GitHubBackupConfigCreate", "repository_url"),
  521. ("GitHubBackupConfigUpdate", "repository_url"),
  522. ("GitHubTestConnectionRequest", "repo_url"), # same guard, in the test route
  523. # SmartPlug{Create,Update} persist these; every read goes back out through
  524. # RESTSmartPlugService._send_request, which applies the same guard.
  525. ("SmartPlugCreate", "rest_on_url"),
  526. ("SmartPlugCreate", "rest_off_url"),
  527. ("SmartPlugCreate", "rest_status_url"),
  528. ("SmartPlugCreate", "rest_power_url"),
  529. ("SmartPlugCreate", "rest_energy_url"),
  530. ("SmartPlugUpdate", "rest_on_url"),
  531. ("SmartPlugUpdate", "rest_off_url"),
  532. ("SmartPlugUpdate", "rest_status_url"),
  533. ("SmartPlugUpdate", "rest_power_url"),
  534. ("SmartPlugUpdate", "rest_energy_url"),
  535. }
  536. # Not a destination Bambuddy requests — no guard applies.
  537. NOT_A_FETCH_TARGET = {
  538. ("AppSettingsUpdate", "external_url"), # Bambuddy's own address (see exempt list above)
  539. ("AppSettingsUpdate", "bambuddy_internal_url"),
  540. ("AppSettingsUpdate", "ldap_server_url"), # ldap://, handed to an LDAP client
  541. ("ProjectCreate", "url"), # stored link, rendered in the UI, never fetched
  542. ("ProjectUpdate", "url"),
  543. ("BOMItemCreate", "sourcing_url"), # stored supplier link, never fetched
  544. ("BOMItemUpdate", "sourcing_url"),
  545. ("MakerWorldResolveRequest", "url"), # parsed for a model id; fetches go to a pinned CDN allowlist
  546. ("DeviceRegisterRequest", "backend_url"), # the device's view of Bambuddy's own address
  547. ("HeartbeatRequest", "backend_url"),
  548. ("SystemConfigRequest", "backend_url"),
  549. ("ExternalLinkCreate", "url"), # sidebar link, rendered in the UI, never requested
  550. ("ExternalLinkUpdate", "url"),
  551. ("AppMessage", "url"), # an app's link, appended to the notification text; never requested
  552. ("FileUpdate", "external_url"), # library file link (#3077), rendered in the UI, never fetched
  553. ("MaintenanceTypeCreate", "wiki_url"), # documentation link surfaced in the UI/notifications
  554. ("MaintenanceTypeUpdate", "wiki_url"),
  555. ("ArchiveUpdate", "external_url"), # stored source link for the model, never fetched
  556. }
  557. # Genuinely unguarded, and deliberately recorded rather than quietly exempted.
  558. # These reach `external_camera.capture_frame`, which dials rtsp:// as well as
  559. # http(s):// — the LAN-service guard rejects any non-HTTP scheme, so wiring it
  560. # up as-is would break every RTSP camera. Closing these needs a scheme-aware
  561. # variant of the guard, not a one-line delegation.
  562. KNOWN_UNGUARDED_NEEDS_SCHEME_AWARE_GUARD = {
  563. ("PrinterCreate", "external_camera_url"),
  564. ("PrinterCreate", "external_camera_snapshot_url"),
  565. ("PrinterUpdate", "external_camera_url"),
  566. ("PrinterUpdate", "external_camera_snapshot_url"),
  567. }
  568. def test_the_route_walk_actually_finds_something():
  569. """Guards the guard. If FastAPI's internals move again and the walk starts
  570. returning nothing, both assertions below pass vacuously and the backstop
  571. silently stops working — which is the exact failure it exists to prevent."""
  572. found = _request_body_url_fields()
  573. assert ("RESTTestConnectionRequest", "url") in found
  574. assert ("HATestConnectionRequest", "url") in found
  575. assert len(found) > 20
  576. def test_every_request_body_url_is_classified():
  577. """A new URL-bearing request field can't land without a decision.
  578. Add it to GUARDED_BODY_URLS once the handler runs it through a guard, or
  579. to NOT_A_FETCH_TARGET with the reason it is never requested. Do not add
  580. anything to KNOWN_UNGUARDED_* without also raising it.
  581. """
  582. classified = GUARDED_BODY_URLS | NOT_A_FETCH_TARGET | KNOWN_UNGUARDED_NEEDS_SCHEME_AWARE_GUARD
  583. unclassified = _request_body_url_fields() - classified
  584. assert not unclassified, (
  585. f"Unclassified request-body URL field(s): {sorted(unclassified)}. Route the value "
  586. f"through a guard and list it in GUARDED_BODY_URLS, or list it in NOT_A_FETCH_TARGET "
  587. f"with the reason it is never fetched."
  588. )
  589. def test_classification_lists_do_not_drift_from_the_routes():
  590. """The reverse direction: a stale entry means a route was renamed or
  591. removed and the list was not updated, which would hide the next one."""
  592. actual = _request_body_url_fields()
  593. stale = (GUARDED_BODY_URLS | NOT_A_FETCH_TARGET | KNOWN_UNGUARDED_NEEDS_SCHEME_AWARE_GUARD) - actual
  594. assert not stale, f"Classification entries no longer match any route: {sorted(stale)}"
  595. @pytest.mark.parametrize(
  596. "host",
  597. [
  598. "0xa9.0xfe.0xa9.0xfe",
  599. "169.254.43518",
  600. "0251.0376.0251.0376",
  601. "0169.254.169.254",
  602. "0x7f.1",
  603. "169.254.169.254.",
  604. "metadata.google.internal.",
  605. "127\u30020\u30020\u30021",
  606. "metadata",
  607. "instance-data.ec2.internal",
  608. "169\uff0e254\uff0e169\uff0e254",
  609. "api.metadata.cloud.ibm.com",
  610. ],
  611. )
  612. def test_public_tier_rejects_other_ipv4_notations_over_https(host: str):
  613. """Over https, so the scheme check cannot be what refuses them."""
  614. with pytest.raises(ValueError):
  615. assert_safe_public_https_url(f"https://{host}/icon.png")
  616. @pytest.mark.parametrize("host", ["169.254.1.1", "169.254.99.7", "169.254.255.254", "[::ffff:169.254.10.10]"])
  617. def test_lan_tier_refuses_the_whole_ipv4_link_local_range(host: str):
  618. """Where cloud providers put their metadata endpoints, listed or not."""
  619. with pytest.raises(ValueError):
  620. assert_safe_lan_service_url(f"http://{host}/", label="URL")
  621. def test_lan_tier_still_permits_ipv6_link_local():
  622. assert_safe_lan_service_url("http://[fe80::1]:8123/", label="URL")