Files
bambuddy/backend/tests/unit/test_external_camera_ssrf.py
T
maziggy 9fc2c6df2d fix(camera): stream an external RTSP camera that describes itself late (issue #3082)
An external camera could pass the connection test, play in VLC, and show a
black live view that gave up after a few seconds.

The two RTSP paths were not asking ffmpeg for the same thing. The one-shot
_capture_rtsp_frame passed no probe settings and got ffmpeg's defaults;
_stream_rtsp hard-coded -probesize 32 -analyzeduration 0. Thirty-two bytes
is enough for a camera that puts its H.264 parameters in the SDP, and not
enough for one that sends them in-band a moment later -- a WebRTC source
republished through go2rtc, in the reporter's case. ffmpeg then starts no
decoder and yields nothing at all, which is why the test button kept
passing while the live view stayed black.

Those settings were never chosen for external cameras: they arrived with
the P2S TLS proxy (#661) as fast-start tuning for the printer camera path,
where the source is a known Bambu model, and were copied here in the same
commit. This path has no model to tune against and belongs on the
defaults, which are a ceiling rather than a wait -- a camera that
announces itself in the first packet still starts as fast as it did.

Drop the probe cap from _stream_rtsp. Keep -fflags nobuffer and -flags
low_delay, which bear on how long ffmpeg sits on frames it already has
rather than how long it may look before it has any. Leave
_capture_rtsp_frame and the per-model printer profiles alone.

Tests pin the absence of both flags, the presence of the low-latency ones,
and the property underneath: both RTSP paths must probe alike, or passing
the test button again means nothing about the live view. The ffmpeg
subprocess fakes move to backend/tests/_fixtures/external_camera.py so the
SSRF suite and this one share one definition.
2026-09-19 10:52:15 +02:00

267 lines
10 KiB
Python

"""The RTSP camera paths must not become a request generator for arbitrary hosts.
`_sanitize_camera_url` is the SSRF boundary for user-configured camera URLs. It
was applied to the MJPEG and snapshot paths but not to the two RTSP ones, which
handed the URL to `ffmpeg -i` unchecked — and ffmpeg's `-i` speaks http, tcp,
file and everything else it was built with, so `camera_type=rtsp` was a way to
name any destination and any protocol.
Wiring the guard in is only half of it. The guard rebuilt URLs from
`parsed.hostname`, which drops credentials and unbrackets IPv6 literals, and it
recognised loopback by comparing against four spellings of it. So these tests
pin three things at once: the RTSP paths refuse what they should, the guard
recognises a destination however it is written, and a real camera — which
usually means an authenticated one — still works.
"""
from unittest.mock import patch
import pytest
from backend.app.services.external_camera import (
_blocked_host_reason,
_capture_rtsp_frame,
_safe_usb_device_path,
_sanitize_camera_url,
_stream_rtsp,
)
from backend.tests._fixtures.external_camera import fake_ffmpeg, spawn_spy
RTSP_SCHEMES = ("rtsp", "rtsps")
HTTP_SCHEMES = ("http", "https")
class TestTheHostsWeRefuse:
"""Loopback, the unspecified address and link-local, however they are spelled."""
@pytest.mark.parametrize(
"host",
[
"127.0.0.1",
"127.0.0.2", # the whole 127/8 range, not just .1
"127.1", # short form
"2130706433", # decimal
"0177.0.0.1", # octal
"0x7f.0.0.1", # hex
"[::1]",
"[::ffff:127.0.0.1]", # loopback wearing an IPv6 spelling
"localhost",
"sub.localhost",
],
)
def test_loopback_is_refused(self, host):
assert _sanitize_camera_url(f"rtsp://{host}:554/live", RTSP_SCHEMES) is None
@pytest.mark.parametrize("host", ["0.0.0.0", "[::]"]) # nosec B104
def test_the_unspecified_address_is_refused(self, host):
assert _sanitize_camera_url(f"rtsp://{host}:554/live", RTSP_SCHEMES) is None
@pytest.mark.parametrize(
"host",
[
"169.254.169.254", # AWS/GCP/Azure metadata
"169.254.1.1", # the rest of the range, not just the metadata IP
"[fe80::1]",
"metadata.google.internal",
"metadata.google",
],
)
def test_link_local_and_metadata_are_refused(self, host):
assert _sanitize_camera_url(f"rtsp://{host}/live", RTSP_SCHEMES) is None
def test_the_reason_is_reported_for_logging(self):
assert _blocked_host_reason("2130706433") == "loopback"
assert _blocked_host_reason("169.254.169.254") is not None
assert _blocked_host_reason("192.168.1.50") is None
class TestTheCamerasWeAllow:
"""LAN is allowed on purpose — that is where cameras are."""
@pytest.mark.parametrize(
"url",
[
"rtsp://192.168.1.50:554/live",
"rtsp://10.0.0.5/stream1",
"rtsp://172.16.4.9:8554/cam",
"rtsp://[fd00::1]:554/live", # unique-local IPv6
"rtsp://cam.lan/live",
"rtsps://camera.example.com:322/stream",
],
)
def test_a_camera_url_survives(self, url):
assert _sanitize_camera_url(url, RTSP_SCHEMES) is not None
def test_a_hostname_is_not_resolved(self):
"""A name that would resolve to loopback still passes.
Not an oversight: aiohttp and ffmpeg resolve independently afterwards,
so a lookup here decides nothing (DNS rebinding) while costing a DNS
round trip on every capture. Pinned so the omission stays deliberate.
"""
assert _sanitize_camera_url("rtsp://localtest.me/live", RTSP_SCHEMES) is not None
class TestWhatTheGuardMustNotDestroy:
"""Most RTSP cameras carry their login in the URL. Stripping it would turn
every one of them into an authentication failure — a worse outage than the
hole being closed."""
def test_credentials_survive(self):
url = "rtsp://admin:hunter2@192.168.1.50:554/live"
assert _sanitize_camera_url(url, RTSP_SCHEMES) == url
def test_percent_encoded_credentials_survive_byte_for_byte(self):
"""urlparse's .username/.password are already decoded, so rebuilding
from them would corrupt any password containing an @ or a :."""
url = "rtsp://ad%40min:p%3Ass%40word@192.168.1.50:554/live"
assert _sanitize_camera_url(url, RTSP_SCHEMES) == url
def test_an_ipv6_literal_keeps_its_brackets(self):
"""Without them the result is not a URL any client can parse."""
assert _sanitize_camera_url("rtsp://[fd00::1]:554/live", RTSP_SCHEMES) == "rtsp://[fd00::1]:554/live"
def test_http_cameras_keep_their_basic_auth_too(self):
url = "http://admin:hunter2@192.168.1.50/stream.mjpg"
assert _sanitize_camera_url(url, HTTP_SCHEMES) == url
def test_port_query_and_fragment_survive(self):
url = "rtsp://192.168.1.50:8554/live?channel=2&subtype=1#frag"
assert _sanitize_camera_url(url, RTSP_SCHEMES) == url
class TestSchemeAllowlist:
"""What keeps an ffmpeg input a camera fetch rather than a fetch."""
@pytest.mark.parametrize(
"url",
[
"http://192.168.1.50:8080/internal",
"https://192.168.1.50/internal",
"tcp://192.168.1.50:22",
"file:///etc/passwd",
"concat:/etc/passwd",
"udp://192.168.1.50:1234",
"ftp://192.168.1.50/x",
],
)
def test_only_rtsp_reaches_the_rtsp_paths(self, url):
assert _sanitize_camera_url(url, RTSP_SCHEMES) is None
def test_rtsp_does_not_reach_the_http_paths(self):
assert _sanitize_camera_url("rtsp://192.168.1.50/live", HTTP_SCHEMES) is None
@pytest.mark.parametrize("url", ["", "not a url", "rtsp://", "://192.168.1.50/x"])
def test_malformed_input_is_refused(self, url):
assert _sanitize_camera_url(url, RTSP_SCHEMES) is None
class TestRtspCaptureRefusesUnsafeUrls:
"""`_capture_rtsp_frame` — the one-shot path behind the test-connection
endpoint, which takes url and camera_type straight off the query string."""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"url",
[
"http://127.0.0.1:8080/internal-service", # the reported PoC
"http://192.168.1.100:8080/any-image.jpg",
"file:///etc/passwd",
"rtsp://127.0.0.1:554/live",
"rtsp://2130706433:554/live",
"rtsp://169.254.169.254/live",
],
)
async def test_no_process_is_spawned(self, url):
with fake_ffmpeg(), spawn_spy() as spawn:
assert await _capture_rtsp_frame(url, timeout=5) is None
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_a_real_camera_still_captures(self):
with fake_ffmpeg(), spawn_spy() as spawn:
frame = await _capture_rtsp_frame("rtsp://admin:hunter2@192.168.1.50:554/live", timeout=5)
assert frame is not None
cmd = spawn.await_args.args
assert "rtsp://admin:hunter2@192.168.1.50:554/live" in cmd, (
"the camera's credentials must reach ffmpeg or every authenticated camera breaks"
)
@pytest.mark.asyncio
async def test_ffmpeg_is_confined_to_rtsp_protocols(self):
"""Belt and braces behind the scheme check: a stream that references
something outside itself must not be able to pull it in."""
with fake_ffmpeg(), spawn_spy() as spawn:
await _capture_rtsp_frame("rtsp://192.168.1.50:554/live", timeout=5)
cmd = spawn.await_args.args
whitelist = cmd[cmd.index("-protocol_whitelist") + 1].split(",")
assert "rtsp" in whitelist
assert "file" not in whitelist
assert "http" not in whitelist
class TestRtspStreamRefusesUnsafeUrls:
"""`_stream_rtsp` — the live-view path, and the one the report missed."""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"url",
[
"http://127.0.0.1:8080/internal-service",
"rtsp://127.0.0.1:554/live",
"rtsp://[::ffff:127.0.0.1]:554/live",
"file:///etc/passwd",
],
)
async def test_no_process_is_spawned(self, url):
with fake_ffmpeg(), spawn_spy() as spawn:
frames = [frame async for frame in _stream_rtsp(url, fps=5)]
assert frames == []
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_a_real_camera_still_reaches_ffmpeg(self):
with fake_ffmpeg(), spawn_spy(returncode=None) as spawn:
[frame async for frame in _stream_rtsp("rtsp://admin:hunter2@192.168.1.50:554/live", fps=5)]
spawn.assert_awaited_once()
cmd = spawn.await_args.args
assert "rtsp://admin:hunter2@192.168.1.50:554/live" in cmd
assert "-protocol_whitelist" in cmd
class TestUsbDevicePaths:
"""The USB paths take a device path from the same request field, and the
streaming one used to check only that it started with /dev/video."""
@pytest.mark.parametrize(
"device",
[
"/dev/video/../../etc/passwd",
"/dev/videos/../../etc/shadow",
"/dev/video0; rm -rf /",
"/etc/passwd",
"/dev/video100", # three digits is not a device number
"",
],
)
def test_a_path_that_is_not_a_device_node_is_refused(self, device):
assert _safe_usb_device_path(device) is None
def test_a_missing_device_is_refused(self):
"""Existence is part of the check — ffmpeg must never be pointed at a
path just because it is shaped like one."""
with patch("backend.app.services.external_camera.Path") as path_cls:
path_cls.return_value.exists.return_value = False
assert _safe_usb_device_path("/dev/video0") is None
def test_the_path_is_rebuilt_from_the_device_number(self):
with patch("backend.app.services.external_camera.Path") as path_cls:
path_cls.return_value.exists.return_value = True
path_cls.return_value.__str__.return_value = "/dev/video7"
assert _safe_usb_device_path("/dev/video7") == "/dev/video7"
path_cls.assert_called_once_with("/dev/video7")