test_external_camera_ssrf.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. """The RTSP camera paths must not become a request generator for arbitrary hosts.
  2. `_sanitize_camera_url` is the SSRF boundary for user-configured camera URLs. It
  3. was applied to the MJPEG and snapshot paths but not to the two RTSP ones, which
  4. handed the URL to `ffmpeg -i` unchecked — and ffmpeg's `-i` speaks http, tcp,
  5. file and everything else it was built with, so `camera_type=rtsp` was a way to
  6. name any destination and any protocol.
  7. Wiring the guard in is only half of it. The guard rebuilt URLs from
  8. `parsed.hostname`, which drops credentials and unbrackets IPv6 literals, and it
  9. recognised loopback by comparing against four spellings of it. So these tests
  10. pin three things at once: the RTSP paths refuse what they should, the guard
  11. recognises a destination however it is written, and a real camera — which
  12. usually means an authenticated one — still works.
  13. """
  14. from unittest.mock import AsyncMock, MagicMock, patch
  15. import pytest
  16. from backend.app.services.external_camera import (
  17. _blocked_host_reason,
  18. _capture_rtsp_frame,
  19. _safe_usb_device_path,
  20. _sanitize_camera_url,
  21. _stream_rtsp,
  22. )
  23. RTSP_SCHEMES = ("rtsp", "rtsps")
  24. HTTP_SCHEMES = ("http", "https")
  25. class TestTheHostsWeRefuse:
  26. """Loopback, the unspecified address and link-local, however they are spelled."""
  27. @pytest.mark.parametrize(
  28. "host",
  29. [
  30. "127.0.0.1",
  31. "127.0.0.2", # the whole 127/8 range, not just .1
  32. "127.1", # short form
  33. "2130706433", # decimal
  34. "0177.0.0.1", # octal
  35. "0x7f.0.0.1", # hex
  36. "[::1]",
  37. "[::ffff:127.0.0.1]", # loopback wearing an IPv6 spelling
  38. "localhost",
  39. "sub.localhost",
  40. ],
  41. )
  42. def test_loopback_is_refused(self, host):
  43. assert _sanitize_camera_url(f"rtsp://{host}:554/live", RTSP_SCHEMES) is None
  44. @pytest.mark.parametrize("host", ["0.0.0.0", "[::]"]) # nosec B104
  45. def test_the_unspecified_address_is_refused(self, host):
  46. assert _sanitize_camera_url(f"rtsp://{host}:554/live", RTSP_SCHEMES) is None
  47. @pytest.mark.parametrize(
  48. "host",
  49. [
  50. "169.254.169.254", # AWS/GCP/Azure metadata
  51. "169.254.1.1", # the rest of the range, not just the metadata IP
  52. "[fe80::1]",
  53. "metadata.google.internal",
  54. "metadata.google",
  55. ],
  56. )
  57. def test_link_local_and_metadata_are_refused(self, host):
  58. assert _sanitize_camera_url(f"rtsp://{host}/live", RTSP_SCHEMES) is None
  59. def test_the_reason_is_reported_for_logging(self):
  60. assert _blocked_host_reason("2130706433") == "loopback"
  61. assert _blocked_host_reason("169.254.169.254") is not None
  62. assert _blocked_host_reason("192.168.1.50") is None
  63. class TestTheCamerasWeAllow:
  64. """LAN is allowed on purpose — that is where cameras are."""
  65. @pytest.mark.parametrize(
  66. "url",
  67. [
  68. "rtsp://192.168.1.50:554/live",
  69. "rtsp://10.0.0.5/stream1",
  70. "rtsp://172.16.4.9:8554/cam",
  71. "rtsp://[fd00::1]:554/live", # unique-local IPv6
  72. "rtsp://cam.lan/live",
  73. "rtsps://camera.example.com:322/stream",
  74. ],
  75. )
  76. def test_a_camera_url_survives(self, url):
  77. assert _sanitize_camera_url(url, RTSP_SCHEMES) is not None
  78. def test_a_hostname_is_not_resolved(self):
  79. """A name that would resolve to loopback still passes.
  80. Not an oversight: aiohttp and ffmpeg resolve independently afterwards,
  81. so a lookup here decides nothing (DNS rebinding) while costing a DNS
  82. round trip on every capture. Pinned so the omission stays deliberate.
  83. """
  84. assert _sanitize_camera_url("rtsp://localtest.me/live", RTSP_SCHEMES) is not None
  85. class TestWhatTheGuardMustNotDestroy:
  86. """Most RTSP cameras carry their login in the URL. Stripping it would turn
  87. every one of them into an authentication failure — a worse outage than the
  88. hole being closed."""
  89. def test_credentials_survive(self):
  90. url = "rtsp://admin:hunter2@192.168.1.50:554/live"
  91. assert _sanitize_camera_url(url, RTSP_SCHEMES) == url
  92. def test_percent_encoded_credentials_survive_byte_for_byte(self):
  93. """urlparse's .username/.password are already decoded, so rebuilding
  94. from them would corrupt any password containing an @ or a :."""
  95. url = "rtsp://ad%40min:p%3Ass%40word@192.168.1.50:554/live"
  96. assert _sanitize_camera_url(url, RTSP_SCHEMES) == url
  97. def test_an_ipv6_literal_keeps_its_brackets(self):
  98. """Without them the result is not a URL any client can parse."""
  99. assert _sanitize_camera_url("rtsp://[fd00::1]:554/live", RTSP_SCHEMES) == "rtsp://[fd00::1]:554/live"
  100. def test_http_cameras_keep_their_basic_auth_too(self):
  101. url = "http://admin:hunter2@192.168.1.50/stream.mjpg"
  102. assert _sanitize_camera_url(url, HTTP_SCHEMES) == url
  103. def test_port_query_and_fragment_survive(self):
  104. url = "rtsp://192.168.1.50:8554/live?channel=2&subtype=1#frag"
  105. assert _sanitize_camera_url(url, RTSP_SCHEMES) == url
  106. class TestSchemeAllowlist:
  107. """What keeps an ffmpeg input a camera fetch rather than a fetch."""
  108. @pytest.mark.parametrize(
  109. "url",
  110. [
  111. "http://192.168.1.50:8080/internal",
  112. "https://192.168.1.50/internal",
  113. "tcp://192.168.1.50:22",
  114. "file:///etc/passwd",
  115. "concat:/etc/passwd",
  116. "udp://192.168.1.50:1234",
  117. "ftp://192.168.1.50/x",
  118. ],
  119. )
  120. def test_only_rtsp_reaches_the_rtsp_paths(self, url):
  121. assert _sanitize_camera_url(url, RTSP_SCHEMES) is None
  122. def test_rtsp_does_not_reach_the_http_paths(self):
  123. assert _sanitize_camera_url("rtsp://192.168.1.50/live", HTTP_SCHEMES) is None
  124. @pytest.mark.parametrize("url", ["", "not a url", "rtsp://", "://192.168.1.50/x"])
  125. def test_malformed_input_is_refused(self, url):
  126. assert _sanitize_camera_url(url, RTSP_SCHEMES) is None
  127. def _fake_ffmpeg():
  128. return patch("backend.app.services.external_camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg")
  129. def _spawn_spy(returncode: int | None = 0, stdout: bytes = b"\xff\xd8" + b"\x00" * 200):
  130. """Stand in for the ffmpeg subprocess, recording the argv it was handed.
  131. The streaming path reads until EOF, so stdout.read returns b"" and the
  132. generator finishes immediately — these tests are about whether ffmpeg was
  133. launched and with what, not about frame extraction.
  134. """
  135. process = MagicMock()
  136. process.returncode = returncode
  137. process.communicate = AsyncMock(return_value=(stdout, b""))
  138. process.stdout.read = AsyncMock(return_value=b"")
  139. process.stderr.read = AsyncMock(return_value=b"")
  140. process.wait = AsyncMock(return_value=returncode)
  141. process.kill = MagicMock()
  142. process.terminate = MagicMock()
  143. return patch(
  144. "backend.app.services.external_camera.asyncio.create_subprocess_exec",
  145. new=AsyncMock(return_value=process),
  146. )
  147. class TestRtspCaptureRefusesUnsafeUrls:
  148. """`_capture_rtsp_frame` — the one-shot path behind the test-connection
  149. endpoint, which takes url and camera_type straight off the query string."""
  150. @pytest.mark.asyncio
  151. @pytest.mark.parametrize(
  152. "url",
  153. [
  154. "http://127.0.0.1:8080/internal-service", # the reported PoC
  155. "http://192.168.1.100:8080/any-image.jpg",
  156. "file:///etc/passwd",
  157. "rtsp://127.0.0.1:554/live",
  158. "rtsp://2130706433:554/live",
  159. "rtsp://169.254.169.254/live",
  160. ],
  161. )
  162. async def test_no_process_is_spawned(self, url):
  163. with _fake_ffmpeg(), _spawn_spy() as spawn:
  164. assert await _capture_rtsp_frame(url, timeout=5) is None
  165. spawn.assert_not_awaited()
  166. @pytest.mark.asyncio
  167. async def test_a_real_camera_still_captures(self):
  168. with _fake_ffmpeg(), _spawn_spy() as spawn:
  169. frame = await _capture_rtsp_frame("rtsp://admin:hunter2@192.168.1.50:554/live", timeout=5)
  170. assert frame is not None
  171. cmd = spawn.await_args.args
  172. assert "rtsp://admin:hunter2@192.168.1.50:554/live" in cmd, (
  173. "the camera's credentials must reach ffmpeg or every authenticated camera breaks"
  174. )
  175. @pytest.mark.asyncio
  176. async def test_ffmpeg_is_confined_to_rtsp_protocols(self):
  177. """Belt and braces behind the scheme check: a stream that references
  178. something outside itself must not be able to pull it in."""
  179. with _fake_ffmpeg(), _spawn_spy() as spawn:
  180. await _capture_rtsp_frame("rtsp://192.168.1.50:554/live", timeout=5)
  181. cmd = spawn.await_args.args
  182. whitelist = cmd[cmd.index("-protocol_whitelist") + 1].split(",")
  183. assert "rtsp" in whitelist
  184. assert "file" not in whitelist
  185. assert "http" not in whitelist
  186. class TestRtspStreamRefusesUnsafeUrls:
  187. """`_stream_rtsp` — the live-view path, and the one the report missed."""
  188. @pytest.mark.asyncio
  189. @pytest.mark.parametrize(
  190. "url",
  191. [
  192. "http://127.0.0.1:8080/internal-service",
  193. "rtsp://127.0.0.1:554/live",
  194. "rtsp://[::ffff:127.0.0.1]:554/live",
  195. "file:///etc/passwd",
  196. ],
  197. )
  198. async def test_no_process_is_spawned(self, url):
  199. with _fake_ffmpeg(), _spawn_spy() as spawn:
  200. frames = [frame async for frame in _stream_rtsp(url, fps=5)]
  201. assert frames == []
  202. spawn.assert_not_awaited()
  203. @pytest.mark.asyncio
  204. async def test_a_real_camera_still_reaches_ffmpeg(self):
  205. with _fake_ffmpeg(), _spawn_spy(returncode=None) as spawn:
  206. [frame async for frame in _stream_rtsp("rtsp://admin:hunter2@192.168.1.50:554/live", fps=5)]
  207. spawn.assert_awaited_once()
  208. cmd = spawn.await_args.args
  209. assert "rtsp://admin:hunter2@192.168.1.50:554/live" in cmd
  210. assert "-protocol_whitelist" in cmd
  211. class TestUsbDevicePaths:
  212. """The USB paths take a device path from the same request field, and the
  213. streaming one used to check only that it started with /dev/video."""
  214. @pytest.mark.parametrize(
  215. "device",
  216. [
  217. "/dev/video/../../etc/passwd",
  218. "/dev/videos/../../etc/shadow",
  219. "/dev/video0; rm -rf /",
  220. "/etc/passwd",
  221. "/dev/video100", # three digits is not a device number
  222. "",
  223. ],
  224. )
  225. def test_a_path_that_is_not_a_device_node_is_refused(self, device):
  226. assert _safe_usb_device_path(device) is None
  227. def test_a_missing_device_is_refused(self):
  228. """Existence is part of the check — ffmpeg must never be pointed at a
  229. path just because it is shaped like one."""
  230. with patch("backend.app.services.external_camera.Path") as path_cls:
  231. path_cls.return_value.exists.return_value = False
  232. assert _safe_usb_device_path("/dev/video0") is None
  233. def test_the_path_is_rebuilt_from_the_device_number(self):
  234. with patch("backend.app.services.external_camera.Path") as path_cls:
  235. path_cls.return_value.exists.return_value = True
  236. path_cls.return_value.__str__.return_value = "/dev/video7"
  237. assert _safe_usb_device_path("/dev/video7") == "/dev/video7"
  238. path_cls.assert_called_once_with("/dev/video7")