From ac0f39268c53a94f8fa4d391c8309f3285c71418 Mon Sep 17 00:00:00 2001 From: Blake Blackshear Date: Wed, 14 Oct 2026 08:45:52 -0500 Subject: [PATCH] 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 --- .../rootfs/usr/local/go2rtc/create_config.py | 18 ++++----------- .../test_http_config_go2rtc_streams.py | 22 +++++++++++++++++++ frigate/util/services.py | 5 ++++- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index 70cb744f13..4390ce33e0 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -3,7 +3,6 @@ import json import os import sys -from pathlib import Path from typing import Any from ruamel.yaml import YAML @@ -25,15 +24,6 @@ sys.path.remove("/opt/frigate") yaml = YAML() -FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")} -# read docker secret files as env vars too -if os.path.isdir("/run/secrets"): - for secret_file in os.listdir("/run/secrets"): - if secret_file.startswith("FRIGATE_"): - FRIGATE_ENV_VARS[secret_file] = ( - Path(os.path.join("/run/secrets", secret_file)).read_text().strip() - ) - config_file = find_config_file() try: @@ -113,7 +103,7 @@ for name in list(go2rtc_config.get("streams", {})): if isinstance(stream, str): try: - formatted_stream = stream.format(**FRIGATE_ENV_VARS) + formatted_stream = substitute_frigate_vars(stream) 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. " @@ -122,7 +112,7 @@ for name in list(go2rtc_config.get("streams", {})): del go2rtc_config["streams"][name] continue go2rtc_config["streams"][name] = formatted_stream - except KeyError as e: + except (KeyError, ValueError) as e: print( "[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info." ) @@ -132,7 +122,7 @@ for name in list(go2rtc_config.get("streams", {})): filtered_streams = [] for i, stream_item in enumerate(stream): try: - formatted_stream = stream_item.format(**FRIGATE_ENV_VARS) + formatted_stream = substitute_frigate_vars(stream_item) 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. " @@ -141,7 +131,7 @@ for name in list(go2rtc_config.get("streams", {})): continue filtered_streams.append(formatted_stream) - except KeyError as e: + except (KeyError, ValueError) as e: print( "[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info." ) 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 88ecd68d91..7861488046 100644 --- a/frigate/test/http_api/test_http_config_go2rtc_streams.py +++ b/frigate/test/http_api/test_http_config_go2rtc_streams.py @@ -7,6 +7,7 @@ from unittest.mock import patch import ruamel.yaml +from frigate.config.env import FRIGATE_ENV_VARS from frigate.models import Event, Recordings, ReviewSegment from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp @@ -167,3 +168,24 @@ class TestConfigGo2rtcStreamRestrictions(BaseTestHttp): assert resp.status_code == 400 assert "Restricted go2rtc stream source" in resp.json()["message"] assert self._read_config() == before + + # --- {FRIGATE_*} placeholders resolve the same way as at go2rtc startup --- + + def test_set_rejects_unresolvable_placeholder(self): + before = self._read_config() + with patch.dict(FRIGATE_ENV_VARS, {"FRIGATE_RTSP_USER": "user"}): + for src in ( + "{FRIGATE_RTSP_USER!r}fmpeg:front_door#raw=-vn", + "{FRIGATE_RTSP_USER:>5}", + "rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video", + ): + resp = self._set_streams({"new_stream": [src]}) + assert resp.status_code == 400, src + assert self._read_config() == before + + def test_set_allows_resolvable_placeholder(self): + src = "rtsp://{FRIGATE_RTSP_USER}@10.0.0.2:554/video" + with patch.dict(FRIGATE_ENV_VARS, {"FRIGATE_RTSP_USER": "user"}): + resp = self._set_streams({"new_stream": [src]}) + assert resp.status_code == 200, resp.text + assert src in self._read_config() diff --git a/frigate/util/services.py b/frigate/util/services.py index 36691e5e18..c460a71f99 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -1073,10 +1073,13 @@ def find_restricted_go2rtc_stream_changes( restricted.append(name) break + # go2rtc startup resolves sources with the same substitution, and + # refuses to start on one it cannot resolve try: resolved_source = substitute_frigate_vars(source) except (KeyError, ValueError): - resolved_source = source + restricted.append(name) + break if is_restricted_go2rtc_api_source( source