mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-20 05:32:49 +03:00
Merge remote-tracking branch 'origin/master' into dev
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
Brings the go2rtc security fixes from #24658 to dev. Dev-specific changes in the merge: - frigate/config/env.py stays deleted; the environment_vars protection for GO2RTC_ALLOW_ARBITRARY_EXEC moves to apply_config_env_vars in frigate/util/env.py, so the value never reaches os.environ - services.py imports substitute_frigate_vars from frigate.util.env and maps UnknownVariableError to the unknown variable message - go2rtc stream tests patch dev's import-time exec override snapshot and its FRIGATE_* variable sources - test_http_media.py keeps dev's _insert_recording helper; the one added on master replaced it and broke the dev tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+80
-2
@@ -78,6 +78,8 @@ from frigate.util.live_streams import (
|
||||
from frigate.util.object_names import get_categorized_object_names
|
||||
from frigate.util.schema import get_config_schema
|
||||
from frigate.util.services import (
|
||||
Go2rtcStreamIssue,
|
||||
find_restricted_go2rtc_stream_changes,
|
||||
get_nvidia_driver_info,
|
||||
process_logs,
|
||||
restart_frigate,
|
||||
@@ -546,6 +548,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"]))])
|
||||
def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
||||
new_config = body.decode()
|
||||
@@ -622,10 +681,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()
|
||||
@@ -960,6 +1031,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(
|
||||
|
||||
+92
-10
@@ -12,8 +12,9 @@ import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
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.responses import JSONResponse, RedirectResponse
|
||||
from joserfc import jwt
|
||||
@@ -1003,7 +1004,7 @@ def create_user(
|
||||
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
|
||||
config_roles = list(request.app.frigate_config.auth.roles.keys())
|
||||
|
||||
if not re.match("^[A-Za-z0-9._]+$", body.username):
|
||||
if not re.match(r"^(?!\.+$)[A-Za-z0-9._]+$", body.username):
|
||||
return JSONResponse(content={"message": "Invalid username"}, status_code=400)
|
||||
|
||||
if body.role not in config_roles:
|
||||
@@ -1229,11 +1230,44 @@ 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 —
|
||||
# `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(
|
||||
{
|
||||
"/live/mse/api/ws",
|
||||
@@ -1242,32 +1276,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(
|
||||
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.
|
||||
"""
|
||||
if not original_url:
|
||||
return None
|
||||
|
||||
parsed = urlparse(original_url)
|
||||
if parsed.path not in GO2RTC_STREAM_PROXY_PATHS:
|
||||
path, query = _split_request_uri(original_url)
|
||||
if not _is_go2rtc_stream_proxy_request(path, request):
|
||||
return None
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
+11
-6
@@ -34,7 +34,11 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.models import User
|
||||
from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
|
||||
from frigate.util.builtin import (
|
||||
clean_camera_user_pass,
|
||||
clear_orphaned_comments,
|
||||
get_record_segment_time,
|
||||
)
|
||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.env import UnknownVariableError, substitute_frigate_vars
|
||||
@@ -47,7 +51,7 @@ from frigate.util.live_streams import (
|
||||
from frigate.util.services import (
|
||||
analyze_record_keyframes,
|
||||
ffprobe_stream,
|
||||
is_restricted_go2rtc_source,
|
||||
is_restricted_go2rtc_api_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -153,9 +157,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(
|
||||
@@ -174,9 +178,9 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
|
||||
except UnknownVariableError:
|
||||
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(
|
||||
@@ -1273,6 +1277,7 @@ async def delete_camera(
|
||||
# Remove camera from config
|
||||
if "cameras" in data and camera_name in data["cameras"]:
|
||||
del data["cameras"][camera_name]
|
||||
clear_orphaned_comments(data["cameras"], data, "cameras")
|
||||
|
||||
# Remove camera from auth roles
|
||||
auth = data.get("auth", {})
|
||||
|
||||
+18
-3
@@ -58,6 +58,7 @@ from frigate.api.defs.response.export_response import (
|
||||
)
|
||||
from frigate.api.defs.response.generic_response import GenericResponse
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.api.media_auth import deny_response_for_media_uri
|
||||
from frigate.const import CLIPS_DIR, EXPORT_DIR
|
||||
from frigate.jobs.export import (
|
||||
ExportJob,
|
||||
@@ -134,6 +135,7 @@ def _validate_export_case(export_case_id: str | None) -> JSONResponse | None:
|
||||
|
||||
|
||||
def _sanitize_existing_image(
|
||||
request: Request,
|
||||
image_path: str | None,
|
||||
) -> tuple[str | None, JSONResponse | None]:
|
||||
if not image_path:
|
||||
@@ -141,6 +143,15 @@ def _sanitize_existing_image(
|
||||
|
||||
existing_image = sanitize_contained_path(image_path, CLIPS_DIR)
|
||||
|
||||
# CLIPS_DIR is shared by every camera, so the caller must also be allowed
|
||||
# to read the image.
|
||||
if existing_image is not None and deny_response_for_media_uri(
|
||||
f"/clips/{quote(os.path.relpath(existing_image, CLIPS_DIR))}",
|
||||
request.headers.get("remote-role"),
|
||||
request.app.frigate_config,
|
||||
):
|
||||
existing_image = None
|
||||
|
||||
if existing_image is None:
|
||||
return None, JSONResponse(
|
||||
content={"success": False, "message": "Invalid image path"},
|
||||
@@ -701,7 +712,7 @@ def export_recordings_batch(
|
||||
sanitized_images: list[str | None] = []
|
||||
for item in body.items:
|
||||
existing_image, image_validation_error = _sanitize_existing_image(
|
||||
item.image_path
|
||||
request, item.image_path
|
||||
)
|
||||
if image_validation_error is not None:
|
||||
return image_validation_error
|
||||
@@ -849,7 +860,9 @@ def export_recording(
|
||||
|
||||
playback_source = body.source
|
||||
friendly_name = body.name
|
||||
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
|
||||
existing_image, image_validation_error = _sanitize_existing_image(
|
||||
request, body.image_path
|
||||
)
|
||||
if image_validation_error is not None:
|
||||
return image_validation_error
|
||||
|
||||
@@ -1044,7 +1057,9 @@ def export_recording_custom(
|
||||
|
||||
playback_source = body.source
|
||||
friendly_name = body.name
|
||||
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
|
||||
existing_image, image_validation_error = _sanitize_existing_image(
|
||||
request, body.image_path
|
||||
)
|
||||
if image_validation_error is not None:
|
||||
return image_validation_error
|
||||
ffmpeg_input_args = body.ffmpeg_input_args
|
||||
|
||||
+13
-4
@@ -384,8 +384,13 @@ async def get_snapshot_from_recording(
|
||||
mime_type = "png" if format == "png" else "jpeg"
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
image_data = get_image_from_recording(
|
||||
config.ffmpeg, recording.path, time_in_segment, codec, height
|
||||
image_data = await asyncio.to_thread(
|
||||
get_image_from_recording,
|
||||
config.ffmpeg,
|
||||
recording.path,
|
||||
time_in_segment,
|
||||
codec,
|
||||
height,
|
||||
)
|
||||
|
||||
if not image_data:
|
||||
@@ -439,8 +444,12 @@ async def submit_recording_snapshot_to_plus(
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
recording: Recordings = recording_query.get()
|
||||
time_in_segment = frame_time - recording.start_time
|
||||
image_data = get_image_from_recording(
|
||||
config.ffmpeg, recording.path, time_in_segment, "png"
|
||||
image_data = await asyncio.to_thread(
|
||||
get_image_from_recording,
|
||||
config.ffmpeg,
|
||||
recording.path,
|
||||
time_in_segment,
|
||||
"png",
|
||||
)
|
||||
|
||||
if not image_data:
|
||||
|
||||
Reference in New Issue
Block a user