From 39567b37ebcca7aeeb7d330741e01438f182c08c Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:31:22 -0500 Subject: [PATCH] restrict go2rtc ${VAR} expansion in stream sources go2rtc expands ${NAME:default} over the raw config text before parsing it, and falls back to the inline default when NAME is unset, so a stream source like ${NOPE:exec:} passed the exec gate and go2rtc then ran the command. On master the API accepted the plain form, and startup accepted the ${{...}} form because .format collapsed it to ${...} after the check would have caught it. Any stream source containing ${ is now restricted unless GO2RTC_ALLOW_ARBITRARY_EXEC is set, the same as exec sources. --- .../rootfs/usr/local/go2rtc/create_config.py | 6 +-- docs/docs/configuration/restream.md | 2 + .../test_http_config_go2rtc_streams.py | 24 +++++++++++ frigate/test/test_go2rtc_exec_config.py | 40 ++++++++++++++++++- frigate/util/services.py | 12 ++++-- 5 files changed, 77 insertions(+), 7 deletions(-) diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index b26e138f9b..de1cbd7182 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -115,7 +115,7 @@ for name in list(go2rtc_config.get("streams", {})): formatted_stream = stream.format(**FRIGATE_ENV_VARS) if is_restricted_go2rtc_source(formatted_stream): print( - f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. " + f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec or ${{VAR}}) which is disabled by default for security. " f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources." ) del go2rtc_config["streams"][name] @@ -134,7 +134,7 @@ for name in list(go2rtc_config.get("streams", {})): formatted_stream = stream_item.format(**FRIGATE_ENV_VARS) if is_restricted_go2rtc_source(formatted_stream): print( - f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. " + f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec or ${{VAR}}) which is disabled by default for security. " f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources." ) continue @@ -150,7 +150,7 @@ for name in list(go2rtc_config.get("streams", {})): go2rtc_config["streams"][name] = filtered_streams else: print( - f"[ERROR] Stream '{name}' was removed because all sources were restricted (echo/expr/exec). " + f"[ERROR] Stream '{name}' was removed because all sources were restricted (echo/expr/exec or ${{VAR}}). " f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources." ) del go2rtc_config["streams"][name] diff --git a/docs/docs/configuration/restream.md b/docs/docs/configuration/restream.md index e428250ce0..6dab252415 100644 --- a/docs/docs/configuration/restream.md +++ b/docs/docs/configuration/restream.md @@ -221,6 +221,8 @@ Any `#` parameter on a bare `rtsp://` source disables the backchannel unless the For security reasons, the `echo:`, `expr:`, and `exec:` stream sources are disabled by default in go2rtc. These sources allow arbitrary command execution and can pose security risks if misconfigured. +go2rtc's `${VAR:default}` variable syntax is also disabled by default in stream sources, since its default can name one of those sources. Use Frigate's [`{FRIGATE_*}` substitution](./config.md#environment-variable-substitution) instead. + If you attempt to use these sources in your configuration, the streams will be removed and an error message will be printed in the logs. To enable these sources, you must set the environment variable `GO2RTC_ALLOW_ARBITRARY_EXEC=true`. This can be done in your Docker Compose file or container environment: diff --git a/frigate/test/http_api/test_http_config_go2rtc_streams.py b/frigate/test/http_api/test_http_config_go2rtc_streams.py index 8498cc7591..0eac972646 100644 --- a/frigate/test/http_api/test_http_config_go2rtc_streams.py +++ b/frigate/test/http_api/test_http_config_go2rtc_streams.py @@ -313,3 +313,27 @@ class TestConfigGo2rtcStreamRestrictions(BaseTestHttp): ) assert resp.status_code == 400 assert self._read_config() == before + + # --- go2rtc ${VAR} expansion --- + + def test_set_rejects_var_expansion_source(self): + before = self._read_config() + resp = self._set_streams({"new_stream": "${NOPE:exec:touch /tmp/pwned}"}) + assert resp.status_code == 400 + assert "Restricted go2rtc stream source" in resp.json()["message"] + assert self._read_config() == before + + def test_set_rejects_escaped_var_expansion_source(self): + before = self._read_config() + resp = self._set_streams({"new_stream": "${{NOPE:exec:id}}"}) + assert resp.status_code == 400 + assert "Restricted go2rtc stream source" in resp.json()["message"] + assert self._read_config() == before + + def test_set_allows_var_expansion_source_when_override_set(self): + with patch( + "frigate.util.services.is_go2rtc_arbitrary_exec_allowed", + return_value=True, + ): + resp = self._set_streams({"new_stream": "${NOPE:exec:id}"}) + assert resp.status_code == 200, resp.text diff --git a/frigate/test/test_go2rtc_exec_config.py b/frigate/test/test_go2rtc_exec_config.py index d61197f450..4e26f37338 100644 --- a/frigate/test/test_go2rtc_exec_config.py +++ b/frigate/test/test_go2rtc_exec_config.py @@ -4,7 +4,11 @@ import os import unittest from unittest.mock import patch -from frigate.util.services import apply_go2rtc_ffmpeg_config +from frigate.util.services import ( + apply_go2rtc_ffmpeg_config, + is_restricted_go2rtc_api_source, + is_restricted_go2rtc_source, +) FFMPEG_PATH = "/usr/lib/ffmpeg/7.0/bin/ffmpeg" @@ -47,5 +51,39 @@ class TestApplyGo2rtcFfmpegConfig(unittest.TestCase): assert config["ngrok"] == {"command": "ngrok tcp 8555"} +class TestRestrictedGo2rtcVarExpansion(unittest.TestCase): + """go2rtc expands `${VAR:default}` over the raw config before parsing, and + the default can name an exec source, so a `${` source is restricted.""" + + def setUp(self): + exec_patch = patch( + "frigate.util.services.is_go2rtc_arbitrary_exec_allowed", + return_value=False, + ) + self.mock_exec_allowed = exec_patch.start() + self.addCleanup(exec_patch.stop) + + def test_var_expansion_source_is_restricted(self): + for source in ( + "${NOPE:exec:touch /tmp/x}", + "rtsp://host/${CAM}", + " ${NOPE:exec:id}", + ): + self.assertTrue(is_restricted_go2rtc_source(source), source) + self.assertTrue(is_restricted_go2rtc_api_source(source), source) + + def test_plain_sources_are_not_restricted(self): + for source in ( + "rtsp://user:pass@host/stream", + "ffmpeg:front#video=h264#height=720", + ): + self.assertFalse(is_restricted_go2rtc_source(source), source) + self.assertFalse(is_restricted_go2rtc_api_source(source), source) + + def test_var_expansion_allowed_when_override_set(self): + self.mock_exec_allowed.return_value = True + self.assertFalse(is_restricted_go2rtc_source("${NOPE:exec:id}")) + + if __name__ == "__main__": unittest.main() diff --git a/frigate/util/services.py b/frigate/util/services.py index f6d985ce4a..0a606cc0a5 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -980,9 +980,15 @@ def is_go2rtc_arbitrary_exec_allowed() -> bool: def is_restricted_go2rtc_source(stream_source: str) -> bool: - """Check if a stream source is a restricted type (echo, expr, or exec) - and the GO2RTC_ALLOW_ARBITRARY_EXEC override is not set.""" - if not stream_source.strip().startswith(("echo:", "expr:", "exec:")): + """Check if a stream source is restricted and the + GO2RTC_ALLOW_ARBITRARY_EXEC override is not set. + + Restricted sources are the echo, expr and exec schemes, and any source + using go2rtc's `${VAR:default}` expansion, which go2rtc runs over the raw + config before parsing and whose default can name an exec source. + """ + source = stream_source.strip() + if not source.startswith(("echo:", "expr:", "exec:")) and "${" not in source: return False return not is_go2rtc_arbitrary_exec_allowed()