Compare commits

..
Author SHA1 Message Date
Josh Hawkins 29f08203ca check every overlapping review item before dropping a recording
Recording retention around review items applied only the first review item, in start order, whose pre and post capture window covered a segment. When an alert and a detection were close together, a quiet segment covered by both was dropped by the detection's motion mode without checking the alert's all mode. The loop also stopped at a review item that started too late to reach the segment, even when a later alert with a longer pre_capture did reach it. Both the recording maintainer and cleanup now check every review item whose window covers the segment and keep it if any of their retain modes would.
2026-10-19 14:45:08 -05:00
18 changed files with 307 additions and 357 deletions
@@ -20,11 +20,6 @@ location /auth {
proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy; 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 X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# inputs for the cross-origin check on websocket and go2rtc stream requests
proxy_set_header Origin $http_origin;
proxy_set_header X-Original-Host $http_host;
proxy_set_header X-Forwarded-Host $forwarded_host;
proxy_set_header X-Original-Upgrade $http_upgrade;
proxy_set_header Content-Length ""; proxy_set_header Content-Length "";
# Pass along auth related info # Pass along auth related info
proxy_set_header Authorization $http_authorization; proxy_set_header Authorization $http_authorization;
@@ -54,13 +54,6 @@ http {
default 1; default 1;
} }
# keep an outer proxy's X-Forwarded-Host so the websocket servers can
# match a browser's Origin when that proxy rewrites Host
map $http_x_forwarded_host $forwarded_host {
"" $http_host;
default $http_x_forwarded_host;
}
upstream frigate_api { upstream frigate_api {
server 127.0.0.1:5001; server 127.0.0.1:5001;
keepalive 1024; keepalive 1024;
@@ -4,7 +4,7 @@ proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade"; proxy_set_header Connection "Upgrade";
proxy_set_header X-Original-URL $request_uri; proxy_set_header X-Original-URL $request_uri;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $forwarded_host; proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-URI $request_uri; proxy_set_header X-Forwarded-URI $request_uri;
proxy_set_header X-Forwarded-Ssl on; proxy_set_header X-Forwarded-Ssl on;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -360,7 +360,6 @@ For example, in Nginx:
``` ```
location /frigate { location /frigate {
proxy_set_header Host $host;
proxy_set_header X-Ingress-Path /frigate; proxy_set_header X-Ingress-Path /frigate;
proxy_pass http://frigate_backend; proxy_pass http://frigate_backend;
} }
-3
View File
@@ -37,8 +37,6 @@ Your reverse proxy must be configured to forward the `Upgrade` and `Connection`
Note that some proxies disable WebSocket support by default. For example, Nginx Proxy Manager has a "Websockets Support" toggle that must be enabled. Note that some proxies disable WebSocket support by default. For example, Nginx Proxy Manager has a "Websockets Support" toggle that must be enabled.
Frigate rejects WebSocket connections opened from a page on another site. Your proxy must either preserve the original `Host` header or send it in `X-Forwarded-Host`, otherwise Frigate cannot tell the connection came from its own UI.
## Proxies ## Proxies
There are many solutions available to implement reverse proxies and the community is invited to help out documenting others through a contribution to this page. There are many solutions available to implement reverse proxies and the community is invited to help out documenting others through a contribution to this page.
@@ -163,7 +161,6 @@ The settings below enabled connection upgrade, sets up logging (optional) and pr
error_log /data/logs/proxy-host-40_error.log warn; error_log /data/logs/proxy-host-40_error.log warn;
location / { location / {
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection; proxy_set_header Connection $http_connection;
proxy_http_version 1.1; proxy_http_version 1.1;
-4
View File
@@ -55,10 +55,6 @@ To install the optional companion Lovelace card, please see the [separate
installation instructions](https://github.com/dermotduffy/frigate-hass-card) for installation instructions](https://github.com/dermotduffy/frigate-hass-card) for
that card. that card.
Frigate rejects browser connections to its live streams that come from another
origin, such as a Home Assistant dashboard. Configure cards to stream through the
integration's proxy rather than pointing them directly at Frigate's port.
## Configuration ## Configuration
When configuring the integration, you will be asked for the `URL` of your Frigate instance which can be pointed at the internal unauthenticated port (`5000`) or the authenticated port (`8971`) for your instance. This may look like `http://<host>:5000/`. When configuring the integration, you will be asked for the `URL` of your Frigate instance which can be pointed at the internal unauthenticated port (`5000`) or the authenticated port (`8971`) for your instance. This may look like `http://<host>:5000/`.
+1 -27
View File
@@ -39,7 +39,6 @@ from frigate.config import AuthConfig, ProxyConfig
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
from frigate.models import User from frigate.models import User
from frigate.notices import raise_notice from frigate.notices import raise_notice
from frigate.util.origin import origin_allowed
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -719,17 +718,6 @@ def auth(request: Request):
success_response = Response("", status_code=202) success_response = Response("", status_code=202)
original_url = request.headers.get("x-original-url")
if _is_cross_origin_stream_request(original_url, request):
logger.warning(
"Rejected cross-origin request: origin=%s, host=%s, x-forwarded-host=%s",
request.headers.get("origin"),
request.headers.get("x-original-host"),
request.headers.get("x-forwarded-host"),
)
return Response("", status_code=403)
# dont require auth if the request is on the internal port # dont require auth if the request is on the internal port
# this header is set by Frigate's nginx proxy, so it cant be spoofed. # this header is set by Frigate's nginx proxy, so it cant be spoofed.
# the port is the boot-time snapshot rather than the live config value: # the port is the boot-time snapshot rather than the live config value:
@@ -753,6 +741,7 @@ def auth(request: Request):
logger.debug("X-Proxy-Secret header does not match configured secret value") logger.debug("X-Proxy-Secret header does not match configured secret value")
return fail_response return fail_response
original_url = request.headers.get("x-original-url")
frigate_config = request.app.frigate_config frigate_config = request.app.frigate_config
# if auth is disabled, just apply the proxy header map and return success # if auth is disabled, just apply the proxy header map and return success
@@ -1318,21 +1307,6 @@ def _is_go2rtc_stream_proxy_request(path: str, request: Request) -> bool:
return any(normalized.startswith(p) for p in GO2RTC_STREAM_PROXY_PATHS) return any(normalized.startswith(p) for p in GO2RTC_STREAM_PROXY_PATHS)
def _is_cross_origin_stream_request(original_url: str | None, request: Request) -> bool:
# the internal port grants admin, so a browser on another site must be
# stopped before that early return
upgrade = request.headers.get("x-original-upgrade", "").lower()
path = split_request_uri(original_url or "")[0]
if upgrade != "websocket" and not _is_go2rtc_stream_proxy_request(path, request):
return False
return not origin_allowed(
request.headers.get("origin"),
request.headers.get("x-original-host"),
request.headers.get("x-forwarded-host"),
)
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:
+1 -24
View File
@@ -40,7 +40,6 @@ from frigate.const import (
) )
from frigate.models import User from frigate.models import User
from frigate.output.ws_auth import ws_has_camera_access from frigate.output.ws_auth import ws_has_camera_access
from frigate.util.origin import origin_allowed
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -473,28 +472,6 @@ def _materialize_for_ws(
return None return None
class OriginCheckedWebSocketApplication(WebSocketWSGIApplication): # type: ignore[misc]
"""ws4py application that rejects cross-origin browser handshakes."""
def __call__(self, environ: dict[str, Any], start_response: Callable) -> Any:
if not origin_allowed(
environ.get("HTTP_ORIGIN"),
environ.get("HTTP_HOST"),
environ.get("HTTP_X_FORWARDED_HOST"),
):
logger.warning(
"Rejected cross-origin websocket handshake: origin=%s, host=%s, "
"x-forwarded-host=%s",
environ.get("HTTP_ORIGIN"),
environ.get("HTTP_HOST"),
environ.get("HTTP_X_FORWARDED_HOST"),
)
start_response("403 Forbidden", [("Content-Length", "0")])
return []
return super().__call__(environ, start_response)
class WebSocket(WebSocket_): # type: ignore[misc] class WebSocket(WebSocket_): # type: ignore[misc]
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -644,7 +621,7 @@ class WebSocketClient(Communicator):
5002, 5002,
server_class=WSGIServer, server_class=WSGIServer,
handler_class=WebSocketWSGIRequestHandler, handler_class=WebSocketWSGIRequestHandler,
app=OriginCheckedWebSocketApplication(handler_cls=_WebSocketHandler), app=WebSocketWSGIApplication(handler_cls=_WebSocketHandler),
) )
self.websocket_server.initialize_websockets_manager() self.websocket_server.initialize_websockets_manager()
self.websocket_thread = threading.Thread( self.websocket_thread = threading.Thread(
+4 -14
View File
@@ -14,11 +14,12 @@ from ws4py.server.wsgirefserver import (
WebSocketWSGIRequestHandler, WebSocketWSGIRequestHandler,
WSGIServer, WSGIServer,
) )
from ws4py.server.wsgiutils import WebSocketWSGIApplication
from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.config_updater import ConfigSubscriber
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
from frigate.comms.ws import OriginCheckedWebSocketApplication, WebSocket from frigate.comms.ws import WebSocket
from frigate.config import AuthConfig, FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera.updater import ( from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber, CameraConfigUpdateSubscriber,
@@ -135,7 +136,7 @@ class OutputProcess(FrigateProcess):
8082, 8082,
server_class=WSGIServer, server_class=WSGIServer,
handler_class=WebSocketWSGIRequestHandler, handler_class=WebSocketWSGIRequestHandler,
app=OriginCheckedWebSocketApplication(handler_cls=WebSocket), app=WebSocketWSGIApplication(handler_cls=WebSocket),
) )
websocket_server.initialize_websockets_manager() websocket_server.initialize_websockets_manager()
websocket_thread = threading.Thread(target=websocket_server.serve_forever) websocket_thread = threading.Thread(target=websocket_server.serve_forever)
@@ -153,7 +154,6 @@ class OutputProcess(FrigateProcess):
], ],
) )
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True) birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
auth_config_subscriber = ConfigSubscriber("config/auth", exact=True)
jsmpeg_cameras: dict[str, JsmpegCamera] = {} jsmpeg_cameras: dict[str, JsmpegCamera] = {}
birdseye: Birdseye | None = None birdseye: Birdseye | None = None
@@ -194,15 +194,6 @@ class OutputProcess(FrigateProcess):
self.config.birdseye = birdseye_config self.config.birdseye = birdseye_config
logger.debug("Applied dynamic birdseye config update") logger.debug("Applied dynamic birdseye config update")
# user endpoints publish None on this topic and role edits an AuthConfig
while True:
auth_topic, auth_config = auth_config_subscriber.check_for_update()
if auth_topic is None:
break
if isinstance(auth_config, AuthConfig):
self.config.auth = auth_config
logger.debug("Applied dynamic auth config update")
# drain review updates every iteration, not just when birdseye is # drain review updates every iteration, not just when birdseye is
# being consumed, so a dropped end never strands a camera # being consumed, so a dropped end never strands a camera
if birdseye is not None: if birdseye is not None:
@@ -339,7 +330,6 @@ class OutputProcess(FrigateProcess):
config_subscriber.stop() config_subscriber.stop()
birdseye_config_subscriber.stop() birdseye_config_subscriber.stop()
auth_config_subscriber.stop()
websocket_server.manager.close_all() websocket_server.manager.close_all()
websocket_server.manager.stop() websocket_server.manager.stop()
websocket_server.manager.join() websocket_server.manager.join()
+27 -23
View File
@@ -198,52 +198,56 @@ class RecordingCleanup(threading.Thread):
deleted_recordings = set() deleted_recordings = set()
kept_recordings: list[tuple[float, float]] = [] kept_recordings: list[tuple[float, float]] = []
for recording in recordings: for recording in recordings:
keep = False modes: set[RetainModeEnum] = set()
mode = None # Now look for every reason to keep this recording segment
# Now look for a reason to keep this recording segment
for idx in range(review_start, len(reviews)): for idx in range(review_start, len(reviews)):
review = reviews[idx] review = reviews[idx]
severity = review.severity severity = review.severity
pre_capture = config.record.get_review_pre_capture(severity) pre_capture = config.record.get_review_pre_capture(severity)
post_capture = config.record.get_review_post_capture(severity) post_capture = config.record.get_review_post_capture(severity)
# if the review starts in the future, stop checking reviews # stop once no later review's pre capture can reach this segment
# and let this recording segment expire if (
if review.start_time - pre_capture > recording.end_time: review.start_time - config.record.event_pre_capture
keep = False > recording.end_time
):
break break
# if the review is in progress or ends after the recording starts, keep it if review.start_time - pre_capture > recording.end_time:
# and stop looking at reviews continue
# collect every review that is in progress or ends after the
# recording starts, since their windows can overlap
if ( if (
review.end_time is None review.end_time is None
or review.end_time + post_capture >= recording.start_time or review.end_time + post_capture >= recording.start_time
): ):
keep = True modes.add(
mode = (
alerts_retain_mode alerts_retain_mode
if review.severity == "alert" if review.severity == "alert"
else detections_retain_mode else detections_retain_mode
) )
break
# if the review ends before this recording segment starts, skip # if the review ends before this recording segment starts, skip
# this review and check the next review for an overlap. # it on future segments too. reviews and recordings are sorted,
# since the review and recordings are sorted, we can skip review # but an earlier review with a longer post capture may still
# that end before the previous recording segment started on future segments # overlap, so only skip while nothing has overlapped yet
if review.end_time + post_capture < recording.start_time: elif not modes:
review_start = idx review_start = idx
# Delete recordings outside of the retention window or based on the retention mode # Delete recordings unless an overlapping review's retention mode keeps them
if ( if not any(
not keep mode == RetainModeEnum.all
or ( or (
mode == RetainModeEnum.motion mode == RetainModeEnum.motion
and recording.motion == 0 and (
and recording.objects == 0 recording.motion != 0
and recording.dBFS == 0 or recording.objects != 0
or recording.dBFS != 0
) )
or (mode == RetainModeEnum.active_objects and recording.objects == 0) )
or (mode == RetainModeEnum.active_objects and recording.objects != 0)
for mode in modes
): ):
recording_path = Path(recording.path) recording_path = Path(recording.path)
recording_path.unlink(missing_ok=True) recording_path.unlink(missing_ok=True)
+14 -13
View File
@@ -759,39 +759,40 @@ class RecordingMaintainer(threading.Thread):
# we fell through the continuous / motion check, so we need to check the review items # we fell through the continuous / motion check, so we need to check the review items
# if the cached segment overlaps with the review items: # if the cached segment overlaps with the review items:
overlaps = False modes: set[RetainModeEnum] = set()
for review in reviews: for review in reviews:
severity = SeverityEnum[review.severity] severity = SeverityEnum[review.severity]
# if the review item starts in the future, stop checking review items # stop once no later review item's pre capture can reach this segment
# and remove this segment if (
review.start_time - record_config.event_pre_capture
) > end_time.timestamp():
break
if ( if (
review.start_time - record_config.get_review_pre_capture(severity) review.start_time - record_config.get_review_pre_capture(severity)
) > end_time.timestamp(): ) > end_time.timestamp():
overlaps = False continue
break
# if the review item is in progress or ends after the recording starts, keep it # collect every review item that is in progress or ends after the
# and stop looking at review items # recording starts, since their windows can overlap
if ( if (
review.end_time is None review.end_time is None
or (review.end_time + record_config.get_review_post_capture(severity)) or (review.end_time + record_config.get_review_post_capture(severity))
>= start_time.timestamp() >= start_time.timestamp()
): ):
overlaps = True modes.add(
break
if overlaps:
record_mode = (
alerts_retain_mode alerts_retain_mode
if review.severity == "alert" if review.severity == "alert"
else detections_retain_mode else detections_retain_mode
) )
if modes:
if segment_stats is None: if segment_stats is None:
segment_stats = self.segment_stats(camera, start_time, end_time) segment_stats = self.segment_stats(camera, start_time, end_time)
if not segment_stats.should_discard_segment(record_mode): # keep the segment if any overlapping review item's mode keeps it
if any(not segment_stats.should_discard_segment(m) for m in modes):
# move from cache to recordings immediately # move from cache to recordings immediately
return await self.move_segment( return await self.move_segment(
camera, camera,
@@ -101,71 +101,6 @@ class TestAuthInternalPort(BaseTestHttp):
self.assertEqual(resp.headers["remote-user"], "anonymous") self.assertEqual(resp.headers["remote-user"], "anonymous")
self.assertEqual(resp.headers["remote-role"], "admin") self.assertEqual(resp.headers["remote-role"], "admin")
def _internal_auth(self, original_url: str, **headers: str):
app = self._create_app()
with AuthTestClient(app) as client:
return client.get(
"/auth",
headers={
"x-server-port": "5000",
"x-original-url": original_url,
"x-original-host": "frigate.lan:5000",
**{k.replace("_", "-"): v for k, v in headers.items()},
},
)
def test_cross_origin_websocket_on_internal_port_denied(self):
resp = self._internal_auth(
"/ws", origin="https://evil.example", x_original_upgrade="websocket"
)
self.assertEqual(resp.status_code, 403)
def test_cross_origin_go2rtc_stream_denied(self):
resp = self._internal_auth(
"/live/mse/api/ws?src=front_door",
origin="https://evil.example",
x_original_upgrade="websocket",
x_go2rtc_stream_proxy="1",
)
self.assertEqual(resp.status_code, 403)
def test_cross_origin_webrtc_post_denied(self):
# the offer POST is not an upgrade, so the stream marker alone gates it
resp = self._internal_auth(
"/api/go2rtc/webrtc?src=front_door",
origin="https://evil.example",
x_go2rtc_stream_proxy="1",
)
self.assertEqual(resp.status_code, 403)
def test_same_origin_websocket_allowed(self):
resp = self._internal_auth(
"/live/mse/api/ws?src=front_door",
origin="http://frigate.lan:5000",
x_original_upgrade="websocket",
x_go2rtc_stream_proxy="1",
)
self.assertEqual(resp.status_code, 202)
def test_websocket_without_origin_allowed(self):
resp = self._internal_auth("/ws", x_original_upgrade="websocket")
self.assertEqual(resp.status_code, 202)
def test_forwarded_host_origin_allowed(self):
# Home Assistant integration proxy: Host is Frigate, Origin is HA
resp = self._internal_auth(
"/api/go2rtc/webrtc?src=front_door",
origin="https://ha.example.com:8123",
x_forwarded_host="ha.example.com:8123",
x_go2rtc_stream_proxy="1",
)
self.assertEqual(resp.status_code, 202)
def test_cross_origin_plain_http_not_checked(self):
resp = self._internal_auth("/api/events", origin="https://evil.example")
self.assertEqual(resp.status_code, 202)
def test_external_port_requires_auth(self): def test_external_port_requires_auth(self):
app = self._create_app() app = self._create_app()
+111
View File
@@ -127,6 +127,116 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(result) self.assertIsNone(result)
maintainer.drop_segment.assert_called_once_with(cache_path) maintainer.drop_segment.assert_called_once_with(cache_path)
async def test_quiet_segment_kept_by_later_overlapping_alert(self):
config = MagicMock(spec=FrigateConfig)
camera_config = MagicMock()
camera_config.record.enabled = True
camera_config.record.continuous.days = 0
camera_config.record.motion.days = 0
camera_config.record.alerts.retain.mode = RetainModeEnum.all
camera_config.record.detections.retain.mode = RetainModeEnum.motion
camera_config.record.event_pre_capture = 60
camera_config.record.get_review_pre_capture.return_value = 60
camera_config.record.get_review_post_capture.return_value = 60
config.cameras = {"test_cam": camera_config}
maintainer = RecordingMaintainer(config, MagicMock())
now = datetime.datetime.now(datetime.UTC)
start_time = now - datetime.timedelta(seconds=20)
end_time = now - datetime.timedelta(seconds=10)
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
maintainer.end_time_cache = {
cache_path: (end_time, 10.0, False, None, None, None, [])
}
maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])]
maintainer.audio_recordings_info["test_cam"] = []
maintainer.drop_segment = MagicMock()
maintainer.move_segment = AsyncMock(return_value=None)
maintainer.recordings_publisher = MagicMock()
# the detection's post capture and the alert's window both cover the segment
detection = MagicMock()
detection.severity = "detection"
detection.start_time = start_time.timestamp() - 50
detection.end_time = start_time.timestamp() - 40
alert = MagicMock()
alert.severity = "alert"
alert.start_time = start_time.timestamp() - 10
alert.end_time = start_time.timestamp()
await maintainer.validate_and_move_segment(
"test_cam",
reviews=[detection, alert],
recording={
"start_time": start_time,
"cache_path": cache_path,
"stream_type": "main",
},
)
maintainer.move_segment.assert_awaited_once()
maintainer.drop_segment.assert_not_called()
async def test_quiet_segment_kept_by_later_alert_with_longer_pre_capture(self):
config = MagicMock(spec=FrigateConfig)
camera_config = MagicMock()
camera_config.record.enabled = True
camera_config.record.continuous.days = 0
camera_config.record.motion.days = 0
camera_config.record.alerts.retain.mode = RetainModeEnum.all
camera_config.record.detections.retain.mode = RetainModeEnum.all
camera_config.record.event_pre_capture = 30
camera_config.record.get_review_pre_capture.side_effect = lambda s: (
30 if s == "alert" else 5
)
camera_config.record.get_review_post_capture.return_value = 5
config.cameras = {"test_cam": camera_config}
maintainer = RecordingMaintainer(config, MagicMock())
now = datetime.datetime.now(datetime.UTC)
start_time = now - datetime.timedelta(seconds=20)
end_time = now - datetime.timedelta(seconds=10)
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
maintainer.end_time_cache = {
cache_path: (end_time, 10.0, False, None, None, None, [])
}
maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])]
maintainer.audio_recordings_info["test_cam"] = []
maintainer.drop_segment = MagicMock()
maintainer.move_segment = AsyncMock(return_value=None)
maintainer.recordings_publisher = MagicMock()
# the detection's pre capture starts after the segment, the alert's reaches it
detection = MagicMock()
detection.severity = "detection"
detection.start_time = end_time.timestamp() + 10
detection.end_time = end_time.timestamp() + 12
alert = MagicMock()
alert.severity = "alert"
alert.start_time = end_time.timestamp() + 20
alert.end_time = end_time.timestamp() + 25
await maintainer.validate_and_move_segment(
"test_cam",
reviews=[detection, alert],
recording={
"start_time": start_time,
"cache_path": cache_path,
"stream_type": "main",
},
)
maintainer.move_segment.assert_awaited_once()
maintainer.drop_segment.assert_not_called()
async def test_defers_review_overlap_segment_until_metadata_catches_up(self): async def test_defers_review_overlap_segment_until_metadata_catches_up(self):
# Regression: a segment overlapping an active_objects review must not # Regression: a segment overlapping an active_objects review must not
# be dropped while detection metadata lags behind the segment end, # be dropped while detection metadata lags behind the segment end,
@@ -139,6 +249,7 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
camera_config.record.continuous.days = 7 camera_config.record.continuous.days = 7
camera_config.record.motion.days = 0 camera_config.record.motion.days = 0
camera_config.record.alerts.retain.mode = RetainModeEnum.active_objects camera_config.record.alerts.retain.mode = RetainModeEnum.active_objects
camera_config.record.event_pre_capture = 5
camera_config.record.get_review_pre_capture.return_value = 5 camera_config.record.get_review_pre_capture.return_value = 5
camera_config.record.get_review_post_capture.return_value = 5 camera_config.record.get_review_post_capture.return_value = 5
config.cameras = {"test_cam": camera_config} config.cameras = {"test_cam": camera_config}
+1 -9
View File
@@ -3,7 +3,7 @@
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from frigate.config import AuthConfig, FrigateConfig from frigate.config import FrigateConfig
from frigate.output.ws_auth import ws_has_camera_access from frigate.output.ws_auth import ws_has_camera_access
@@ -55,11 +55,3 @@ class TestWsHasCameraAccess(unittest.TestCase):
self.assertFalse( self.assertFalse(
ws_has_camera_access(self._make_ws("limited_user"), "birdseye", self.config) ws_has_camera_access(self._make_ws("limited_user"), "birdseye", self.config)
) )
def test_replaced_auth_config_revokes_camera(self):
ws = self._make_ws("limited_user")
self.assertTrue(ws_has_camera_access(ws, "front_door", self.config))
self.config.auth = AuthConfig(roles={"limited_user": ["back_door"]})
self.assertFalse(ws_has_camera_access(ws, "front_door", self.config))
self.assertTrue(ws_has_camera_access(ws, "back_door", self.config))
+142
View File
@@ -0,0 +1,142 @@
"""Tests for review-based retention in recording cleanup."""
import datetime
import unittest
from unittest.mock import MagicMock
from peewee import SqliteDatabase
from frigate.config import FrigateConfig
from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus
from frigate.record.cleanup import RecordingCleanup
class TestRecordingCleanupReviewOverlap(unittest.TestCase):
def setUp(self):
self.db = SqliteDatabase(":memory:")
models = [Previews, Recordings, ReviewSegment, UserReviewStatus]
self.db.bind(models)
self.db.create_tables(models)
self.base = datetime.datetime.now().timestamp() - 3600
def tearDown(self):
self.db.close()
def _build_cleanup(self, alerts: dict, detections: dict) -> RecordingCleanup:
config = FrigateConfig(
**{
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect", "record"],
}
]
},
"record": {
"enabled": True,
"continuous": {"days": 0},
"motion": {"days": 0},
"alerts": alerts,
"detections": detections,
},
}
},
}
)
return RecordingCleanup(config, MagicMock())
def _insert_review(self, id: str, severity: str, start: float, end: float):
ReviewSegment.create(
id=id,
camera="front_door",
start_time=self.base + start,
end_time=self.base + end,
severity=severity,
thumb_path=f"/media/frigate/clips/review/{id}.webp",
data={},
)
def _insert_recording(self, id: str, start: float):
Recordings.create(
id=id,
camera="front_door",
path=f"/media/frigate/recordings/{id}.mp4",
start_time=self.base + start,
end_time=self.base + start + 10,
duration=10,
motion=0,
objects=0,
dBFS=0,
segment_size=0,
stream_type="main",
)
def test_quiet_segment_kept_by_later_overlapping_alert(self):
cleanup = self._build_cleanup(
alerts={"pre_capture": 60, "post_capture": 60, "retain": {"mode": "all"}},
detections={"post_capture": 60, "retain": {"mode": "motion"}},
)
self._insert_review("detection", "detection", 90, 100)
self._insert_review("alert", "alert", 130, 140)
self._insert_recording("quiet", 140)
cleanup.expire_recordings()
assert Recordings.get_or_none(Recordings.id == "quiet") is not None
def test_quiet_segment_dropped_when_no_overlapping_mode_keeps_it(self):
cleanup = self._build_cleanup(
alerts={
"pre_capture": 60,
"post_capture": 60,
"retain": {"mode": "motion"},
},
detections={"post_capture": 60, "retain": {"mode": "motion"}},
)
self._insert_review("detection", "detection", 90, 100)
self._insert_review("alert", "alert", 130, 140)
self._insert_recording("quiet", 140)
cleanup.expire_recordings()
assert Recordings.get_or_none(Recordings.id == "quiet") is None
def test_longer_pre_capture_survives_an_earlier_shorter_review(self):
# the detection starts after the segment even with its pre capture,
# but the later alert's longer pre capture still reaches it
cleanup = self._build_cleanup(
alerts={"pre_capture": 30, "retain": {"mode": "all"}},
detections={"pre_capture": 5, "retain": {"mode": "all"}},
)
self._insert_review("detection", "detection", 10, 12)
self._insert_review("alert", "alert", 20, 25)
self._insert_recording("early", -6)
cleanup.expire_recordings()
assert Recordings.get_or_none(Recordings.id == "early") is not None
def test_longer_post_capture_survives_a_later_shorter_review(self):
# the alert's window ends before the detection's, so skipping past
# it must not skip the detection for later segments
cleanup = self._build_cleanup(
alerts={"post_capture": 5, "retain": {"mode": "motion"}},
detections={"post_capture": 60, "retain": {"mode": "all"}},
)
self._insert_review("detection", "detection", 90, 100)
self._insert_review("alert", "alert", 110, 115)
self._insert_recording("first", 125)
self._insert_recording("second", 135)
cleanup.expire_recordings()
assert Recordings.get_or_none(Recordings.id == "first") is not None
assert Recordings.get_or_none(Recordings.id == "second") is not None
if __name__ == "__main__":
unittest.main()
-126
View File
@@ -1,126 +0,0 @@
"""Tests for the same-origin check on WebSocket handshakes."""
import unittest
from unittest.mock import MagicMock
from frigate.comms.ws import OriginCheckedWebSocketApplication, WebSocket
from frigate.util.origin import origin_allowed
def ws_origin_allowed(environ: dict[str, str]) -> bool:
return origin_allowed(
environ.get("HTTP_ORIGIN"),
environ.get("HTTP_HOST"),
environ.get("HTTP_X_FORWARDED_HOST"),
)
class TestOriginAllowed(unittest.TestCase):
def test_missing_origin_allowed(self):
self.assertTrue(ws_origin_allowed({"HTTP_HOST": "frigate.lan"}))
def test_direct_access_same_origin(self):
# nginx forwards Host without the port and X-Forwarded-Host with it
environ = {
"HTTP_ORIGIN": "http://192.168.1.10:5000",
"HTTP_HOST": "192.168.1.10",
"HTTP_X_FORWARDED_HOST": "192.168.1.10:5000",
}
self.assertTrue(ws_origin_allowed(environ))
def test_default_port_origin(self):
environ = {
"HTTP_ORIGIN": "https://frigate.example.com",
"HTTP_HOST": "frigate.example.com",
}
self.assertTrue(ws_origin_allowed(environ))
def test_host_comparison_is_case_insensitive(self):
environ = {
"HTTP_ORIGIN": "https://Frigate.Example.com",
"HTTP_HOST": "frigate.example.COM",
}
self.assertTrue(ws_origin_allowed(environ))
def test_ipv6_host(self):
environ = {"HTTP_ORIGIN": "http://[::1]:8971", "HTTP_HOST": "[::1]"}
self.assertTrue(ws_origin_allowed(environ))
def test_cross_origin_rejected(self):
environ = {
"HTTP_ORIGIN": "https://evil.example",
"HTTP_HOST": "frigate.lan",
"HTTP_X_FORWARDED_HOST": "frigate.lan:5000",
}
self.assertFalse(ws_origin_allowed(environ))
def test_null_origin_rejected(self):
self.assertFalse(
ws_origin_allowed({"HTTP_ORIGIN": "null", "HTTP_HOST": "frigate.lan"})
)
def test_proxy_rewriting_host_with_forwarded_host(self):
environ = {
"HTTP_ORIGIN": "https://frigate.example.com",
"HTTP_HOST": "10.0.0.5",
"HTTP_X_FORWARDED_HOST": "frigate.example.com",
}
self.assertTrue(ws_origin_allowed(environ))
def test_forwarded_host_list_uses_first_entry(self):
environ = {
"HTTP_ORIGIN": "https://frigate.example.com",
"HTTP_HOST": "10.0.0.5",
"HTTP_X_FORWARDED_HOST": "frigate.example.com, proxy.internal",
}
self.assertTrue(ws_origin_allowed(environ))
environ["HTTP_ORIGIN"] = "https://proxy.internal"
self.assertFalse(ws_origin_allowed(environ))
def test_home_assistant_ingress(self):
# HA core copies Host and sets X-Forwarded-Host to its own host
environ = {
"HTTP_ORIGIN": "https://ha.example.com:8123",
"HTTP_HOST": "ha.example.com",
"HTTP_X_FORWARDED_HOST": "ha.example.com:8123",
"HTTP_X_INGRESS_PATH": "/api/hassio_ingress/abc123",
}
self.assertTrue(ws_origin_allowed(environ))
def test_ingress_path_does_not_bypass_origin_check(self):
environ = {
"HTTP_ORIGIN": "https://evil.example",
"HTTP_HOST": "frigate.example.com",
"HTTP_X_INGRESS_PATH": "/frigate",
}
self.assertFalse(ws_origin_allowed(environ))
def test_home_assistant_integration_proxy(self):
# hass-web-proxy-lib drops Host, so Host is the integration's Frigate URL
environ = {
"HTTP_ORIGIN": "https://ha.example.com:8123",
"HTTP_HOST": "frigate.lan",
"HTTP_X_FORWARDED_HOST": "ha.example.com:8123",
}
self.assertTrue(ws_origin_allowed(environ))
class TestOriginCheckedWebSocketApplication(unittest.TestCase):
def test_cross_origin_handshake_gets_403(self):
app = OriginCheckedWebSocketApplication(handler_cls=WebSocket)
start_response = MagicMock()
environ = {
"REQUEST_METHOD": "GET",
"HTTP_ORIGIN": "https://evil.example",
"HTTP_HOST": "frigate.lan",
}
self.assertEqual(app(environ, start_response), [])
start_response.assert_called_once()
self.assertEqual(start_response.call_args.args[0], "403 Forbidden")
self.assertNotIn("ws4py.websocket", environ)
if __name__ == "__main__":
unittest.main()
-31
View File
@@ -1,31 +0,0 @@
"""Same-origin check for browser WebSocket handshakes and go2rtc stream requests."""
from urllib.parse import urlsplit
def _hostname(value: str) -> str | None:
try:
return urlsplit(value).hostname
except ValueError:
return None
def origin_allowed(
origin: str | None, host: str | None, forwarded_host: str | None
) -> bool:
"""Return True unless a browser sent the request from a foreign origin.
Browsers always send Origin on a WebSocket handshake, so a missing Origin
means a non-browser client. A page cannot add headers to a handshake, so
X-Forwarded-Host comes from a proxy. Ports are ignored because nginx
forwards Host without one.
"""
if origin is None:
return True
origin_host = _hostname(origin)
if not origin_host:
return False
hosts = ((forwarded_host or "").split(",")[0].strip(), host or "")
return any(origin_host == _hostname(f"//{h}") for h in hosts if h)
+1
View File
@@ -28,6 +28,7 @@ export default defineConfig({
}, },
"/live": { "/live": {
target: `ws://${proxyHost}`, target: `ws://${proxyHost}`,
changeOrigin: true,
ws: true, ws: true,
}, },
}, },