From 001355552867f1327e4abc0c2377b9eda1312e01 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 18 May 2026 10:32:39 -0500 Subject: [PATCH 01/12] Fixes (#23235) * use stable empty object reference for swr metadata default * version bump * Refactor get_min_region_size for dimension normalization Refactor get_min_region_size to normalize dimensions for smaller models and ensure minimum region size is 320 for larger models. * reject restricted go2rtc stream sources when added via api * add env var check function * fix typing --------- Co-authored-by: Nicolas Mowen --- Makefile | 2 +- .../rootfs/usr/local/go2rtc/create_config.py | 35 ++------------ docs/docs/frigate/updating.md | 12 ++--- frigate/api/camera.py | 15 +++++- .../test/http_api/test_http_camera_access.py | 46 +++++++++++++++++++ frigate/util/object.py | 19 ++++---- frigate/util/services.py | 35 ++++++++++++++ web/src/hooks/use-deferred-stream-metadata.ts | 4 +- 8 files changed, 117 insertions(+), 51 deletions(-) diff --git a/Makefile b/Makefile index 51f12f972a..42adb6bacc 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ default_target: local COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1) -VERSION = 0.17.1 +VERSION = 0.17.2 IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) BOARDS= #Initialized empty diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index fb701a9b62..96de79f93b 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -17,36 +17,12 @@ from frigate.const import ( ) from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode from frigate.util.config import find_config_file +from frigate.util.services import is_restricted_go2rtc_source sys.path.remove("/opt/frigate") yaml = YAML() -# Check if arbitrary exec sources are allowed (defaults to False for security) -allow_arbitrary_exec = None -if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ: - allow_arbitrary_exec = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC") -elif ( - os.path.isdir("/run/secrets") - and os.access("/run/secrets", os.R_OK) - and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets") -): - allow_arbitrary_exec = ( - Path(os.path.join("/run/secrets", "GO2RTC_ALLOW_ARBITRARY_EXEC")) - .read_text() - .strip() - ) -# check for the add-on options file -elif os.path.isfile("/data/options.json"): - with open("/data/options.json") as f: - raw_options = f.read() - options = json.loads(raw_options) - allow_arbitrary_exec = options.get("go2rtc_allow_arbitrary_exec") - -ALLOW_ARBITRARY_EXEC = allow_arbitrary_exec is not None and str( - allow_arbitrary_exec -).lower() in ("true", "1", "yes") - FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")} # read docker secret files as env vars too if os.path.isdir("/run/secrets"): @@ -135,18 +111,13 @@ if LIBAVFORMAT_VERSION_MAJOR < 59: go2rtc_config["ffmpeg"]["rtsp"] = rtsp_args -def is_restricted_source(stream_source: str) -> bool: - """Check if a stream source is restricted (echo, expr, or exec).""" - return stream_source.strip().startswith(("echo:", "expr:", "exec:")) - - for name in list(go2rtc_config.get("streams", {})): stream = go2rtc_config["streams"][name] if isinstance(stream, str): try: formatted_stream = stream.format(**FRIGATE_ENV_VARS) - if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream): + if is_restricted_go2rtc_source(formatted_stream): print( f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. " f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources." @@ -165,7 +136,7 @@ for name in list(go2rtc_config.get("streams", {})): for i, stream_item in enumerate(stream): try: formatted_stream = stream_item.format(**FRIGATE_ENV_VARS) - if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream): + if is_restricted_go2rtc_source(formatted_stream): print( f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. " f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources." diff --git a/docs/docs/frigate/updating.md b/docs/docs/frigate/updating.md index 841a3e2d58..4e3add3733 100644 --- a/docs/docs/frigate/updating.md +++ b/docs/docs/frigate/updating.md @@ -5,7 +5,7 @@ title: Updating # Updating Frigate -The current stable version of Frigate is **0.17.0**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.17.0). +The current stable version of Frigate is **0.17.2**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.17.2). Keeping Frigate up to date ensures you benefit from the latest features, performance improvements, and bug fixes. The update process varies slightly depending on your installation method (Docker, Home Assistant App, etc.). Below are instructions for the most common setups. @@ -31,21 +31,21 @@ If you’re running Frigate via Docker (recommended method), follow these steps: 2. **Update and Pull the Latest Image**: - If using Docker Compose: - - Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.17.0` instead of `0.16.4`). For example: + - Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.17.2` instead of `0.16.4`). For example: ```yaml services: frigate: - image: ghcr.io/blakeblackshear/frigate:0.17.0 + image: ghcr.io/blakeblackshear/frigate:0.17.2 ``` - Then pull the image: ```bash - docker pull ghcr.io/blakeblackshear/frigate:0.17.0 + docker pull ghcr.io/blakeblackshear/frigate:0.17.2 ``` - **Note for `stable` Tag Users**: If your `docker-compose.yml` uses the `stable` tag (e.g., `ghcr.io/blakeblackshear/frigate:stable`), you don’t need to update the tag manually. The `stable` tag always points to the latest stable release after pulling. - If using `docker run`: - - Pull the image with the appropriate tag (e.g., `0.17.0`, `0.17.0-tensorrt`, or `stable`): + - Pull the image with the appropriate tag (e.g., `0.17.2`, `0.17.2-tensorrt`, or `stable`): ```bash - docker pull ghcr.io/blakeblackshear/frigate:0.17.0 + docker pull ghcr.io/blakeblackshear/frigate:0.17.2 ``` 3. **Start the Container**: diff --git a/frigate/api/camera.py b/frigate/api/camera.py index a94486d8c2..1dae5ae31d 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -24,7 +24,7 @@ from frigate.api.defs.tags import Tags from frigate.config.config import FrigateConfig from frigate.util.builtin import clean_camera_user_pass from frigate.util.image import run_ffmpeg_snapshot -from frigate.util.services import ffprobe_stream +from frigate.util.services import ffprobe_stream, is_restricted_go2rtc_source logger = logging.getLogger(__name__) @@ -111,6 +111,19 @@ 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): + logger.warning( + "Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)", + stream_name, + ) + return JSONResponse( + content={ + "success": False, + "message": "Restricted stream source type", + }, + status_code=400, + ) + try: params = {"name": stream_name} if src: diff --git a/frigate/test/http_api/test_http_camera_access.py b/frigate/test/http_api/test_http_camera_access.py index 211c84bb4f..44520d79f5 100644 --- a/frigate/test/http_api/test_http_camera_access.py +++ b/frigate/test/http_api/test_http_camera_access.py @@ -1,3 +1,4 @@ +import os from unittest.mock import patch from fastapi import HTTPException, Request @@ -357,6 +358,51 @@ class TestGo2rtcStreamAccess(BaseTestHttp): f"got {resp.status_code}" ) + def test_add_stream_rejects_restricted_source(self): + """PUT /go2rtc/streams must reject exec:/echo:/expr: sources even for + admins""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + with AuthTestClient(app) as client: + for src in ( + "exec:/tmp/rev.sh", + "echo:foo", + "expr:bar", + " exec:/tmp/rev.sh", + ): + resp = client.put(f"/go2rtc/streams/revshell?src={src}") + assert resp.status_code == 400, ( + f"Expected 400 for restricted src {src!r}; got {resp.status_code}" + ) + assert resp.json().get("success") is False + + def test_add_stream_allows_non_restricted_source(self): + """A normal stream URL should pass the restricted-source check and reach + the (unavailable in tests) go2rtc proxy — so we expect 500, not 400.""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + with AuthTestClient(app) as client: + resp = client.put("/go2rtc/streams/legit?src=rtsp://10.0.0.1:554/video") + assert resp.status_code != 400, ( + f"Non-restricted source should not be rejected with 400; got {resp.status_code}" + ) + + def test_add_stream_allows_restricted_source_when_override_set(self): + """When GO2RTC_ALLOW_ARBITRARY_EXEC is set, the API must defer to operator + intent and forward the request to go2rtc instead of short-circuiting with 400.""" + 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?src=exec:/tmp/something") + assert resp.status_code == 200, ( + f"Restricted src should be forwarded when override set; got {resp.status_code}" + ) + mock_put.assert_called_once() + forwarded_src = mock_put.call_args.kwargs["params"]["src"] + assert forwarded_src == "exec:/tmp/something" + 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.""" diff --git a/frigate/util/object.py b/frigate/util/object.py index 905745da62..b8f41e2c32 100644 --- a/frigate/util/object.py +++ b/frigate/util/object.py @@ -271,18 +271,17 @@ def get_min_region_size(model_config: ModelConfig) -> int: """Get the min region size.""" largest_dimension = max(model_config.height, model_config.width) - if largest_dimension > 320: - # We originally tested allowing any model to have a region down to half of the model size - # but this led to many false positives. In this case we specifically target larger models - # which can benefit from a smaller region in some cases to detect smaller objects. - half = int(largest_dimension / 2) + # return largest dimension for smaller models, but make sure the dimension is normalized + if largest_dimension < 320: + if largest_dimension % 4 == 0: + return largest_dimension - if half % 4 == 0: - return half + return int((largest_dimension + 3) / 4) * 4 - return int((half + 3) / 4) * 4 - - return largest_dimension + # Any model that is 320 or larger should have a minimum region size of 320 + # this allows larger models to use smaller regions to detect smaller objects + # in the case that the motion area is smaller so that it can be upscaled. + return 320 def create_tensor_input(frame, model_config: ModelConfig, region): diff --git a/frigate/util/services.py b/frigate/util/services.py index 64d83833dc..d366a7390a 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -556,6 +556,41 @@ def get_jetson_stats() -> Optional[dict[int, dict]]: return results +def _go2rtc_arbitrary_exec_allowed() -> bool: + """Read the GO2RTC_ALLOW_ARBITRARY_EXEC override from env, docker + secrets, or the Home Assistant add-on options file.""" + raw: Optional[str] = None + if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ: + raw = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC") + elif ( + os.path.isdir("/run/secrets") + and os.access("/run/secrets", os.R_OK) + and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets") + ): + try: + with open("/run/secrets/GO2RTC_ALLOW_ARBITRARY_EXEC") as f: + raw = f.read().strip() + except OSError: + raw = None + elif os.path.isfile("/data/options.json"): + try: + with open("/data/options.json") as f: + options = json.loads(f.read()) + raw = options.get("go2rtc_allow_arbitrary_exec") + except (OSError, json.JSONDecodeError): + raw = None + + return raw is not None and str(raw).lower() in ("true", "1", "yes") + + +def is_restricted_go2rtc_source(stream_source: str) -> bool: + """Check if a stream source is a restricted type (echo, expr, or exec) + and the GO2RTC_ALLOW_ARBITRARY_EXEC override is not set.""" + if not stream_source.strip().startswith(("echo:", "expr:", "exec:")): + return False + return not _go2rtc_arbitrary_exec_allowed() + + def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess: """Run ffprobe on stream.""" clean_path = escape_special_characters(path) diff --git a/web/src/hooks/use-deferred-stream-metadata.ts b/web/src/hooks/use-deferred-stream-metadata.ts index 8e68b6a6a3..251dc97345 100644 --- a/web/src/hooks/use-deferred-stream-metadata.ts +++ b/web/src/hooks/use-deferred-stream-metadata.ts @@ -5,6 +5,8 @@ import { LiveStreamMetadata } from "@/types/live"; const FETCH_TIMEOUT_MS = 10000; const DEFER_DELAY_MS = 2000; +const emptyObject: Readonly<{ [key: string]: LiveStreamMetadata }> = + Object.freeze({}); /** * Hook that fetches go2rtc stream metadata with deferred loading. @@ -77,7 +79,7 @@ export default function useDeferredStreamMetadata(streamNames: string[]) { return metadata; }, []); - const { data: metadata = {} } = useSWR<{ + const { data: metadata = emptyObject } = useSWR<{ [key: string]: LiveStreamMetadata; }>(swrKey, fetcher, { revalidateOnFocus: false, From 26d31300e66dda2db6dcd90b84be268dc5e77aef Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 18 May 2026 09:58:10 -0600 Subject: [PATCH 02/12] Add metadata for creation time to recording segments and exports (#23239) --- frigate/record/export.py | 24 ++++++++++++++++++++++-- frigate/record/maintainer.py | 2 ++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/frigate/record/export.py b/frigate/record/export.py index d4b49bb4b0..3c4f3ba4a5 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -235,7 +235,17 @@ class RecordingExporter(threading.Thread): # add metadata title = f"Frigate Recording for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}" - ffmpeg_cmd.extend(["-metadata", f"title={title}"]) + creation_time = datetime.datetime.fromtimestamp( + self.start_time, tz=datetime.timezone.utc + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + ffmpeg_cmd.extend( + [ + "-metadata", + f"title={title}", + "-metadata", + f"creation_time={creation_time}", + ] + ) ffmpeg_cmd.append(video_path) @@ -326,7 +336,17 @@ class RecordingExporter(threading.Thread): # add metadata title = f"Frigate Preview for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}" - ffmpeg_cmd.extend(["-metadata", f"title={title}"]) + creation_time = datetime.datetime.fromtimestamp( + self.start_time, tz=datetime.timezone.utc + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + ffmpeg_cmd.extend( + [ + "-metadata", + f"title={title}", + "-metadata", + f"creation_time={creation_time}", + ] + ) return ffmpeg_cmd, playlist_lines diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index a90d1edc12..e36df78d0b 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -547,6 +547,8 @@ class RecordingMaintainer(threading.Thread): "copy", "-movflags", "+faststart", + "-metadata", + f"creation_time={start_time.strftime('%Y-%m-%dT%H:%M:%S.%fZ')}", file_path, stderr=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.DEVNULL, From 06b059c36ae3fd8beebd69bafb57fac05891de59 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 20 May 2026 07:29:37 -0500 Subject: [PATCH 03/12] fix admin response cache leak to non-admin users via nginx proxy_cache (#23261) --- docker/main/rootfs/usr/local/nginx/conf/nginx.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index 46241c5ab1..f6b0928eb9 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -259,6 +259,7 @@ http { include proxy.conf; proxy_cache api_cache; + proxy_cache_key "$scheme$proxy_host$request_uri|$role|$groups|$user"; proxy_cache_lock on; proxy_cache_use_stale updating; proxy_cache_valid 200 5s; From ef44c18c07999ec649705b5ad1290c91ff40e7be Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 21 May 2026 09:04:41 -0500 Subject: [PATCH 04/12] Docs update (#23280) * stationary car detection troubleshooting tips * tweak --- docs/docs/troubleshooting/faqs.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/docs/troubleshooting/faqs.md b/docs/docs/troubleshooting/faqs.md index ff2379ea72..bba4a1c187 100644 --- a/docs/docs/troubleshooting/faqs.md +++ b/docs/docs/troubleshooting/faqs.md @@ -110,3 +110,19 @@ No. Frigate uses the TCP protocol to connect to your camera's RTSP URL. VLC auto TCP ensures that all data packets arrive in the correct order. This is crucial for video recording, decoding, and stream processing, which is why Frigate enforces a TCP connection. UDP is faster but less reliable, as it does not guarantee packet delivery or order, and VLC does not have the same requirements as Frigate. You can still configure Frigate to use UDP by using ffmpeg input args or the preset `preset-rtsp-udp`. See the [ffmpeg presets](/configuration/ffmpeg_presets) documentation. + +### Why does Frigate keep creating new events for my parked car? + +Stationary tracking is designed to _prevent_ this — a parked car should stay one tracked object and not generate new events. If you're getting repeated events for the same car, it's likely that Frigate is losing the tracked object and re-detecting it as a new one. + +Open one of the events in Explore → **Tracking Details**. If the detection scores are low (< 70% or so), the model isn't confident the parked car is a car. This is common with the free [COCO-trained](https://cocodataset.org/#explore) object detection models on steep/top-down angles, partially occluded cars, foliage, or low-light footage. When detections fall below `min_score` for too many frames the tracker loses the object, and the next confident frame creates a brand new one. + +What helps: + +- **Improve the view** — even a small angle change that gets more of the car visible could lift scores enough to stabilize tracking. +- **Use a more accurate model** — switching from `mobiledet` to `yolov9`, or stepping up to a larger variant like `yolov9-s` over `yolov9-t`, can help (at the cost of inference time, and still on the COCO dataset). The biggest gains usually come from fine-tuning a model on images from your own cameras so it learns your specific scene. [Frigate+](https://frigate.video/plus) is a paid option that does this - models are trained on security-camera footage and can be fine-tuned on images you submit from your own setup. +- **Don't set `detect -> stationary -> max_frames` for `car`** — it artificially ends tracking and forces re-detection as a new object. See [Stationary Objects](../configuration/stationary_objects.md). +- **Restrict alerts to the areas you care about** with `required_zones` — see [Zones](../configuration/zones.md#restricting-alerts-and-detections-to-specific-zones). Make sure those zones use the default `loitering_time: 0` unless you specifically want the review item to stay open until the car leaves. +- **Filter impossible locations** with [object filter masks](../configuration/masks.md#object-filter-masks) if cars are being detected on rooftops, treetops, etc. + +See [Object Filters](../configuration/object_filters.md) for more on tuning `min_score` and `threshold` — note that raising them too high will make this exact problem worse. From 910059281f1cfd5b52f5ee70f63216499bd3e28c Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 21 May 2026 15:00:46 -0500 Subject: [PATCH 05/12] update mask docs for more clarity (#23282) --- docs/docs/configuration/masks.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/docs/configuration/masks.md b/docs/docs/configuration/masks.md index 4a47225863..0fcf366eda 100644 --- a/docs/docs/configuration/masks.md +++ b/docs/docs/configuration/masks.md @@ -3,6 +3,8 @@ id: masks title: Masks --- +Frigate has two kinds of masks: motion masks and object filter masks. Both are narrow tools for fine-tuning, **not for hiding an area from Frigate**. Masks should be used sparingly; in most cases where users reach for one, a [zone](zones.md) with `required_zones` is the right tool instead. See [Which tool do I need?](#which-tool-do-i-need) and [Common mistakes](#common-mistakes) below if you're new to Frigate's mask behavior. + ## Motion masks Motion masks are used to prevent unwanted types of motion from triggering detection. Try watching the Debug feed (Settings --> Debug) with `Motion Boxes` enabled to see what may be regularly detected as motion. For example, you want to mask out your timestamp, the sky, rooftops, etc. Keep in mind that this mask only prevents motion from being detected and does not prevent objects from being detected if object detection was started due to motion in unmasked areas. Motion is also used during object tracking to refine the object detection area in the next frame. _Over-masking will make it more difficult for objects to be tracked._ @@ -17,6 +19,15 @@ Object filter masks can be used to filter out stubborn false positives in fixed ![object mask](/img/bottom-center-mask.jpg) +## Which tool do I need? + +| What you're trying to do | Recommended tool | How it works | +| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Don't get alerts or recordings for activity in an area (e.g., the sidewalk in front of your house) | A [zone](zones.md) combined with `review.alerts.required_zones` (and/or `review.detections.required_zones`) | Frigate keeps detecting and tracking activity in the area, but a review item is only created once the bottom-center of an object's bounding box enters a required zone. | +| Stop a stubborn false positive at a specific fixed spot (e.g., a tree base that keeps being detected as a person) | An **object filter mask** for that object type | Any detection of that object type whose bounding-box bottom-center lands inside the mask is treated as a false positive and discarded. | +| Ignore motion in an area that obviously isn't an object of interest (e.g., the camera timestamp, sky, flags, treetops swaying) | A **motion mask** | Motion inside the mask is ignored when deciding whether to run object detection. Objects can still be detected in a motion masked area if motion elsewhere in the frame triggers detection. | +| Stop tracking an object type altogether on this camera (e.g., you never care about cats) | Remove the object from the camera's [`objects.track`](objects.md) list | Frigate skips this object type entirely on this camera, regardless of where it appears. | + ## Using the mask creator To create a poly mask: @@ -82,3 +93,14 @@ This is what `required_zones` are for. You should define a zone (remember this i > Maybe my specific situation just warrants this. I've just been having a hard time understanding the relevance of this information - it seems to be that it's exactly what would be expected when "masking out" an area of ANY image. That may be the case for you. Frigate will definitely work harder tracking people on the sidewalk to make sure it doesn't miss anyone who steps foot on your stoop. The trade off with the way you have it now is slower recognition of objects and potential misses. That may be acceptable based on your needs. Also, if your resolution is low enough on the detect stream, your regions may already be so big that they grab the entire object anyway. + +## Common mistakes + +**"I added a motion mask to ignore my driveway/sidewalk."** +A motion mask doesn't hide an area from Frigate. Objects can still be detected and tracked inside a masked area. The mask only stops motion _in that area_ from triggering object detection. If you want activity on the sidewalk to never produce a review item, define a [zone](zones.md) over the area you DO care about (your stoop, your driveway) and add it to `review.alerts.required_zones`. Frigate will still see people on the sidewalk, but it won't create an alert until they cross into the zone. + +**"I added an object filter mask because I don't care about cars in my yard."** +Object filter masks are for stubborn false positives at fixed locations, not for filtering whole areas or whole object types. If you only want alerts when a car enters the driveway, use a [zone](zones.md) with `required_zones`. If you don't care about a whole object type on this camera, remove it from [`objects.track`](objects.md). + +**"I masked everything except a thin strip on my stoop."** +Heavy masking hurts tracking. Frigate uses motion near a tracked object's previous bounding box to decide where to look in the next frame; with most of the frame masked, an object walking from an unmasked area into a masked one effectively disappears and gets picked up as a "new" object when it reappears. For example: someone walks down your sidewalk, stops under a tree (masked area) to tie their shoe, then continues. Frigate sees that as two separate people and can create two separate review items. Because Frigate needs several consecutive frames above the confidence threshold to commit to a detection, each re-appearance can also delay or miss alerts. Use `required_zones` for "only alert me about this spot" and leave the surrounding area unmasked so tracking stays intact. From fa07109a855b7e4b9d6538f8b7d3cd9424fbf761 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 23 May 2026 07:47:32 -0500 Subject: [PATCH 06/12] filter motion review by allowed cameras (#23294) --- web/src/views/events/EventView.tsx | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 70067ff5c0..dd1bb75e4d 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -44,6 +44,7 @@ import SummaryTimeline from "@/components/timeline/SummaryTimeline"; import { RecordingStartingPoint } from "@/types/record"; import VideoControls from "@/components/player/VideoControls"; import { TimeRange } from "@/types/timeline"; +import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity"; import useOptimisticState from "@/hooks/use-optimistic-state"; import { Skeleton } from "@/components/ui/skeleton"; @@ -918,25 +919,26 @@ function MotionReview({ }: MotionReviewProps) { const segmentDuration = 30; const { data: config } = useSWR("config"); + const allowedCameras = useAllowedCameras(); const reviewCameras = useMemo(() => { if (!config) { return []; } - let cameras; - if (!filter || !filter.cameras) { - cameras = Object.values(config.cameras); - } else { - const filteredCams = filter.cameras; - - cameras = Object.values(config.cameras).filter((cam) => - filteredCams.includes(cam.name), - ); - } + const selectedCams = filter?.cameras; + const cameras = Object.values(config.cameras).filter((cam) => { + if (!allowedCameras.includes(cam.name)) { + return false; + } + if (selectedCams && !selectedCams.includes(cam.name)) { + return false; + } + return true; + }); return cameras.sort((a, b) => a.ui.order - b.ui.order); - }, [config, filter]); + }, [config, filter, allowedCameras]); const videoPlayersRef = useRef<{ [camera: string]: PreviewController }>({}); From 28e3e1ec7434d4068da65ae0e1473c415772cc36 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 25 May 2026 12:06:16 -0600 Subject: [PATCH 07/12] Add ability to control chapters set on MP4 Export (#23310) --- .../defs/request/export_recordings_body.py | 9 ++ frigate/api/export.py | 2 + frigate/record/export.py | 118 +++++++++++++++++- 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/frigate/api/defs/request/export_recordings_body.py b/frigate/api/defs/request/export_recordings_body.py index 19fc2f0194..aef5aa9520 100644 --- a/frigate/api/defs/request/export_recordings_body.py +++ b/frigate/api/defs/request/export_recordings_body.py @@ -4,6 +4,7 @@ from pydantic import BaseModel, Field from pydantic.json_schema import SkipJsonSchema from frigate.record.export import ( + ChaptersEnum, PlaybackFactorEnum, PlaybackSourceEnum, ) @@ -18,3 +19,11 @@ class ExportRecordingsBody(BaseModel): ) name: Optional[str] = Field(title="Friendly name", default=None, max_length=256) image_path: Union[str, SkipJsonSchema[None]] = None + chapters: Optional[ChaptersEnum] = Field( + default=None, + title="Chapter mode", + description=( + "Optional chapter metadata to embed in the export. When omitted, " + "no chapter track is added." + ), + ) diff --git a/frigate/api/export.py b/frigate/api/export.py index 24fed93b03..786e046dbb 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -31,6 +31,7 @@ from frigate.api.defs.tags import Tags from frigate.const import CLIPS_DIR, EXPORT_DIR from frigate.models import Export, Previews, Recordings from frigate.record.export import ( + ChaptersEnum, PlaybackFactorEnum, PlaybackSourceEnum, RecordingExporter, @@ -161,6 +162,7 @@ def export_recording( if playback_source in PlaybackSourceEnum.__members__.values() else PlaybackSourceEnum.recordings ), + chapters=ChaptersEnum(body.chapters) if body.chapters else None, ) exporter.start() return JSONResponse( diff --git a/frigate/record/export.py b/frigate/record/export.py index 3c4f3ba4a5..d5d5ddb766 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -12,6 +12,7 @@ from enum import Enum from pathlib import Path from typing import Optional +import pytz from peewee import DoesNotExist from frigate.config import FfmpegConfig, FrigateConfig @@ -50,6 +51,14 @@ class PlaybackSourceEnum(str, Enum): preview = "preview" +class ChaptersEnum(str, Enum): + # One chapter per recording segment, titled with the segment's + # wallclock start time in strict ISO 8601 form. Lets viewers map + # output playback time back to wallclock without reading a timestamp + # overlay via OCR. + recording_segments = "recording_segments" + + class RecordingExporter(threading.Thread): """Exports a specific set of recordings for a camera to storage as a single file.""" @@ -64,6 +73,7 @@ class RecordingExporter(threading.Thread): end_time: int, playback_factor: PlaybackFactorEnum, playback_source: PlaybackSourceEnum, + chapters: Optional[ChaptersEnum] = None, ) -> None: super().__init__() self.config = config @@ -75,6 +85,7 @@ class RecordingExporter(threading.Thread): self.end_time = end_time self.playback_factor = playback_factor self.playback_source = playback_source + self.chapters = chapters # ensure export thumb dir Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True) @@ -83,6 +94,77 @@ class RecordingExporter(threading.Thread): # return in iso format return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + def _chapter_metadata_path(self) -> str: + return os.path.join(CACHE_DIR, f"export_chapters_{self.export_id}.txt") + + def _build_recording_segment_chapter_metadata_file( + self, recordings: list + ) -> Optional[str]: + """Write an FFmpeg metadata file with one chapter per recording segment. + + Each chapter's title is the segment's wallclock start time in + strict ISO 8601 form so a viewer can map any point in the + export's playback timeline back to real-world time without + OCR-ing a burnt-in timestamp. Chapter offsets are computed in + *output time*: the VOD endpoint concatenates recording clips + back-to-back, so wall-clock gaps between recordings collapse in + the produced video. Returns ``None`` when there are no + recordings or every segment is empty after clipping. + """ + if not recordings: + return None + + tz_name = self.config.ui.timezone + tz: Optional[datetime.tzinfo] = None + if tz_name: + try: + tz = pytz.timezone(tz_name) + except pytz.UnknownTimeZoneError: + tz = None + if tz is None: + tz = datetime.timezone.utc + + chapter_blocks: list[str] = [] + output_offset_ms = 0 + for rec in recordings: + clipped_start = max(float(rec.start_time), float(self.start_time)) + clipped_end = min(float(rec.end_time), float(self.end_time)) + if clipped_end <= clipped_start: + continue + + duration_ms = int(round((clipped_end - clipped_start) * 1000)) + if duration_ms <= 0: + continue + + title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat( + timespec="seconds" + ) + chapter_blocks.append( + "[CHAPTER]\n" + "TIMEBASE=1/1000\n" + f"START={output_offset_ms}\n" + f"END={output_offset_ms + duration_ms}\n" + f"title={title}" + ) + output_offset_ms += duration_ms + + if not chapter_blocks: + return None + + meta_path = self._chapter_metadata_path() + try: + with open(meta_path, "w", encoding="utf-8") as f: + f.write(";FFMETADATA1\n") + f.write("\n".join(chapter_blocks)) + f.write("\n") + except OSError: + logger.exception( + "Failed to write chapter metadata file for export %s", self.export_id + ) + return None + + return meta_path + def save_thumbnail(self, id: str) -> str: thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp") @@ -218,9 +300,41 @@ class RecordingExporter(threading.Thread): ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin" + # When chapters are requested, query the per-segment recording rows + # and write an FFmpeg metadata sidecar. Timelapse playback rescales + # time so chapter offsets would no longer match wallclock — restrict + # chapter injection to realtime playback. + chapter_args = "" + if ( + self.chapters == ChaptersEnum.recording_segments + and self.playback_factor == PlaybackFactorEnum.realtime + ): + recordings = list( + Recordings.select( + Recordings.start_time, + Recordings.end_time, + ) + .where( + Recordings.start_time.between(self.start_time, self.end_time) + | Recordings.end_time.between(self.start_time, self.end_time) + | ( + (self.start_time > Recordings.start_time) + & (self.end_time < Recordings.end_time) + ) + ) + .where(Recordings.camera == self.camera) + .order_by(Recordings.start_time.asc()) + .iterator() + ) + chapters_path = self._build_recording_segment_chapter_metadata_file( + recordings + ) + if chapters_path: + chapter_args = f" -i {chapters_path} -map 0 -dn -map_metadata 1" + if self.playback_factor == PlaybackFactorEnum.realtime: ffmpeg_cmd = ( - f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart" + f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart" ).split(" ") elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: ffmpeg_cmd = ( @@ -396,6 +510,8 @@ class RecordingExporter(threading.Thread): capture_output=True, ) + Path(self._chapter_metadata_path()).unlink(missing_ok=True) + if p.returncode != 0: logger.error( f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}" From 06e3d0ac5dcf2f8d156c571c6317f6547dfc3b73 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 9 Jun 2026 09:07:42 -0600 Subject: [PATCH 08/12] Chapter tweaks (#23440) * Add camera metadata and fix preview chapters * Add config option for chapters --- frigate/api/export.py | 11 +++++++++-- frigate/config/camera/record.py | 10 ++++++++++ frigate/record/export.py | 19 +++++++++---------- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/frigate/api/export.py b/frigate/api/export.py index 786e046dbb..614ac78db6 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -31,7 +31,6 @@ from frigate.api.defs.tags import Tags from frigate.const import CLIPS_DIR, EXPORT_DIR from frigate.models import Export, Previews, Recordings from frigate.record.export import ( - ChaptersEnum, PlaybackFactorEnum, PlaybackSourceEnum, RecordingExporter, @@ -94,6 +93,14 @@ def export_recording( friendly_name = body.name existing_image = sanitize_filepath(body.image_path) if body.image_path else None + # a chapters value in the request body overrides the camera's export config + camera_config = request.app.frigate_config.cameras[camera_name] + chapters = ( + body.chapters + if body.chapters is not None + else camera_config.record.export.chapters + ) + # Ensure that existing_image is a valid path if existing_image and not existing_image.startswith(CLIPS_DIR): return JSONResponse( @@ -162,7 +169,7 @@ def export_recording( if playback_source in PlaybackSourceEnum.__members__.values() else PlaybackSourceEnum.recordings ), - chapters=ChaptersEnum(body.chapters) if body.chapters else None, + chapters=chapters, ) exporter.start() return JSONResponse( diff --git a/frigate/config/camera/record.py b/frigate/config/camera/record.py index 09a7a84d5b..4c8f873568 100644 --- a/frigate/config/camera/record.py +++ b/frigate/config/camera/record.py @@ -9,6 +9,7 @@ from frigate.review.types import SeverityEnum from ..base import FrigateBaseModel __all__ = [ + "ChaptersEnum", "RecordConfig", "RecordExportConfig", "RecordPreviewConfig", @@ -66,10 +67,19 @@ class RecordPreviewConfig(FrigateBaseModel): ) +class ChaptersEnum(str, Enum): + none = "none" + recording_segments = "recording_segments" + + class RecordExportConfig(FrigateBaseModel): timelapse_args: str = Field( default=DEFAULT_TIME_LAPSE_FFMPEG_ARGS, title="Timelapse Args" ) + chapters: ChaptersEnum = Field( + default=ChaptersEnum.none, + title="Chapter metadata to embed in exported recordings", + ) class RecordConfig(FrigateBaseModel): diff --git a/frigate/record/export.py b/frigate/record/export.py index d5d5ddb766..28a72d05ea 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -16,6 +16,7 @@ import pytz from peewee import DoesNotExist from frigate.config import FfmpegConfig, FrigateConfig +from frigate.config.camera.record import ChaptersEnum from frigate.const import ( CACHE_DIR, CLIPS_DIR, @@ -51,14 +52,6 @@ class PlaybackSourceEnum(str, Enum): preview = "preview" -class ChaptersEnum(str, Enum): - # One chapter per recording segment, titled with the segment's - # wallclock start time in strict ISO 8601 form. Lets viewers map - # output playback time back to wallclock without reading a timestamp - # overlay via OCR. - recording_segments = "recording_segments" - - class RecordingExporter(threading.Thread): """Exports a specific set of recordings for a camera to storage as a single file.""" @@ -358,6 +351,8 @@ class RecordingExporter(threading.Thread): f"title={title}", "-metadata", f"creation_time={creation_time}", + "-metadata", + f"comment=Camera: {self.camera}", ] ) @@ -435,7 +430,7 @@ class RecordingExporter(threading.Thread): if self.playback_factor == PlaybackFactorEnum.realtime: ffmpeg_cmd = ( - f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} {codec} -movflags +faststart {video_path}" + f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} {codec} -movflags +faststart" ).split(" ") elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: ffmpeg_cmd = ( @@ -443,7 +438,7 @@ class RecordingExporter(threading.Thread): self.config.ffmpeg.ffmpeg_path, self.config.ffmpeg.hwaccel_args, f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}", - f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}", + f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart", EncodeTypeEnum.timelapse, ) ).split(" ") @@ -459,9 +454,13 @@ class RecordingExporter(threading.Thread): f"title={title}", "-metadata", f"creation_time={creation_time}", + "-metadata", + f"comment=Camera: {self.camera}", ] ) + ffmpeg_cmd.append(video_path) + return ffmpeg_cmd, playlist_lines def run(self) -> None: From b3ce4486b9535491f3498aee339695e77e6a44a4 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 16 Jun 2026 08:07:12 -0600 Subject: [PATCH 09/12] Catch edge cases in security protections (#23493) * Fix go2rtc nested key dict * Don't allow path traversal --- .../rootfs/usr/local/go2rtc/create_config.py | 19 ++++++++++++++++++- frigate/api/export.py | 10 ++++++++++ frigate/util/services.py | 4 ++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index 96de79f93b..dfd8b722ca 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -17,7 +17,10 @@ from frigate.const import ( ) from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode from frigate.util.config import find_config_file -from frigate.util.services import is_restricted_go2rtc_source +from frigate.util.services import ( + is_go2rtc_arbitrary_exec_allowed, + is_restricted_go2rtc_source, +) sys.path.remove("/opt/frigate") @@ -159,6 +162,20 @@ for name in list(go2rtc_config.get("streams", {})): ) del go2rtc_config["streams"][name] + elif isinstance(stream, dict): + # The map form ({"url": ...}) lets go2rtc resolve the source + # recursively, so it is effectively a dynamic way to generate the URL + # for a stream. That can only be backed by an exec source, so it cannot + # be allowed unless arbitrary exec is explicitly enabled. When it is + # enabled, leave the map untouched for go2rtc to resolve. + if not is_go2rtc_arbitrary_exec_allowed(): + print( + f"[ERROR] Stream '{name}' uses a dynamic source format which is disabled by default for security. " + f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources." + ) + del go2rtc_config["streams"][name] + continue + # add birdseye restream stream if enabled if config.get("birdseye", {}).get("restream", False): birdseye: dict[str, Any] = config.get("birdseye") diff --git a/frigate/api/export.py b/frigate/api/export.py index 614ac78db6..8f916eaf17 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -91,6 +91,16 @@ def export_recording( playback_factor = body.playback playback_source = body.source friendly_name = body.name + + # sanitize_filepath normalizes "\" to "/" but leaves ".." intact, so a path + # like "clips\..\..\etc/passwd" passes the CLIPS_DIR prefix check yet still + # escapes the directory once resolved. A valid snapshot path never uses "..". + if body.image_path and ".." in body.image_path: + return JSONResponse( + content=({"success": False, "message": "Invalid image path"}), + status_code=400, + ) + existing_image = sanitize_filepath(body.image_path) if body.image_path else None # a chapters value in the request body overrides the camera's export config diff --git a/frigate/util/services.py b/frigate/util/services.py index d366a7390a..5bf958198c 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -556,7 +556,7 @@ def get_jetson_stats() -> Optional[dict[int, dict]]: return results -def _go2rtc_arbitrary_exec_allowed() -> bool: +def is_go2rtc_arbitrary_exec_allowed() -> bool: """Read the GO2RTC_ALLOW_ARBITRARY_EXEC override from env, docker secrets, or the Home Assistant add-on options file.""" raw: Optional[str] = None @@ -588,7 +588,7 @@ def is_restricted_go2rtc_source(stream_source: str) -> bool: and the GO2RTC_ALLOW_ARBITRARY_EXEC override is not set.""" if not stream_source.strip().startswith(("echo:", "expr:", "exec:")): return False - return not _go2rtc_arbitrary_exec_allowed() + return not is_go2rtc_arbitrary_exec_allowed() def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess: From 4e5e8e3c5934e89f73ba3d9e478e540fdf398ebf Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:17:23 -0500 Subject: [PATCH 10/12] Offload preview encoding and Plus upload off the API event loop (#23552) * offload preview ffmpeg encoding to a thread to avoid blocking the api event loop * offload Frigate+ recording snapshot upload to a thread to avoid blocking the api event loop --- frigate/api/media.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/frigate/api/media.py b/frigate/api/media.py index dad7a3d87d..caa57eb73b 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -380,7 +380,9 @@ async def submit_recording_snapshot_to_plus( ) nd = cv2.imdecode(np.frombuffer(image_data, dtype=np.int8), cv2.IMREAD_COLOR) - request.app.frigate_config.plus_api.upload_image(nd, camera_name) + await asyncio.to_thread( + request.app.frigate_config.plus_api.upload_image, nd, camera_name + ) return JSONResponse( content={ @@ -1517,14 +1519,14 @@ async def event_preview(request: Request, event_id: str): end_ts = start_ts + ( min(event.end_time - event.start_time, 20) if event.end_time else 20 ) - return preview_gif(request, event.camera, start_ts, end_ts) + return await preview_gif(request, event.camera, start_ts, end_ts) @router.get( "/{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif", dependencies=[Depends(require_camera_access)], ) -def preview_gif( +async def preview_gif( request: Request, camera_name: str, start_ts: float, @@ -1587,7 +1589,8 @@ def preview_gif( "-", ] - process = sp.run( + process = await asyncio.to_thread( + sp.run, ffmpeg_cmd, capture_output=True, ) @@ -1654,7 +1657,8 @@ def preview_gif( "-", ] - process = sp.run( + process = await asyncio.to_thread( + sp.run, ffmpeg_cmd, input=str.encode("\n".join(selected_previews)), capture_output=True, @@ -1683,7 +1687,7 @@ def preview_gif( "/{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4", dependencies=[Depends(require_camera_access)], ) -def preview_mp4( +async def preview_mp4( request: Request, camera_name: str, start_ts: float, @@ -1763,7 +1767,8 @@ def preview_mp4( path, ] - process = sp.run( + process = await asyncio.to_thread( + sp.run, ffmpeg_cmd, capture_output=True, ) @@ -1827,7 +1832,8 @@ def preview_mp4( path, ] - process = sp.run( + process = await asyncio.to_thread( + sp.run, ffmpeg_cmd, input=str.encode("\n".join(selected_previews)), capture_output=True, @@ -1880,9 +1886,9 @@ async def review_preview( ) if format == "gif": - return preview_gif(request, review.camera, start_ts, end_ts) + return await preview_gif(request, review.camera, start_ts, end_ts) else: - return preview_mp4(request, review.camera, start_ts, end_ts) + return await preview_mp4(request, review.camera, start_ts, end_ts) @router.get( From 933a7f1a3f00b33667eef4a23d587977bc817fd4 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:57:46 -0500 Subject: [PATCH 11/12] resolve the leaked Query default so media Cache-Control max-age is always a valid int (#23553) --- frigate/api/media.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/frigate/api/media.py b/frigate/api/media.py index caa57eb73b..8e1d00ade3 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -59,6 +59,19 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.media]) +def _resolve_cache_age(max_cache_age: int) -> int: + """Return max_cache_age as an int. + + When a media handler is invoked directly by another handler instead of + through its route, FastAPI doesn't resolve the Query() default and + max_cache_age arrives as the Query object; fall back to its int default. + """ + if isinstance(max_cache_age, int): + return max_cache_age + + return max_cache_age.default + + @router.get("/{camera_name}", dependencies=[Depends(require_camera_access)]) async def mjpeg_feed( request: Request, @@ -1215,7 +1228,7 @@ async def event_thumbnail( thumbnail_bytes, media_type=extension.get_mime_type(), headers={ - "Cache-Control": f"private, max-age={max_cache_age}" + "Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}" if event_complete else "no-store", }, @@ -1677,7 +1690,7 @@ async def preview_gif( gif_bytes, media_type="image/gif", headers={ - "Cache-Control": f"private, max-age={max_cache_age}", + "Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}", "Content-Type": "image/gif", }, ) @@ -1848,7 +1861,7 @@ async def preview_mp4( headers = { "Content-Description": "File Transfer", - "Cache-Control": f"private, max-age={max_cache_age}", + "Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}", "Content-Type": "video/mp4", "Content-Length": str(os.path.getsize(path)), # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers From 3d4dd3ac4b00e7257bd3412608a783001d7d77ed Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:55:39 -0500 Subject: [PATCH 12/12] allow non-admin users to send PTZ commands for cameras they have access to (#23578) --- frigate/comms/ws.py | 47 +++++++++++-- frigate/test/test_ws_auth.py | 128 +++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 7 deletions(-) diff --git a/frigate/comms/ws.py b/frigate/comms/ws.py index 2f16ab7141..9af231da30 100644 --- a/frigate/comms/ws.py +++ b/frigate/comms/ws.py @@ -34,6 +34,7 @@ from frigate.const import ( UPDATE_REVIEW_DESCRIPTION, UPSERT_REVIEW_SEGMENT, ) +from frigate.models import User logger = logging.getLogger(__name__) @@ -69,11 +70,16 @@ _WS_VIEWER_TOPICS = frozenset( } ) +# Camera-scoped command topics a camera-authorized (non-admin) user may send. +_WS_CAMERA_COMMAND_TOPICS = frozenset({"ptz"}) + def _check_ws_authorization( topic: str, role_header: str | None, separator: str, + roles_config: dict[str, list[str]] | None = None, + camera_names: set[str] | None = None, ) -> bool: """Check if a WebSocket message is authorized. @@ -81,6 +87,10 @@ def _check_ws_authorization( topic: The message topic. role_header: The HTTP_REMOTE_ROLE header value, or None. separator: The role separator character from proxy config. + roles_config: The auth.roles mapping (role -> allowed cameras), used to + authorize camera-scoped commands for non-admin users. + camera_names: All configured camera names, used to resolve a role's + allowed cameras. Returns: True if authorized, False if blocked. @@ -90,16 +100,33 @@ def _check_ws_authorization( return False # No role header: default to viewer (fail-closed) - if role_header is None: - return topic in _WS_VIEWER_TOPICS + roles = [r.strip() for r in role_header.split(separator)] if role_header else [] - # Check if any role is admin - roles = [r.strip() for r in role_header.split(separator)] + # Admin can send anything if "admin" in roles: return True - # Non-admin: only viewer topics allowed - return topic in _WS_VIEWER_TOPICS + # Read-only topics any authenticated user can send + if topic in _WS_VIEWER_TOPICS: + return True + + # Camera-scoped command like "/ptz": allow when the user's role(s) + # grant access to that camera. + parts = topic.split("/") + if ( + roles_config is not None + and len(parts) == 2 + and parts[1] in _WS_CAMERA_COMMAND_TOPICS + ): + allowed: set[str] = set() + # No role header maps to the default viewer role (e.g. proxy-only setups) + for role in roles or ["viewer"]: + allowed.update( + User.get_allowed_cameras(role, roles_config, camera_names or set()) + ) + return parts[0] in allowed + + return False class WebSocket(WebSocket_): # type: ignore[misc] @@ -131,6 +158,8 @@ class WebSocketClient(Communicator): class _WebSocketHandler(WebSocket): receiver = self._dispatcher role_separator = self.config.proxy.separator or "," + roles_config = self.config.auth.roles + camera_names = set(self.config.cameras.keys()) def received_message(self, message: WebSocket.received_message) -> None: # type: ignore[name-defined] try: @@ -152,7 +181,11 @@ class WebSocketClient(Communicator): self.environ.get("HTTP_REMOTE_ROLE") if self.environ else None ) if self.environ is not None and not _check_ws_authorization( - topic, role_header, self.role_separator + topic, + role_header, + self.role_separator, + self.roles_config, + self.camera_names, ): logger.warning( "Blocked unauthorized WebSocket message: topic=%s, role=%s", diff --git a/frigate/test/test_ws_auth.py b/frigate/test/test_ws_auth.py index b762f4384c..a9fc6e1320 100644 --- a/frigate/test/test_ws_auth.py +++ b/frigate/test/test_ws_auth.py @@ -11,6 +11,16 @@ class TestCheckWsAuthorization(unittest.TestCase): DEFAULT_SEPARATOR = "," + # admin/viewer are reserved and always map to all cameras (empty list); + # custom roles map to a specific set of cameras. + ROLES_CONFIG = { + "admin": [], + "viewer": [], + "yard": ["front_door", "backyard"], + "garage_only": ["garage"], + } + CAMERA_NAMES = {"front_door", "backyard", "garage"} + # --- IPC topic blocking (unconditional, regardless of role) --- def test_ipc_topic_blocked_for_admin(self): @@ -161,6 +171,124 @@ class TestCheckWsAuthorization(unittest.TestCase): _check_ws_authorization("onConnect", None, self.DEFAULT_SEPARATOR) ) + # --- Camera-scoped PTZ access (non-admin with camera access) --- + + def test_viewer_can_ptz_camera_with_access(self): + # viewer maps to all cameras, so PTZ is allowed + self.assertTrue( + _check_ws_authorization( + "front_door/ptz", + "viewer", + self.DEFAULT_SEPARATOR, + self.ROLES_CONFIG, + self.CAMERA_NAMES, + ) + ) + + def test_custom_role_can_ptz_assigned_camera(self): + self.assertTrue( + _check_ws_authorization( + "front_door/ptz", + "yard", + self.DEFAULT_SEPARATOR, + self.ROLES_CONFIG, + self.CAMERA_NAMES, + ) + ) + + def test_custom_role_blocked_from_ptz_unassigned_camera(self): + self.assertFalse( + _check_ws_authorization( + "garage/ptz", + "yard", + self.DEFAULT_SEPARATOR, + self.ROLES_CONFIG, + self.CAMERA_NAMES, + ) + ) + + def test_multiple_roles_union_camera_access_for_ptz(self): + # "yard" covers front_door/backyard, "garage_only" covers garage + self.assertTrue( + _check_ws_authorization( + "garage/ptz", + "yard,garage_only", + self.DEFAULT_SEPARATOR, + self.ROLES_CONFIG, + self.CAMERA_NAMES, + ) + ) + + def test_unknown_role_blocked_from_ptz(self): + self.assertFalse( + _check_ws_authorization( + "front_door/ptz", + "nonexistent", + self.DEFAULT_SEPARATOR, + self.ROLES_CONFIG, + self.CAMERA_NAMES, + ) + ) + + def test_no_role_header_treated_as_viewer_for_ptz(self): + # proxy-only / auth-disabled setups default to the viewer role + self.assertTrue( + _check_ws_authorization( + "front_door/ptz", + None, + self.DEFAULT_SEPARATOR, + self.ROLES_CONFIG, + self.CAMERA_NAMES, + ) + ) + + def test_camera_access_does_not_grant_set_commands(self): + # camera access enables PTZ only, not config-changing "set" commands + self.assertFalse( + _check_ws_authorization( + "front_door/detect/set", + "yard", + self.DEFAULT_SEPARATOR, + self.ROLES_CONFIG, + self.CAMERA_NAMES, + ) + ) + + def test_ptz_autotracker_stays_admin_only(self): + # ptz_autotracker is a config toggle, not a live-view action + self.assertFalse( + _check_ws_authorization( + "front_door/ptz_autotracker/set", + "viewer", + self.DEFAULT_SEPARATOR, + self.ROLES_CONFIG, + self.CAMERA_NAMES, + ) + ) + + def test_admin_can_ptz_any_camera_with_config(self): + self.assertTrue( + _check_ws_authorization( + "garage/ptz", + "admin", + self.DEFAULT_SEPARATOR, + self.ROLES_CONFIG, + self.CAMERA_NAMES, + ) + ) + + def test_ipc_topic_still_blocked_with_camera_access(self): + # IPC topics are blocked unconditionally, even with camera access + self.assertFalse( + _check_ws_authorization( + UPDATE_CAMERA_ACTIVITY, + "viewer", + self.DEFAULT_SEPARATOR, + self.ROLES_CONFIG, + self.CAMERA_NAMES, + ) + ) + if __name__ == "__main__": unittest.main()