Compare commits

..
Author SHA1 Message Date
Josh Hawkins 39567b37eb 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:<cmd>} 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.
2026-10-19 22:31:22 -05:00
5 changed files with 77 additions and 7 deletions
@@ -115,7 +115,7 @@ for name in list(go2rtc_config.get("streams", {})):
formatted_stream = stream.format(**FRIGATE_ENV_VARS) formatted_stream = stream.format(**FRIGATE_ENV_VARS)
if is_restricted_go2rtc_source(formatted_stream): if is_restricted_go2rtc_source(formatted_stream):
print( 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." f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
) )
del go2rtc_config["streams"][name] 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) formatted_stream = stream_item.format(**FRIGATE_ENV_VARS)
if is_restricted_go2rtc_source(formatted_stream): if is_restricted_go2rtc_source(formatted_stream):
print( 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." f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
) )
continue continue
@@ -150,7 +150,7 @@ for name in list(go2rtc_config.get("streams", {})):
go2rtc_config["streams"][name] = filtered_streams go2rtc_config["streams"][name] = filtered_streams
else: else:
print( 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." f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
) )
del go2rtc_config["streams"][name] del go2rtc_config["streams"][name]
+2
View File
@@ -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. 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. 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: 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:
@@ -313,3 +313,27 @@ class TestConfigGo2rtcStreamRestrictions(BaseTestHttp):
) )
assert resp.status_code == 400 assert resp.status_code == 400
assert self._read_config() == before 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
+39 -1
View File
@@ -4,7 +4,11 @@ import os
import unittest import unittest
from unittest.mock import patch 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" FFMPEG_PATH = "/usr/lib/ffmpeg/7.0/bin/ffmpeg"
@@ -47,5 +51,39 @@ class TestApplyGo2rtcFfmpegConfig(unittest.TestCase):
assert config["ngrok"] == {"command": "ngrok tcp 8555"} 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+9 -3
View File
@@ -980,9 +980,15 @@ def is_go2rtc_arbitrary_exec_allowed() -> bool:
def is_restricted_go2rtc_source(stream_source: str) -> bool: def is_restricted_go2rtc_source(stream_source: str) -> bool:
"""Check if a stream source is a restricted type (echo, expr, or exec) """Check if a stream source is restricted and the
and the GO2RTC_ALLOW_ARBITRARY_EXEC override is not set.""" GO2RTC_ALLOW_ARBITRARY_EXEC override is not set.
if not stream_source.strip().startswith(("echo:", "expr:", "exec:")):
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 False
return not is_go2rtc_arbitrary_exec_allowed() return not is_go2rtc_arbitrary_exec_allowed()