mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-15 03:02:49 +03:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d43a1d8565 | ||
|
|
c43b8f58f9 | ||
|
|
3dad2605db | ||
|
|
20962bb12d | ||
|
|
ac0f39268c | ||
|
|
21f5ab5a1e | ||
|
|
fab41048a1 | ||
|
|
9b165948c3 | ||
|
|
3bbac1be48 | ||
|
|
945a2d7744 | ||
|
|
12d9ab15c2 | ||
|
|
7badf191e3 |
@@ -1,7 +1,7 @@
|
|||||||
default_target: local
|
default_target: local
|
||||||
|
|
||||||
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
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
|
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
|
||||||
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
|
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||||
BOARDS= #Initialized empty
|
BOARDS= #Initialized empty
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ location /auth {
|
|||||||
# Pass info about the request
|
# Pass info about the request
|
||||||
proxy_set_header X-Original-Method $request_method;
|
proxy_set_header X-Original-Method $request_method;
|
||||||
proxy_set_header X-Original-URL $request_uri;
|
proxy_set_header X-Original-URL $request_uri;
|
||||||
|
# set by the go2rtc stream locations; client headers are not forwarded, so
|
||||||
|
# this cannot be spoofed
|
||||||
|
proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy;
|
||||||
proxy_set_header X-Server-Port $server_port;
|
proxy_set_header X-Server-Port $server_port;
|
||||||
proxy_set_header Content-Length "";
|
proxy_set_header Content-Length "";
|
||||||
# Pass along auth related info
|
# Pass along auth related info
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ http {
|
|||||||
|
|
||||||
# frigate lovelace card uses this path
|
# frigate lovelace card uses this path
|
||||||
location /live/mse/api/ws {
|
location /live/mse/api/ws {
|
||||||
|
# tells /auth to apply the go2rtc stream checks
|
||||||
|
set $go2rtc_stream_proxy 1;
|
||||||
include auth_request.conf;
|
include auth_request.conf;
|
||||||
limit_except GET {
|
limit_except GET {
|
||||||
deny all;
|
deny all;
|
||||||
@@ -211,6 +213,8 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /live/webrtc/api/ws {
|
location /live/webrtc/api/ws {
|
||||||
|
# tells /auth to apply the go2rtc stream checks
|
||||||
|
set $go2rtc_stream_proxy 1;
|
||||||
include auth_request.conf;
|
include auth_request.conf;
|
||||||
limit_except GET {
|
limit_except GET {
|
||||||
deny all;
|
deny all;
|
||||||
@@ -231,6 +235,8 @@ http {
|
|||||||
|
|
||||||
# integration uses this to add webrtc candidate
|
# integration uses this to add webrtc candidate
|
||||||
location /api/go2rtc/webrtc {
|
location /api/go2rtc/webrtc {
|
||||||
|
# tells /auth to apply the go2rtc stream checks
|
||||||
|
set $go2rtc_stream_proxy 1;
|
||||||
include auth_request.conf;
|
include auth_request.conf;
|
||||||
limit_except POST {
|
limit_except POST {
|
||||||
deny all;
|
deny all;
|
||||||
|
|||||||
+80
-2
@@ -73,6 +73,8 @@ from frigate.util.config import (
|
|||||||
)
|
)
|
||||||
from frigate.util.schema import get_config_schema
|
from frigate.util.schema import get_config_schema
|
||||||
from frigate.util.services import (
|
from frigate.util.services import (
|
||||||
|
Go2rtcStreamIssue,
|
||||||
|
find_restricted_go2rtc_stream_changes,
|
||||||
get_nvidia_driver_info,
|
get_nvidia_driver_info,
|
||||||
process_logs,
|
process_logs,
|
||||||
restart_frigate,
|
restart_frigate,
|
||||||
@@ -522,6 +524,63 @@ def config_raw():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _go2rtc_streams(raw_config: str) -> Any:
|
||||||
|
"""Load go2rtc.streams with the same loader Frigate and go2rtc startup use.
|
||||||
|
|
||||||
|
Raises ruamel.yaml.YAMLError when the config cannot be parsed.
|
||||||
|
"""
|
||||||
|
loaded = ruamel.yaml.YAML().load(raw_config)
|
||||||
|
go2rtc = loaded.get("go2rtc") if isinstance(loaded, dict) else None
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
new_streams = _go2rtc_streams(new_raw_config)
|
||||||
|
except ruamel.yaml.YAMLError:
|
||||||
|
logger.warning("Rejected config change that could not be parsed")
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": "Unable to parse config"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
old_streams = _go2rtc_streams(old_raw_config)
|
||||||
|
except ruamel.yaml.YAMLError:
|
||||||
|
# with no readable saved config, every source is treated as new
|
||||||
|
old_streams = None
|
||||||
|
|
||||||
|
issues = find_restricted_go2rtc_stream_changes(old_streams, new_streams)
|
||||||
|
if not issues:
|
||||||
|
return None
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
for issue, label in (
|
||||||
|
(Go2rtcStreamIssue.restricted, "Restricted go2rtc stream source in"),
|
||||||
|
(Go2rtcStreamIssue.unknown_variable, "Unknown variable in go2rtc stream"),
|
||||||
|
(
|
||||||
|
Go2rtcStreamIssue.invalid_placeholder,
|
||||||
|
"Invalid variable placeholder in go2rtc stream",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
names = [name for name, found in issues.items() if found == issue]
|
||||||
|
if names:
|
||||||
|
messages.append(f"{label}: {', '.join(names)}")
|
||||||
|
|
||||||
|
logger.warning("Rejected config change: %s", "; ".join(messages))
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": "; ".join(messages)},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config/save", dependencies=[Depends(require_role(["admin"]))])
|
@router.post("/config/save", dependencies=[Depends(require_role(["admin"]))])
|
||||||
def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
||||||
new_config = body.decode()
|
new_config = body.decode()
|
||||||
@@ -598,10 +657,22 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save the config to file
|
|
||||||
try:
|
|
||||||
config_file = find_config_file()
|
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:
|
||||||
with open(config_file, "w") as f:
|
with open(config_file, "w") as f:
|
||||||
f.write(new_config)
|
f.write(new_config)
|
||||||
f.close()
|
f.close()
|
||||||
@@ -912,6 +983,13 @@ def config_set(request: Request, body: AppConfigSetBody):
|
|||||||
),
|
),
|
||||||
status_code=400,
|
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:
|
except Exception as e:
|
||||||
logging.error(f"Error updating config: {e}")
|
logging.error(f"Error updating config: {e}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
+91
-9
@@ -11,8 +11,9 @@ import secrets
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, unquote
|
||||||
|
|
||||||
|
import requests
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from joserfc import jwt
|
from joserfc import jwt
|
||||||
@@ -1138,11 +1139,44 @@ def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]:
|
|||||||
return owner_cameras
|
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
|
# nginx proxies these paths straight to go2rtc with authentication-only checks
|
||||||
# (see auth_request.conf). Each names the desired stream via the `src` query
|
# (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 —
|
# param, so the camera-level check must happen here in the `/auth` subrequest —
|
||||||
# `require_go2rtc_stream_access` only guards the REST `/go2rtc/streams/{name}`
|
# `require_go2rtc_stream_access` only guards the REST `/go2rtc/streams/{name}`
|
||||||
# endpoint, not these proxied live-stream paths.
|
# endpoint, not these proxied live-stream paths. The nginx locations are prefix
|
||||||
|
# matches, so anything under these paths is proxied too.
|
||||||
GO2RTC_STREAM_PROXY_PATHS = frozenset(
|
GO2RTC_STREAM_PROXY_PATHS = frozenset(
|
||||||
{
|
{
|
||||||
"/live/mse/api/ws",
|
"/live/mse/api/ws",
|
||||||
@@ -1151,32 +1185,80 @@ GO2RTC_STREAM_PROXY_PATHS = frozenset(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_URI_SCHEME_AND_AUTHORITY = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://[^/?]*")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_request_uri(request_uri: str) -> tuple[str, str]:
|
||||||
|
"""Split a raw request URI into its path and query string.
|
||||||
|
|
||||||
|
urlparse is not used since it reads a path starting with `//` as a host.
|
||||||
|
"""
|
||||||
|
uri = _URI_SCHEME_AND_AUTHORITY.sub("", request_uri, count=1)
|
||||||
|
path, _, query = uri.partition("?")
|
||||||
|
return path, query
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_request_path(path: str) -> str:
|
||||||
|
"""Normalize a raw request path the way nginx does before matching locations.
|
||||||
|
|
||||||
|
nginx percent-decodes the path, merges repeated slashes and resolves dot
|
||||||
|
segments, while `X-Original-URL` carries the raw `$request_uri`.
|
||||||
|
"""
|
||||||
|
segments: list[str] = []
|
||||||
|
for segment in unquote(path).split("/"):
|
||||||
|
if segment in ("", "."):
|
||||||
|
continue
|
||||||
|
if segment == "..":
|
||||||
|
if segments:
|
||||||
|
segments.pop()
|
||||||
|
continue
|
||||||
|
segments.append(segment)
|
||||||
|
|
||||||
|
return "/" + "/".join(segments)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_go2rtc_stream_proxy_request(path: str, request: Request) -> bool:
|
||||||
|
# nginx marks requests routed through the go2rtc stream locations, and the
|
||||||
|
# normalized path catches them if that marker is ever missing
|
||||||
|
if request.headers.get("x-go2rtc-stream-proxy") == "1":
|
||||||
|
return True
|
||||||
|
|
||||||
|
normalized = _normalize_request_path(path)
|
||||||
|
return any(normalized.startswith(p) for p in GO2RTC_STREAM_PROXY_PATHS)
|
||||||
|
|
||||||
|
|
||||||
def deny_response_for_go2rtc_stream(
|
def deny_response_for_go2rtc_stream(
|
||||||
original_url: str | None, role: str | None, request: Request
|
original_url: str | None, role: str | None, request: Request
|
||||||
) -> int | None:
|
) -> 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
|
Returns 403 when any `src` named in `original_url` is not an existing go2rtc
|
||||||
camera outside the role's allow-list (or when no `src` is provided on a
|
stream (for every role), or when it resolves to a camera outside a
|
||||||
stream-proxy path), otherwise None. Mirrors the resolution logic in
|
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
|
`require_go2rtc_stream_access` so substream names map to their owning
|
||||||
camera correctly.
|
camera correctly.
|
||||||
"""
|
"""
|
||||||
if not original_url:
|
if not original_url:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
parsed = urlparse(original_url)
|
path, query = _split_request_uri(original_url)
|
||||||
if parsed.path not in GO2RTC_STREAM_PROXY_PATHS:
|
if not _is_go2rtc_stream_proxy_request(path, request):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
frigate_config = request.app.frigate_config
|
frigate_config = request.app.frigate_config
|
||||||
|
sources = parse_qs(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
|
# admin and full-access roles (no allow-list) bypass the camera check
|
||||||
if not role or not is_role_restricted(role, frigate_config):
|
if not role or not is_role_restricted(role, frigate_config):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
sources = parse_qs(parsed.query).get("src", [])
|
|
||||||
if not sources:
|
if not sources:
|
||||||
# a stream-proxy request naming no stream has nothing legitimate to
|
# a stream-proxy request naming no stream has nothing legitimate to
|
||||||
# show a restricted user
|
# show a restricted user
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ from frigate.util.image import run_ffmpeg_snapshot
|
|||||||
from frigate.util.services import (
|
from frigate.util.services import (
|
||||||
analyze_record_keyframes,
|
analyze_record_keyframes,
|
||||||
ffprobe_stream,
|
ffprobe_stream,
|
||||||
is_restricted_go2rtc_source,
|
is_restricted_go2rtc_api_source,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 = ""):
|
def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
|
||||||
"""Add or update a go2rtc stream configuration."""
|
"""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(
|
logger.warning(
|
||||||
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
|
"Rejected go2rtc stream '%s' with restricted source",
|
||||||
stream_name,
|
stream_name,
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -173,9 +173,9 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
resolved_src = src
|
resolved_src = src
|
||||||
|
|
||||||
if is_restricted_go2rtc_source(resolved_src):
|
if is_restricted_go2rtc_api_source(resolved_src):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
|
"Rejected go2rtc stream '%s' with restricted source",
|
||||||
stream_name,
|
stream_name,
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -5,6 +6,8 @@ from typing import Annotated
|
|||||||
|
|
||||||
from pydantic import AfterValidator, ValidationInfo
|
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_")}
|
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")
|
secrets_dir = os.environ.get("CREDENTIALS_DIRECTORY", "/run/secrets")
|
||||||
# read secret files as env vars too
|
# 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.
|
# Matches a FRIGATE_* identifier following an opening brace.
|
||||||
_FRIGATE_IDENT_RE = re.compile(r"FRIGATE_[A-Za-z0-9_]+")
|
_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]:
|
def validate_env_vars(v: dict[str, str], info: ValidationInfo) -> dict[str, str]:
|
||||||
if isinstance(info.context, dict) and info.context.get("install", False):
|
if isinstance(info.context, dict) and info.context.get("install", False):
|
||||||
for k, val in v.items():
|
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
|
os.environ[k] = val
|
||||||
if k.startswith("FRIGATE_"):
|
if k.startswith("FRIGATE_"):
|
||||||
FRIGATE_ENV_VARS[k] = val
|
FRIGATE_ENV_VARS[k] = val
|
||||||
|
|||||||
@@ -403,6 +403,77 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
|
|||||||
forwarded_src = mock_put.call_args.kwargs["params"]["src"]
|
forwarded_src = mock_put.call_args.kwargs["params"]["src"]
|
||||||
assert forwarded_src == "exec:/tmp/something"
|
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#audio=/16000",
|
||||||
|
"ffmpeg:front_door#audio=opus/",
|
||||||
|
"ffmpeg:front_door#audio=opus/16000/1",
|
||||||
|
"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",
|
||||||
|
# documented HomeKit source
|
||||||
|
"ffmpeg:front_door#video=h264#width=1280#height=720#audio=opus/16000",
|
||||||
|
"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):
|
def test_stream_alias_blocked_when_owning_camera_disallowed(self):
|
||||||
"""limited_user cannot access a stream alias that belongs to a camera they
|
"""limited_user cannot access a stream alias that belongs to a camera they
|
||||||
are not allowed to see."""
|
are not allowed to see."""
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""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.config.env import FRIGATE_ENV_VARS
|
||||||
|
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)
|
||||||
|
|
||||||
|
env_patch = patch.dict(os.environ)
|
||||||
|
env_patch.start()
|
||||||
|
self.addCleanup(env_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, prefix: str = ""):
|
||||||
|
config = copy.deepcopy(_CONFIG)
|
||||||
|
config["go2rtc"]["streams"].update(streams)
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
return client.post(
|
||||||
|
"/config/save?save_option=saveonly",
|
||||||
|
content=prefix + _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
|
||||||
|
|
||||||
|
def test_save_tagged_config_with_standard_source_allowed(self):
|
||||||
|
# a tag on the root map still passes validation and startup parsing
|
||||||
|
resp = self._save_streams(
|
||||||
|
{"new_stream": "rtsp://10.0.0.2:554/video"}, prefix="!config\n"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
def test_save_tagged_config_with_injected_source_rejected(self):
|
||||||
|
before = self._read_config()
|
||||||
|
resp = self._save_streams({"new_stream": [_INJECTED_SRC]}, prefix="!config\n")
|
||||||
|
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, message in (
|
||||||
|
(
|
||||||
|
"{FRIGATE_RTSP_USER!r}fmpeg:front_door#raw=-vn",
|
||||||
|
"Invalid variable placeholder in go2rtc stream: new_stream",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{FRIGATE_RTSP_USER:>5}",
|
||||||
|
"Invalid variable placeholder in go2rtc stream: new_stream",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video",
|
||||||
|
"Unknown variable in go2rtc stream: new_stream",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
resp = self._set_streams({"new_stream": [src]})
|
||||||
|
assert resp.status_code == 400, src
|
||||||
|
assert resp.json()["message"] == message, src
|
||||||
|
assert self._read_config() == before
|
||||||
|
|
||||||
|
def test_set_reports_each_issue(self):
|
||||||
|
resp = self._set_streams(
|
||||||
|
{
|
||||||
|
"typo_stream": ["rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video"],
|
||||||
|
"injected_stream": [_INJECTED_SRC],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.json()["message"] == (
|
||||||
|
"Restricted go2rtc stream source in: injected_stream; "
|
||||||
|
"Unknown variable in go2rtc stream: typo_stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -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"
|
||||||
@@ -49,14 +49,10 @@ class TestGo2RtcAddStreamSubstitution(unittest.TestCase):
|
|||||||
def test_mixed_localtime_and_frigate_var(self):
|
def test_mixed_localtime_and_frigate_var(self):
|
||||||
"""%{localtime\\:...} alongside {FRIGATE_USER} substitutes only the var."""
|
"""%{localtime\\:...} alongside {FRIGATE_USER} substitutes only the var."""
|
||||||
FRIGATE_ENV_VARS["FRIGATE_USER"] = "admin"
|
FRIGATE_ENV_VARS["FRIGATE_USER"] = "admin"
|
||||||
src = (
|
src = "rtsp://host/s?text=%{localtime\\:%Y-%m-%d}&user={FRIGATE_USER}"
|
||||||
"ffmpeg:rtsp://host/s#raw=-vf "
|
|
||||||
"drawtext=text=%{localtime\\:%Y-%m-%d}:user={FRIGATE_USER}"
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self._call_route(src),
|
self._call_route(src),
|
||||||
"ffmpeg:rtsp://host/s#raw=-vf "
|
"rtsp://host/s?text=%{localtime\\:%Y-%m-%d}&user=admin",
|
||||||
"drawtext=text=%{localtime\\:%Y-%m-%d}:user=admin",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_unknown_var_falls_back_to_raw_src(self):
|
def test_unknown_var_falls_back_to_raw_src(self):
|
||||||
@@ -232,6 +228,29 @@ class TestEnvVars(unittest.TestCase):
|
|||||||
result = validate_env_string("{FRIGATE_BROKER}")
|
result = validate_env_string("{FRIGATE_BROKER}")
|
||||||
self.assertEqual(result, "mqtt.local")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ param, which the static-media auth in `media_auth` does not inspect.
|
|||||||
|
|
||||||
import types
|
import types
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from frigate.api.auth import deny_response_for_go2rtc_stream
|
from frigate.api.auth import deny_response_for_go2rtc_stream
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
@@ -20,6 +23,7 @@ _CONFIG = {
|
|||||||
"dual_user": ["front_door", "back_door"],
|
"dual_user": ["front_door", "back_door"],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"go2rtc": {"streams": {"doorbell_audio": ["rtsp://10.0.0.4:554/audio"]}},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
"front_door": {
|
"front_door": {
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
@@ -46,7 +50,9 @@ _CONFIG = {
|
|||||||
|
|
||||||
|
|
||||||
def _request(config: FrigateConfig) -> types.SimpleNamespace:
|
def _request(config: FrigateConfig) -> types.SimpleNamespace:
|
||||||
return types.SimpleNamespace(app=types.SimpleNamespace(frigate_config=config))
|
return types.SimpleNamespace(
|
||||||
|
app=types.SimpleNamespace(frigate_config=config), headers={}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDenyResponseForGo2rtcStream(unittest.TestCase):
|
class TestDenyResponseForGo2rtcStream(unittest.TestCase):
|
||||||
@@ -54,6 +60,20 @@ class TestDenyResponseForGo2rtcStream(unittest.TestCase):
|
|||||||
self.config = FrigateConfig(**_CONFIG)
|
self.config = FrigateConfig(**_CONFIG)
|
||||||
self.request = _request(self.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):
|
def _deny(self, url: str, role: str):
|
||||||
return deny_response_for_go2rtc_stream(url, role, self.request)
|
return deny_response_for_go2rtc_stream(url, role, self.request)
|
||||||
|
|
||||||
@@ -170,6 +190,107 @@ class TestDenyResponseForGo2rtcStream(unittest.TestCase):
|
|||||||
def test_missing_role_bypasses(self):
|
def test_missing_role_bypasses(self):
|
||||||
self.assertIsNone(self._deny("http://host/live/mse/api/ws?src=back_door", None))
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- path spellings nginx normalizes before matching its location ---
|
||||||
|
|
||||||
|
def test_path_variants_are_checked(self):
|
||||||
|
for path in (
|
||||||
|
"/live/mse/api/%77s",
|
||||||
|
"/live/mse/api%2Fws",
|
||||||
|
"//live/mse/api/ws",
|
||||||
|
"/live//mse/api/ws",
|
||||||
|
"/live/mse/api/./ws",
|
||||||
|
"/live/other/../mse/api/ws",
|
||||||
|
"/live/mse/api/%2e/ws",
|
||||||
|
"/%6Cive/webrtc/api/ws",
|
||||||
|
"/api/go2rtc/%77ebrtc",
|
||||||
|
"/live/mse/api/ws/",
|
||||||
|
"/live/mse/api/ws/extra",
|
||||||
|
"http://host//live/mse/api/ws",
|
||||||
|
):
|
||||||
|
self.assertEqual(self._deny(f"{path}?src=nonexistent", "viewer"), 403, path)
|
||||||
|
|
||||||
|
def test_path_variants_allow_existing_stream(self):
|
||||||
|
self.assertIsNone(self._deny("/live/mse/api/%77s?src=front_door", "viewer"))
|
||||||
|
|
||||||
|
def test_nginx_marker_applies_check(self):
|
||||||
|
self.request.headers = {"x-go2rtc-stream-proxy": "1"}
|
||||||
|
self.assertEqual(self._deny("/unexpected?src=nonexistent", "viewer"), 403)
|
||||||
|
|
||||||
|
def test_unrelated_paths_not_checked(self):
|
||||||
|
for path in ("/live/mse/api", "/api/go2rtc/streams", "/live/jsmpeg/x"):
|
||||||
|
self.assertIsNone(self._deny(f"{path}?src=nonexistent", "viewer"), path)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import subprocess as sp
|
|||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
@@ -986,6 +987,122 @@ def is_restricted_go2rtc_source(stream_source: str) -> bool:
|
|||||||
return not is_go2rtc_arbitrary_exec_allowed()
|
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"})
|
||||||
|
# a preset or codec name, optionally with a sample rate (e.g. opus/16000)
|
||||||
|
_GO2RTC_FFMPEG_NAME_VALUE = re.compile(r"^([A-Za-z0-9_]*|[A-Za-z0-9_]+/[0-9]{1,6})$")
|
||||||
|
_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]
|
||||||
|
|
||||||
|
|
||||||
|
class Go2rtcStreamIssue(str, Enum):
|
||||||
|
"""Why a go2rtc stream source was rejected."""
|
||||||
|
|
||||||
|
restricted = "restricted"
|
||||||
|
unknown_variable = "unknown_variable"
|
||||||
|
invalid_placeholder = "invalid_placeholder"
|
||||||
|
|
||||||
|
|
||||||
|
def find_restricted_go2rtc_stream_changes(
|
||||||
|
old_streams: dict[str, Any] | None, new_streams: dict[str, Any] | None
|
||||||
|
) -> dict[str, Go2rtcStreamIssue]:
|
||||||
|
"""Return the go2rtc streams with new sources that must be rejected.
|
||||||
|
|
||||||
|
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 resolve
|
||||||
|
its {FRIGATE_*} placeholders and 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 {}
|
||||||
|
issues: dict[str, Go2rtcStreamIssue] = {}
|
||||||
|
|
||||||
|
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
|
||||||
|
issues[name] = Go2rtcStreamIssue.restricted
|
||||||
|
break
|
||||||
|
|
||||||
|
# only sources whose placeholders resolve here are accepted, so the
|
||||||
|
# resolved value checked below is what go2rtc loads at startup
|
||||||
|
try:
|
||||||
|
resolved_source = substitute_frigate_vars(source)
|
||||||
|
except KeyError:
|
||||||
|
issues[name] = Go2rtcStreamIssue.unknown_variable
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
issues[name] = Go2rtcStreamIssue.invalid_placeholder
|
||||||
|
break
|
||||||
|
|
||||||
|
if is_restricted_go2rtc_api_source(
|
||||||
|
source
|
||||||
|
) or is_restricted_go2rtc_api_source(resolved_source):
|
||||||
|
issues[name] = Go2rtcStreamIssue.restricted
|
||||||
|
break
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
|
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
|
||||||
"""Run ffprobe on stream."""
|
"""Run ffprobe on stream."""
|
||||||
clean_path = escape_special_characters(path)
|
clean_path = escape_special_characters(path)
|
||||||
|
|||||||
Reference in New Issue
Block a user