Files
frigate/frigate/test/test_env.py
T
a3ca8742b0
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
Security patches (#24658)
* update version

* Restrict ffmpeg: go2rtc sources added through the API

go2rtc expands ffmpeg: sources into an exec: command line built from the
source's #key=value parameters, so the echo/expr/exec prefix check on
PUT /go2rtc/streams could be bypassed to run arbitrary ffmpeg arguments.

Sources submitted through the API now only accept the ffmpeg: parameters
the UI and docs use (video, audio, hardware with preset names, plus
numeric width/height/rotate/bitrate/timeout and async). Quotes,
whitespace, #raw, #input, #drawtext and unknown parameters are rejected
unless GO2RTC_ALLOW_ARBITRARY_EXEC is set. Sources defined in the config
file are unchanged.

Fixes GHSA-qjjw-3v66-279f

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Only allow existing streams on proxied go2rtc live paths

nginx proxies /live/mse/api/ws, /live/webrtc/api/ws and /api/go2rtc/webrtc
straight to go2rtc, which creates a new ad hoc stream from any src that is
not an existing stream name and starts the producer it describes. Any
authenticated user, including the built-in viewer role, could use this to
launch arbitrary go2rtc sources without going through the restricted-source
checks on PUT /go2rtc/streams.

The /auth subrequest now denies these paths unless every src names an
existing stream: a camera or its live stream, a go2rtc stream from the
config, or a stream go2rtc reports at runtime. This applies to every role,
and the runtime lookup fails closed when go2rtc cannot be reached.

Fixes GHSA-93xj-hg88-ffg7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ignore GO2RTC_ALLOW_ARBITRARY_EXEC in environment_vars

environment_vars installs every key into the Frigate process environment
at startup, so an admin could enable GO2RTC_ALLOW_ARBITRARY_EXEC from the
config and turn off the restricted go2rtc source checks on the API. The
override is meant to be set by the operator in the container environment
(or the Home Assistant add-on options), so it is now skipped with a
warning when it appears in environment_vars.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Apply go2rtc source restrictions to config/save and config/set

go2rtc.streams written through config/save or config/set were only
filtered at startup, which blocks echo/expr/exec and the map form but not
the ffmpeg: parameters that PUT /go2rtc/streams now rejects. Saving a
stream this way and restarting bypassed the API restriction.

Both endpoints now reject a change that adds a go2rtc stream source that
fails is_restricted_go2rtc_api_source, or a map-form source, unless
GO2RTC_ALLOW_ARBITRARY_EXEC is set. Sources already in the saved config
for the same stream are left alone, so streams configured on disk keep
working and resubmitting the whole file from the config editor succeeds.
config/set restores the previous file when it rejects a change.

Follow-up to GHSA-qjjw-3v66-279f

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Match go2rtc stream-proxy paths the way nginx does

/auth compared the raw $request_uri path against the three go2rtc
stream-proxy paths exactly, while nginx matches its prefix locations
against the normalized URI. Encoded characters, repeated slashes, dot
segments or a suffix on the path skipped the stream check even though
nginx still proxied the request to go2rtc.

The go2rtc stream locations now set a variable that the auth subrequest
forwards as X-Go2rtc-Stream-Proxy (client headers are not forwarded, so it
cannot be spoofed). As a fallback, /auth also normalizes the path like
nginx (percent-decoding, merging slashes, resolving dot segments) and
prefix matches it, and splits the raw URI by hand since urlparse reads a
leading // as a host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Parse config stream changes with Frigate's YAML loader

The go2rtc stream check on config/save and config/set parsed the config
with the safe loader and treated a parse error as an empty config, while
Frigate and the go2rtc startup script use the round-trip loader. Input
that only the round-trip loader accepts, such as a tagged root map, hid
new stream sources from the check.

The check now uses the same round-trip loader and rejects the request
when the new config cannot be parsed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Resolve go2rtc stream placeholders the same way everywhere

The go2rtc startup script expanded stream sources with str.format(),
which also supports attribute and index access, while the config API
check used substitute_frigate_vars and fell back to the raw string when a
placeholder could not be resolved. A placeholder the API check could not
resolve could therefore expand into a restricted source at startup.

The startup script now uses substitute_frigate_vars, like the rest of the
config, and the API check rejects a stream source whose placeholders
cannot be resolved. The startup script's own copy of the FRIGATE_* env
var map is removed; the shared one also honors CREDENTIALS_DIRECTORY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Accept codec sample rates in ffmpeg: go2rtc sources

The ffmpeg: parameter allowlist only accepted plain names for video,
audio and hardware, so the documented HomeKit source with
#audio=opus/16000 was rejected by PUT /go2rtc/streams, config/save and
config/set. Names may now carry a numeric /rate suffix. A rate without a
name is still rejected. All ffmpeg: sources in the docs pass the check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restore the environment after go2rtc exec override tests

Tests that removed or set GO2RTC_ALLOW_ARBITRARY_EXEC did not restore a
value that was already present, so later tests could run with a changed
security setting. They now use patch.dict on os.environ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Revert the go2rtc startup script to str.format substitution

Restores create_config.py to its state on master. The config API check
rejects any stream source whose {FRIGATE_*} placeholders it cannot
resolve with substitute_frigate_vars, and for every source it accepts,
str.format() at startup either produces the same string or fails and
stops go2rtc startup. Changing the startup script is therefore not
needed for the fix, and leaving it alone avoids a conflict with dev,
which already moved this script to the new env helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Explain why a go2rtc stream change was rejected

A stream source with an undefined or malformed {FRIGATE_*} placeholder
was reported as a restricted source, which reads like a security block
when the user made a typo. The config API now reports unknown variables
and invalid placeholders separately from restricted sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:10:05 -04:00

257 lines
10 KiB
Python

"""Tests for environment variable handling."""
import os
import unittest
from unittest.mock import MagicMock, patch
from frigate.config.env import (
FRIGATE_ENV_VARS,
validate_env_string,
validate_env_vars,
)
class TestGo2RtcAddStreamSubstitution(unittest.TestCase):
"""Covers the API path: PUT /go2rtc/streams/{stream_name}.
The route shells out to go2rtc via `requests.put`; we mock the HTTP call
and assert that the substituted `src` parameter handles the same mixed
{FRIGATE_*} + literal-brace strings as the config-loading path.
"""
def setUp(self):
self._original_env_vars = dict(FRIGATE_ENV_VARS)
def tearDown(self):
FRIGATE_ENV_VARS.clear()
FRIGATE_ENV_VARS.update(self._original_env_vars)
def _call_route(self, src: str) -> str:
"""Invoke go2rtc_add_stream and return the substituted src param."""
from frigate.api import camera as camera_api
captured = {}
def fake_put(url, params=None, timeout=None):
captured["params"] = params
resp = MagicMock()
resp.ok = True
resp.text = ""
resp.status_code = 200
return resp
with patch.object(camera_api.requests, "put", side_effect=fake_put):
camera_api.go2rtc_add_stream(
request=MagicMock(), stream_name="cam1", src=src
)
return captured["params"]["src"]
def test_mixed_localtime_and_frigate_var(self):
"""%{localtime\\:...} alongside {FRIGATE_USER} substitutes only the var."""
FRIGATE_ENV_VARS["FRIGATE_USER"] = "admin"
src = "rtsp://host/s?text=%{localtime\\:%Y-%m-%d}&user={FRIGATE_USER}"
self.assertEqual(
self._call_route(src),
"rtsp://host/s?text=%{localtime\\:%Y-%m-%d}&user=admin",
)
def test_unknown_var_falls_back_to_raw_src(self):
"""Existing route behavior: unknown {FRIGATE_*} keeps raw src."""
src = "rtsp://host/{FRIGATE_NONEXISTENT}/stream"
self.assertEqual(self._call_route(src), src)
def test_malformed_placeholder_rejected_via_api(self):
"""Malformed FRIGATE placeholders raise (not silently passed through).
Regression: previously camera.py caught any KeyError and fell back
to the raw src, so `{FRIGATE_FOO:>5}` was silently accepted via the
API while config loading rejected it. The helper now raises
ValueError for malformed syntax to keep the two paths consistent.
"""
with self.assertRaises(ValueError):
self._call_route("rtsp://host/{FRIGATE_FOO:>5}/stream")
class TestEnvString(unittest.TestCase):
def setUp(self):
self._original_env_vars = dict(FRIGATE_ENV_VARS)
def tearDown(self):
FRIGATE_ENV_VARS.clear()
FRIGATE_ENV_VARS.update(self._original_env_vars)
def test_substitution(self):
"""EnvString substitutes FRIGATE_ env vars."""
FRIGATE_ENV_VARS["FRIGATE_TEST_HOST"] = "192.168.1.100"
result = validate_env_string("{FRIGATE_TEST_HOST}")
self.assertEqual(result, "192.168.1.100")
def test_substitution_in_url(self):
"""EnvString substitutes vars embedded in a URL."""
FRIGATE_ENV_VARS["FRIGATE_CAM_USER"] = "admin"
FRIGATE_ENV_VARS["FRIGATE_CAM_PASS"] = "secret"
result = validate_env_string(
"rtsp://{FRIGATE_CAM_USER}:{FRIGATE_CAM_PASS}@10.0.0.1/stream"
)
self.assertEqual(result, "rtsp://admin:secret@10.0.0.1/stream")
def test_no_placeholder(self):
"""Plain strings pass through unchanged."""
result = validate_env_string("192.168.1.1")
self.assertEqual(result, "192.168.1.1")
def test_unknown_var_raises(self):
"""Referencing an unknown var raises KeyError."""
with self.assertRaises(KeyError):
validate_env_string("{FRIGATE_NONEXISTENT_VAR}")
def test_non_frigate_braces_passthrough(self):
"""Braces that are not {FRIGATE_*} placeholders pass through untouched.
Regression test for ffmpeg drawtext expressions like
"%{localtime\\:%Y-%m-%d}" being mangled by str.format().
"""
expr = (
"ffmpeg:rtsp://127.0.0.1/src#raw=-vf "
"drawtext=text=%{localtime\\:%Y-%m-%d_%H\\:%M\\:%S}"
":x=5:fontcolor=white"
)
self.assertEqual(validate_env_string(expr), expr)
def test_double_brace_escape_preserved(self):
"""`{{output}}` collapses to `{output}` (documented go2rtc escape)."""
result = validate_env_string(
"exec:ffmpeg -i /media/file.mp4 -f rtsp {{output}}"
)
self.assertEqual(result, "exec:ffmpeg -i /media/file.mp4 -f rtsp {output}")
def test_double_brace_around_frigate_var(self):
"""`{{FRIGATE_FOO}}` stays literal — escape takes precedence."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
self.assertEqual(validate_env_string("{{FRIGATE_FOO}}"), "{FRIGATE_FOO}")
def test_mixed_frigate_var_and_braces(self):
"""A FRIGATE_ var alongside literal single braces substitutes only the var."""
FRIGATE_ENV_VARS["FRIGATE_USER"] = "admin"
result = validate_env_string(
"drawtext=text=%{localtime}:user={FRIGATE_USER}:x=5"
)
self.assertEqual(result, "drawtext=text=%{localtime}:user=admin:x=5")
def test_triple_braces_around_frigate_var(self):
"""`{{{FRIGATE_FOO}}}` collapses like str.format(): `{bar}`."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
self.assertEqual(validate_env_string("{{{FRIGATE_FOO}}}"), "{bar}")
def test_trailing_double_brace_after_var(self):
"""`{FRIGATE_FOO}}}` collapses like str.format(): `bar}`."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
self.assertEqual(validate_env_string("{FRIGATE_FOO}}}"), "bar}")
def test_leading_double_brace_then_var(self):
"""`{{{FRIGATE_FOO}` collapses like str.format(): `{bar`."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
self.assertEqual(validate_env_string("{{{FRIGATE_FOO}"), "{bar")
def test_malformed_unterminated_placeholder_raises(self):
"""`{FRIGATE_FOO` (no closing brace) raises like str.format() did."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
with self.assertRaises(ValueError):
validate_env_string("prefix-{FRIGATE_FOO")
def test_malformed_format_spec_raises(self):
"""`{FRIGATE_FOO:>5}` (format spec) raises like str.format() did."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
with self.assertRaises(ValueError):
validate_env_string("{FRIGATE_FOO:>5}")
def test_malformed_conversion_raises(self):
"""`{FRIGATE_FOO!r}` (conversion) raises like str.format() did."""
FRIGATE_ENV_VARS["FRIGATE_FOO"] = "bar"
with self.assertRaises(ValueError):
validate_env_string("{FRIGATE_FOO!r}")
class TestEnvVars(unittest.TestCase):
def setUp(self):
self._original_env_vars = dict(FRIGATE_ENV_VARS)
self._original_environ = os.environ.copy()
def tearDown(self):
FRIGATE_ENV_VARS.clear()
FRIGATE_ENV_VARS.update(self._original_env_vars)
# Clean up any env vars we set
for key in list(os.environ.keys()):
if key not in self._original_environ:
del os.environ[key]
def _make_context(self, install: bool):
"""Create a mock ValidationInfo with the given install flag."""
class MockContext:
def __init__(self, ctx):
self.context = ctx
mock = MockContext({"install": install})
return mock
def test_install_sets_os_environ(self):
"""validate_env_vars with install=True sets os.environ."""
ctx = self._make_context(install=True)
validate_env_vars({"MY_CUSTOM_VAR": "value123"}, ctx)
self.assertEqual(os.environ.get("MY_CUSTOM_VAR"), "value123")
def test_install_updates_frigate_env_vars(self):
"""validate_env_vars with install=True updates FRIGATE_ENV_VARS for FRIGATE_ keys."""
ctx = self._make_context(install=True)
validate_env_vars({"FRIGATE_MQTT_PASS": "secret"}, ctx)
self.assertEqual(FRIGATE_ENV_VARS["FRIGATE_MQTT_PASS"], "secret")
def test_install_skips_non_frigate_in_env_vars_dict(self):
"""Non-FRIGATE_ keys are set in os.environ but not in FRIGATE_ENV_VARS."""
ctx = self._make_context(install=True)
validate_env_vars({"OTHER_VAR": "value"}, ctx)
self.assertEqual(os.environ.get("OTHER_VAR"), "value")
self.assertNotIn("OTHER_VAR", FRIGATE_ENV_VARS)
def test_no_install_does_not_set(self):
"""validate_env_vars without install=True does not modify state."""
ctx = self._make_context(install=False)
validate_env_vars({"FRIGATE_SKIP": "nope"}, ctx)
self.assertNotIn("FRIGATE_SKIP", FRIGATE_ENV_VARS)
self.assertNotIn("FRIGATE_SKIP", os.environ)
def test_env_vars_available_for_env_string(self):
"""Vars set via validate_env_vars are usable in validate_env_string."""
ctx = self._make_context(install=True)
validate_env_vars({"FRIGATE_BROKER": "mqtt.local"}, ctx)
result = validate_env_string("{FRIGATE_BROKER}")
self.assertEqual(result, "mqtt.local")
def test_install_ignores_go2rtc_exec_override(self):
"""The go2rtc exec override cannot be enabled from the config."""
from frigate.util.services import is_go2rtc_arbitrary_exec_allowed
env_patch = patch.dict(os.environ)
env_patch.start()
self.addCleanup(env_patch.stop)
os.environ.pop("GO2RTC_ALLOW_ARBITRARY_EXEC", None)
ctx = self._make_context(install=True)
with patch("frigate.util.services.os.path.isfile", return_value=False):
for key in ("GO2RTC_ALLOW_ARBITRARY_EXEC", "go2rtc_allow_arbitrary_exec"):
validate_env_vars({key: "true", "OTHER_VAR": "value"}, ctx)
self.assertNotIn(key, os.environ)
self.assertFalse(is_go2rtc_arbitrary_exec_allowed())
self.assertEqual(os.environ.get("OTHER_VAR"), "value")
def test_install_keeps_container_go2rtc_exec_override(self):
"""An override set in the container environment is left untouched."""
ctx = self._make_context(install=True)
with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
validate_env_vars({"GO2RTC_ALLOW_ARBITRARY_EXEC": "false"}, ctx)
self.assertEqual(os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC"), "true")
if __name__ == "__main__":
unittest.main()