Compare commits

..
Author SHA1 Message Date
Blake BlackshearandClaude Opus 5.5 9b165948c3 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>
2026-10-14 08:00:25 -05:00
Blake BlackshearandClaude Opus 5.5 3bbac1be48 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>
2026-10-14 07:32:09 -05:00
Blake BlackshearandClaude Opus 5.5 945a2d7744 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>
2026-10-14 07:16:13 -05:00
Blake BlackshearandClaude Opus 5.5 12d9ab15c2 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>
2026-10-14 07:10:49 -05:00
Blake Blackshear 7badf191e3 update version 2026-10-14 07:02:09 -05:00
11 changed files with 633 additions and 19 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
default_target: local
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
VERSION = 0.18.1
VERSION = 0.18.2
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
BOARDS= #Initialized empty
+62 -2
View File
@@ -73,6 +73,7 @@ from frigate.util.config import (
)
from frigate.util.schema import get_config_schema
from frigate.util.services import (
find_restricted_go2rtc_stream_changes,
get_nvidia_driver_info,
process_logs,
restart_frigate,
@@ -522,6 +523,46 @@ def config_raw():
)
def _load_yaml_dict(raw_config: str) -> dict[str, Any]:
try:
loaded = ruamel.yaml.YAML(typ="safe", pure=True).load(raw_config)
except ruamel.yaml.YAMLError:
return {}
return loaded if isinstance(loaded, dict) else {}
def _go2rtc_streams(raw_config: str) -> Any:
go2rtc = _load_yaml_dict(raw_config).get("go2rtc")
return go2rtc.get("streams") if isinstance(go2rtc, dict) else None
def _restricted_go2rtc_streams_response(
old_raw_config: str, new_raw_config: str
) -> JSONResponse | None:
"""Reject config changes that add restricted go2rtc stream sources.
The same sources are rejected by PUT /go2rtc/streams, and go2rtc would
otherwise load them from the saved config on the next restart.
"""
restricted = find_restricted_go2rtc_stream_changes(
_go2rtc_streams(old_raw_config), _go2rtc_streams(new_raw_config)
)
if not restricted:
return None
logger.warning(
"Rejected config change with restricted go2rtc stream sources: %s",
", ".join(restricted),
)
return JSONResponse(
content={
"success": False,
"message": f"Restricted go2rtc stream source in: {', '.join(restricted)}",
},
status_code=400,
)
@router.post("/config/save", dependencies=[Depends(require_role(["admin"]))])
def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
new_config = body.decode()
@@ -598,10 +639,22 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
status_code=400,
)
config_file = find_config_file()
try:
with open(config_file) as f:
old_raw_config = f.read()
except OSError:
old_raw_config = ""
restricted_response = _restricted_go2rtc_streams_response(
old_raw_config, new_config
)
if restricted_response is not None:
return restricted_response
# Save the config to file
try:
config_file = find_config_file()
with open(config_file, "w") as f:
f.write(new_config)
f.close()
@@ -912,6 +965,13 @@ def config_set(request: Request, body: AppConfigSetBody):
),
status_code=400,
)
restricted_response = _restricted_go2rtc_streams_response(
old_raw_config, new_raw_config
)
if restricted_response is not None:
with open(config_file, "w") as f:
f.write(old_raw_config)
return restricted_response
except Exception as e:
logging.error(f"Error updating config: {e}")
return JSONResponse(
+45 -5
View File
@@ -13,6 +13,7 @@ from datetime import datetime
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import requests
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
from joserfc import jwt
@@ -1138,6 +1139,38 @@ def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]:
return owner_cameras
def _is_existing_go2rtc_stream(request: Request, stream_name: str) -> bool:
"""Check that `stream_name` names a stream go2rtc already knows about.
go2rtc turns any `src` that is not an existing stream name into a new ad hoc
stream and starts the producer it describes, so the proxied live-stream
paths must only ever reference existing streams.
"""
frigate_config = request.app.frigate_config
if _get_stream_owner_cameras(request, stream_name):
return True
config_streams = getattr(frigate_config.go2rtc, "streams", None) or {}
if stream_name in config_streams:
return True
# streams added at runtime through PUT /go2rtc/streams only exist in go2rtc
try:
r = requests.get("http://127.0.0.1:1984/api/streams", timeout=2)
except requests.RequestException:
logger.warning("Unable to fetch streams from go2rtc to authorize request")
return False
if not r.ok:
return False
try:
return stream_name in r.json()
except ValueError:
return False
# nginx proxies these paths straight to go2rtc with authentication-only checks
# (see auth_request.conf). Each names the desired stream via the `src` query
# param, so the camera-level check must happen here in the `/auth` subrequest —
@@ -1155,11 +1188,12 @@ GO2RTC_STREAM_PROXY_PATHS = frozenset(
def deny_response_for_go2rtc_stream(
original_url: str | None, role: str | None, request: Request
) -> int | None:
"""Block role-restricted users from go2rtc live streams they cannot access.
"""Block go2rtc live-stream requests the current role may not make.
Returns 403 when any `src` stream named in `original_url` resolves to a
camera outside the role's allow-list (or when no `src` is provided on a
stream-proxy path), otherwise None. Mirrors the resolution logic in
Returns 403 when any `src` named in `original_url` is not an existing go2rtc
stream (for every role), or when it resolves to a camera outside a
restricted role's allow-list (or no `src` is provided for such a role),
otherwise None. Mirrors the resolution logic in
`require_go2rtc_stream_access` so substream names map to their owning
camera correctly.
"""
@@ -1171,12 +1205,18 @@ def deny_response_for_go2rtc_stream(
return None
frigate_config = request.app.frigate_config
sources = parse_qs(parsed.query, keep_blank_values=True).get("src", [])
# an unknown src would make go2rtc create an ad hoc stream from it, which
# bypasses the restricted-source checks on PUT /go2rtc/streams
for src in sources:
if not _is_existing_go2rtc_stream(request, src):
return 403
# admin and full-access roles (no allow-list) bypass the camera check
if not role or not is_role_restricted(role, frigate_config):
return None
sources = parse_qs(parsed.query).get("src", [])
if not sources:
# a stream-proxy request naming no stream has nothing legitimate to
# show a restricted user
+5 -5
View File
@@ -46,7 +46,7 @@ from frigate.util.image import run_ffmpeg_snapshot
from frigate.util.services import (
analyze_record_keyframes,
ffprobe_stream,
is_restricted_go2rtc_source,
is_restricted_go2rtc_api_source,
)
logger = logging.getLogger(__name__)
@@ -152,9 +152,9 @@ def go2rtc_camera_stream(request: Request, stream_name: str):
)
def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
"""Add or update a go2rtc stream configuration."""
if src and is_restricted_go2rtc_source(src):
if src and is_restricted_go2rtc_api_source(src):
logger.warning(
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
"Rejected go2rtc stream '%s' with restricted source",
stream_name,
)
return JSONResponse(
@@ -173,9 +173,9 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
except KeyError:
resolved_src = src
if is_restricted_go2rtc_source(resolved_src):
if is_restricted_go2rtc_api_source(resolved_src):
logger.warning(
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
"Rejected go2rtc stream '%s' with restricted source",
stream_name,
)
return JSONResponse(
+14
View File
@@ -1,3 +1,4 @@
import logging
import os
import re
from pathlib import Path
@@ -5,6 +6,8 @@ from typing import Annotated
from pydantic import AfterValidator, ValidationInfo
logger = logging.getLogger(__name__)
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
secrets_dir = os.environ.get("CREDENTIALS_DIRECTORY", "/run/secrets")
# read secret files as env vars too
@@ -16,6 +19,10 @@ if os.path.isdir(secrets_dir) and os.access(secrets_dir, os.R_OK):
)
# Security overrides that must come from the container environment, which is
# controlled by the operator, rather than from the config, which admins can edit
_PROTECTED_ENV_VARS = frozenset({"GO2RTC_ALLOW_ARBITRARY_EXEC"})
# Matches a FRIGATE_* identifier following an opening brace.
_FRIGATE_IDENT_RE = re.compile(r"FRIGATE_[A-Za-z0-9_]+")
@@ -95,6 +102,13 @@ EnvString = Annotated[str, AfterValidator(validate_env_string)]
def validate_env_vars(v: dict[str, str], info: ValidationInfo) -> dict[str, str]:
if isinstance(info.context, dict) and info.context.get("install", False):
for k, val in v.items():
if k.upper() in _PROTECTED_ENV_VARS:
logger.warning(
"Ignoring %s in environment_vars, it must be set in the container environment",
k,
)
continue
os.environ[k] = val
if k.startswith("FRIGATE_"):
FRIGATE_ENV_VARS[k] = val
@@ -403,6 +403,72 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
forwarded_src = mock_put.call_args.kwargs["params"]["src"]
assert forwarded_src == "exec:/tmp/something"
def test_add_stream_rejects_ffmpeg_argument_injection(self):
"""PUT /go2rtc/streams must reject ffmpeg: sources whose parameters can
inject arbitrary ffmpeg arguments, since go2rtc expands them to exec:"""
app = self._make_app(_MULTI_CAMERA_CONFIG)
with patch("frigate.api.camera.requests.put") as mock_put:
with AuthTestClient(app) as client:
for src in (
# shape of the source in GHSA-qjjw-3v66-279f
"ffmpeg:#input='-f''h264''-i''data:placeholder''-c''copy'",
"ffmpeg:#input=-re",
"ffmpeg:front_door#raw=-vn",
"ffmpeg:front_door#video=-vn",
"ffmpeg:front_door#video=out.mp4",
"ffmpeg:front_door#drawtext=x",
"ffmpeg:front_door#width=1x",
"ffmpeg:'front_door'",
" ffmpeg:#input=-re",
):
resp = client.put("/go2rtc/streams/injected", params={"src": src})
assert resp.status_code == 400, (
f"Expected 400 for ffmpeg src {src!r}; got {resp.status_code}"
)
assert resp.json().get("success") is False
mock_put.assert_not_called()
def test_add_stream_allows_standard_ffmpeg_sources(self):
"""ffmpeg: sources built by the UI and shown in the docs must still be
forwarded to go2rtc"""
app = self._make_app(_MULTI_CAMERA_CONFIG)
mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})()
with patch(
"frigate.api.camera.requests.put", return_value=mock_response
) as mock_put:
with AuthTestClient(app) as client:
for src in (
"ffmpeg:rtsp://10.0.0.1:554/video",
"ffmpeg:rtsp://10.0.0.1:554/video#video=copy#audio=copy",
"ffmpeg:rtsp://10.0.0.1:554/video#video=h264#hardware",
"ffmpeg:front_door#audio=opus",
"ffmpeg:front_door#video=h264#hardware=vaapi",
"ffmpeg:front_door#video=h264#width=1280#rotate=-90",
"ffmpeg:http://10.0.0.1/flv?port=1935&app=bcs#video=copy",
):
resp = client.put("/go2rtc/streams/legit", params={"src": src})
assert resp.status_code == 200, (
f"Expected 200 for ffmpeg src {src!r}; got {resp.status_code}"
)
forwarded_src = mock_put.call_args.kwargs["params"]["src"]
assert forwarded_src == src
def test_add_stream_allows_ffmpeg_injection_when_override_set(self):
"""GO2RTC_ALLOW_ARBITRARY_EXEC also lifts the ffmpeg: parameter check"""
app = self._make_app(_MULTI_CAMERA_CONFIG)
mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})()
with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
with patch(
"frigate.api.camera.requests.put", return_value=mock_response
) as mock_put:
with AuthTestClient(app) as client:
resp = client.put(
"/go2rtc/streams/legit",
params={"src": "ffmpeg:front_door#raw=-vn"},
)
assert resp.status_code == 200
mock_put.assert_called_once()
def test_stream_alias_blocked_when_owning_camera_disallowed(self):
"""limited_user cannot access a stream alias that belongs to a camera they
are not allowed to see."""
@@ -0,0 +1,155 @@
"""Tests for go2rtc stream source restrictions on config/save and config/set."""
import copy
import os
import tempfile
from unittest.mock import patch
import ruamel.yaml
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
# shape of the source in GHSA-qjjw-3v66-279f, with the payload replaced
_INJECTED_SRC = "ffmpeg:#input='-f''h264''-i''data:placeholder''-c''copy'"
# an unrestricted ffmpeg: source the operator already has in the file on disk
_EXISTING_SRC = "ffmpeg:rtsp://10.0.0.1:554/video#raw=-vn"
_CONFIG = {
"mqtt": {"host": "mqtt"},
"go2rtc": {"streams": {"front_door": [_EXISTING_SRC]}},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
def _dump(config: dict) -> str:
yaml = ruamel.yaml.YAML()
stream = tempfile.SpooledTemporaryFile(mode="w+")
yaml.dump(config, stream)
stream.seek(0)
return stream.read()
class TestConfigGo2rtcStreamRestrictions(BaseTestHttp):
def setUp(self):
super().setUp([Event, Recordings, ReviewSegment])
self.minimal_config = _CONFIG
self.app = super().create_app()
fd, self.config_path = tempfile.mkstemp(suffix=".yml")
with os.fdopen(fd, "w") as f:
f.write(_dump(_CONFIG))
self.addCleanup(os.unlink, self.config_path)
find_patch = patch(
"frigate.api.app.find_config_file", return_value=self.config_path
)
find_patch.start()
self.addCleanup(find_patch.stop)
os.environ.pop("GO2RTC_ALLOW_ARBITRARY_EXEC", None)
def _read_config(self) -> str:
with open(self.config_path) as f:
return f.read()
def _set_streams(self, streams: dict):
with AuthTestClient(self.app) as client:
return client.put(
"/config/set",
json={
"config_data": {"go2rtc": {"streams": streams}},
"requires_restart": 1,
},
)
def _save_streams(self, streams: dict):
config = copy.deepcopy(_CONFIG)
config["go2rtc"]["streams"].update(streams)
with AuthTestClient(self.app) as client:
return client.post(
"/config/save?save_option=saveonly",
content=_dump(config),
headers={"Content-Type": "text/plain"},
)
# --- config/set ---
def test_set_rejects_injected_ffmpeg_source(self):
before = self._read_config()
resp = self._set_streams({"new_stream": [_INJECTED_SRC]})
assert resp.status_code == 400
assert resp.json()["success"] is False
assert self._read_config() == before
def test_set_rejects_injected_string_source(self):
before = self._read_config()
resp = self._set_streams({"new_stream": "ffmpeg:front_door#raw=-vn"})
assert resp.status_code == 400
assert self._read_config() == before
def test_set_rejects_injected_source_added_to_existing_stream(self):
before = self._read_config()
resp = self._set_streams({"front_door": [_EXISTING_SRC, _INJECTED_SRC]})
assert resp.status_code == 400
assert self._read_config() == before
def test_set_allows_standard_sources(self):
resp = self._set_streams(
{
"new_stream": [
"rtsp://10.0.0.2:554/video",
"ffmpeg:new_stream#audio=opus",
]
}
)
assert resp.status_code == 200, resp.text
assert "ffmpeg:new_stream#audio=opus" in self._read_config()
def test_set_keeps_existing_source_on_disk(self):
resp = self._set_streams(
{"front_door": [_EXISTING_SRC, "ffmpeg:front_door#audio=opus"]}
)
assert resp.status_code == 200, resp.text
assert _EXISTING_SRC in self._read_config()
def test_set_allows_injected_source_when_override_set(self):
with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
resp = self._set_streams({"new_stream": [_INJECTED_SRC]})
assert resp.status_code == 200, resp.text
# --- config/save ---
def test_save_rejects_injected_ffmpeg_source(self):
before = self._read_config()
resp = self._save_streams({"new_stream": [_INJECTED_SRC]})
assert resp.status_code == 400
assert self._read_config() == before
def test_save_rejects_exec_source(self):
before = self._read_config()
resp = self._save_streams({"new_stream": "exec:/tmp/something"})
assert resp.status_code == 400
assert self._read_config() == before
def test_save_rejects_map_source(self):
before = self._read_config()
resp = self._save_streams({"new_stream": {"url": "rtsp://10.0.0.2/video"}})
assert resp.status_code == 400
assert self._read_config() == before
def test_save_resubmitting_existing_config_allowed(self):
# the UI config editor always sends back the whole file
resp = self._save_streams({"new_stream": "rtsp://10.0.0.2:554/video"})
assert resp.status_code == 200, resp.text
config = self._read_config()
assert _EXISTING_SRC in config
assert "rtsp://10.0.0.2:554/video" in config
@@ -0,0 +1,73 @@
"""Tests for the /auth subrequest on nginx-proxied go2rtc live-stream paths."""
import time
from unittest.mock import patch
import requests
from fastapi.testclient import TestClient
from joserfc.jwk import OctKey
from frigate.api.auth import create_encoded_jwt
from frigate.models import Event, Recordings, ReviewSegment, User
from frigate.test.http_api.base_http_test import BaseTestHttp
_CONFIG = {
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
# shape of the source in GHSA-93xj-hg88-ffg7, with the payload replaced
_AD_HOC_SRC = "ffmpeg:%23input%3D'-f''h264''-i''data:placeholder''-c''copy'"
class TestGo2rtcProxyAuth(BaseTestHttp):
def setUp(self):
super().setUp([Event, ReviewSegment, Recordings, User])
self.minimal_config = _CONFIG
self.app = super().create_app()
self.app.jwt_token = OctKey.import_key(b"0" * 32)
get_patch = patch(
"frigate.api.auth.requests.get",
side_effect=requests.ConnectionError("go2rtc unavailable"),
)
get_patch.start()
self.addCleanup(get_patch.stop)
def _auth(self, role: str, original_url: str):
token = create_encoded_jwt(
role, role, int(time.time()) + 3600, self.app.jwt_token
)
with TestClient(self.app) as client:
return client.get(
"/auth",
headers={
"authorization": f"Bearer {token}",
"x-original-url": original_url,
},
)
def test_viewer_ad_hoc_mse_source_denied(self):
resp = self._auth("viewer", f"/live/mse/api/ws?src={_AD_HOC_SRC}")
assert resp.status_code == 403
def test_viewer_ad_hoc_webrtc_sources_denied(self):
for path in ("/live/webrtc/api/ws", "/api/go2rtc/webrtc"):
resp = self._auth("viewer", f"{path}?src={_AD_HOC_SRC}")
assert resp.status_code == 403, path
def test_admin_ad_hoc_mse_source_denied(self):
resp = self._auth("admin", f"/live/mse/api/ws?src={_AD_HOC_SRC}")
assert resp.status_code == 403
def test_viewer_camera_stream_allowed(self):
resp = self._auth("viewer", "/live/mse/api/ws?src=front_door")
assert resp.status_code == 202
assert resp.headers["remote-role"] == "viewer"
+22 -6
View File
@@ -49,14 +49,10 @@ class TestGo2RtcAddStreamSubstitution(unittest.TestCase):
def test_mixed_localtime_and_frigate_var(self):
"""%{localtime\\:...} alongside {FRIGATE_USER} substitutes only the var."""
FRIGATE_ENV_VARS["FRIGATE_USER"] = "admin"
src = (
"ffmpeg:rtsp://host/s#raw=-vf "
"drawtext=text=%{localtime\\:%Y-%m-%d}:user={FRIGATE_USER}"
)
src = "rtsp://host/s?text=%{localtime\\:%Y-%m-%d}&user={FRIGATE_USER}"
self.assertEqual(
self._call_route(src),
"ffmpeg:rtsp://host/s#raw=-vf "
"drawtext=text=%{localtime\\:%Y-%m-%d}:user=admin",
"rtsp://host/s?text=%{localtime\\:%Y-%m-%d}&user=admin",
)
def test_unknown_var_falls_back_to_raw_src(self):
@@ -232,6 +228,26 @@ class TestEnvVars(unittest.TestCase):
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
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."""
os.environ["GO2RTC_ALLOW_ARBITRARY_EXEC"] = "true"
ctx = self._make_context(install=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()
+89
View File
@@ -8,6 +8,9 @@ param, which the static-media auth in `media_auth` does not inspect.
import types
import unittest
from unittest.mock import MagicMock, patch
import requests
from frigate.api.auth import deny_response_for_go2rtc_stream
from frigate.config import FrigateConfig
@@ -20,6 +23,7 @@ _CONFIG = {
"dual_user": ["front_door", "back_door"],
}
},
"go2rtc": {"streams": {"doorbell_audio": ["rtsp://10.0.0.4:554/audio"]}},
"cameras": {
"front_door": {
"ffmpeg": {
@@ -54,6 +58,20 @@ class TestDenyResponseForGo2rtcStream(unittest.TestCase):
self.config = FrigateConfig(**_CONFIG)
self.request = _request(self.config)
# go2rtc is not running in tests; individual tests override this
get_patch = patch(
"frigate.api.auth.requests.get",
side_effect=requests.ConnectionError("go2rtc unavailable"),
)
self.mock_get = get_patch.start()
self.addCleanup(get_patch.stop)
def _go2rtc_streams(self, *names: str) -> None:
response = MagicMock(ok=True)
response.json.return_value = {name: {} for name in names}
self.mock_get.side_effect = None
self.mock_get.return_value = response
def _deny(self, url: str, role: str):
return deny_response_for_go2rtc_stream(url, role, self.request)
@@ -170,6 +188,77 @@ class TestDenyResponseForGo2rtcStream(unittest.TestCase):
def test_missing_role_bypasses(self):
self.assertIsNone(self._deny("http://host/live/mse/api/ws?src=back_door", None))
# --- ad hoc sources are denied for every role ---
def test_ad_hoc_ffmpeg_source_denied_for_all_roles(self):
# shape of the source in GHSA-93xj-hg88-ffg7
src = "ffmpeg:%23input%3D'-f''h264''-i''data:placeholder''-c''copy'"
for path in (
"/live/mse/api/ws",
"/live/webrtc/api/ws",
"/api/go2rtc/webrtc",
):
for role in ("viewer", "admin", "limited_user", None):
self.assertEqual(
self._deny(f"http://host{path}?src={src}", role),
403,
f"{path} {role}",
)
def test_ad_hoc_source_with_name_param_denied(self):
# name= would otherwise let go2rtc replace an existing stream's source
self.assertEqual(
self._deny(
"http://host/live/mse/api/ws?src=ffmpeg:x%23video%3Dh264"
"&name=front_door",
"admin",
),
403,
)
def test_ad_hoc_source_after_existing_src_denied(self):
self.assertEqual(
self._deny(
"http://host/live/mse/api/ws?src=front_door&src=exec:x", "viewer"
),
403,
)
def test_unknown_stream_denied_for_admin(self):
self.assertEqual(
self._deny("http://host/live/mse/api/ws?src=nonexistent", "admin"), 403
)
def test_blank_src_denied_for_admin(self):
self.assertEqual(self._deny("http://host/live/mse/api/ws?src=", "admin"), 403)
def test_go2rtc_config_stream_allowed(self):
self.assertIsNone(
self._deny("http://host/live/mse/api/ws?src=doorbell_audio", "viewer")
)
self.mock_get.assert_not_called()
def test_runtime_go2rtc_stream_allowed(self):
# streams added through PUT /go2rtc/streams only exist in go2rtc
self._go2rtc_streams("front_door", "wizard_test")
self.assertIsNone(
self._deny("http://host/live/mse/api/ws?src=wizard_test", "admin")
)
def test_runtime_lookup_does_not_admit_ad_hoc_source(self):
self._go2rtc_streams("front_door", "wizard_test")
self.assertEqual(
self._deny("http://host/live/mse/api/ws?src=ffmpeg:wizard_test", "admin"),
403,
)
def test_go2rtc_error_fails_closed(self):
self.mock_get.side_effect = None
self.mock_get.return_value = MagicMock(ok=False)
self.assertEqual(
self._deny("http://host/live/mse/api/ws?src=wizard_test", "admin"), 403
)
if __name__ == "__main__":
unittest.main()
+101
View File
@@ -986,6 +986,107 @@ def is_restricted_go2rtc_source(stream_source: str) -> bool:
return not is_go2rtc_arbitrary_exec_allowed()
# go2rtc expands an `ffmpeg:` source into an `exec:` command line built from its
# `#key=value` parameters, so only parameters that map to fixed presets or plain
# numbers are accepted from the API
_GO2RTC_FFMPEG_NAME_PARAMS = frozenset({"video", "audio", "hardware"})
_GO2RTC_FFMPEG_NUMERIC_PARAMS = frozenset(
{"width", "height", "rotate", "bitrate", "timeout"}
)
_GO2RTC_FFMPEG_FLAG_PARAMS = frozenset({"async"})
_GO2RTC_FFMPEG_NAME_VALUE = re.compile(r"^[A-Za-z0-9_]*$")
_GO2RTC_FFMPEG_NUMERIC_VALUE = re.compile(r"^-?[0-9]+[kKmM]?$")
def _is_safe_go2rtc_ffmpeg_source(stream_source: str) -> bool:
"""Check that an `ffmpeg:` source only uses allowlisted parameters."""
if any(c in stream_source for c in "'\"") or re.search(r"\s", stream_source):
return False
_, *params = stream_source.split("#")
for param in params:
key, has_value, value = param.partition("=")
if key in _GO2RTC_FFMPEG_NAME_PARAMS:
if not _GO2RTC_FFMPEG_NAME_VALUE.match(value):
return False
elif key in _GO2RTC_FFMPEG_NUMERIC_PARAMS:
if not _GO2RTC_FFMPEG_NUMERIC_VALUE.match(value):
return False
elif key in _GO2RTC_FFMPEG_FLAG_PARAMS:
if has_value:
return False
else:
return False
return True
def is_restricted_go2rtc_api_source(stream_source: str) -> bool:
"""Check if a stream source submitted through the API is restricted.
In addition to the echo/expr/exec sources, this restricts `ffmpeg:` sources
whose parameters could inject arbitrary ffmpeg arguments. The
GO2RTC_ALLOW_ARBITRARY_EXEC override lifts both restrictions.
"""
source = stream_source.strip()
if is_restricted_go2rtc_source(source):
return True
if not source.startswith("ffmpeg:"):
return False
if _is_safe_go2rtc_ffmpeg_source(source[len("ffmpeg:") :]):
return False
return not is_go2rtc_arbitrary_exec_allowed()
def _go2rtc_stream_sources(stream: Any) -> list[Any]:
return stream if isinstance(stream, list) else [stream]
def find_restricted_go2rtc_stream_changes(
old_streams: dict[str, Any] | None, new_streams: dict[str, Any] | None
) -> list[str]:
"""Return the names of go2rtc streams with new restricted sources.
Used when the config is changed through the API. A source that is already
in the saved config for the same stream is left alone, so streams the
operator configured on disk keep working; any other source must pass
`is_restricted_go2rtc_api_source`.
"""
# imported here since frigate.config imports this module
from frigate.config.env import substitute_frigate_vars
old_streams = old_streams if isinstance(old_streams, dict) else {}
new_streams = new_streams if isinstance(new_streams, dict) else {}
restricted = []
for name, stream in new_streams.items():
existing = _go2rtc_stream_sources(old_streams.get(name))
for source in _go2rtc_stream_sources(stream):
if source in existing:
continue
if not isinstance(source, str):
# the map form is only usable with arbitrary exec enabled
if is_go2rtc_arbitrary_exec_allowed():
continue
restricted.append(name)
break
try:
resolved_source = substitute_frigate_vars(source)
except (KeyError, ValueError):
resolved_source = source
if is_restricted_go2rtc_api_source(
source
) or is_restricted_go2rtc_api_source(resolved_source):
restricted.append(name)
break
return restricted
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
"""Run ffprobe on stream."""
clean_path = escape_special_characters(path)