Compare commits

..
Author SHA1 Message Date
Josh Hawkins f8f79e7427 reject users whose role was removed from the config
A user whose role was removed from the config fell back to viewer, which has no camera restrictions, so a restricted role removed from yaml gained access to every camera. /auth now rejects that user's tokens, and login refuses them until an admin assigns a valid role.
2026-10-18 13:12:43 -05:00
Josh Hawkins 859a5caffb note that a proxy on the tls port must serve https to browsers 2026-10-18 12:33:48 -05:00
Josh Hawkins 1b1a38f0a1 revoke tokens issued in the same second as a password change
/auth compared the token's whole-second iat against the truncated change time, so a token issued earlier in the same second as a password change stayed valid. The change time is now rounded up, and the cookie reissued to a user who changes their own password carries that rounded-up iat so it still passes.
2026-10-18 12:33:34 -05:00
Josh Hawkins a409309be4 regenerate config schema mock data 2026-10-18 12:21:01 -05:00
Josh Hawkins a2e7e5c991 set the secure cookie flag when the request arrived over tls
The session cookie only got the Secure flag from auth.cookie_secure, which defaults to false even though TLS is on by default, so browsers would send it over plain HTTP. The cookie is now Secure whenever nginx reports an https scheme, and nginx passes that scheme to the /auth subrequest so refreshed cookies match. cookie_secure still forces the flag for a reverse proxy that terminates TLS in front of plain-HTTP Frigate.
2026-10-18 12:18:58 -05:00
Josh Hawkins 3dc382938c take the jwt role from the user and revoke stale sessions
/auth trusted the role claim in the token, and only checked password_changed_at when refreshing a cookie. Demoting or deleting a user, changing a password, or resetting the admin password left existing cookies and bearer tokens working with their old role. The API now keeps each user's role and password change time in memory, loads it at startup, and updates it from the user endpoints. /auth rejects unknown users and tokens issued before the last password change, and authorizes with the stored role without touching the database. Resetting the admin password now sets password_changed_at.
2026-10-18 12:11:59 -05:00
33 changed files with 958 additions and 1582 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
numpy
openvino >= 2026.4.1
openvino >= 2026.2.0
+4 -4
View File
@@ -23,7 +23,7 @@ pandas == 3.0.*
peewee == 4.5.*
peewee_migrate == 2.3.*
psutil == 7.2.*
pydantic == 2.14.*
pydantic == 2.13.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
tzdata == 2026.5
pyzmq == 27.2.*
@@ -42,16 +42,16 @@ opencv-python-headless == 4.11.0.*
opencv-contrib-python == 4.11.0.*
scipy == 1.17.*
# OpenVino & ONNX
openvino == 2026.4.*
openvino == 2025.4.*
onnxruntime == 1.30.*
# Embeddings
transformers == 5.19.*
# Generative AI
google-genai == 2.31.*
google-genai == 2.29.*
ollama == 0.6.*
openai == 3.26.*
# push notifications
py-vapid == 1.9.6
py-vapid == 1.9.4
pywebpush == 2.5.*
# alpr
pyclipper == 1.4.*
+1 -1
View File
@@ -1 +1 @@
scikit-build == 0.19.*
scikit-build == 0.18.*
@@ -19,6 +19,7 @@ location /auth {
# this cannot be spoofed
proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy;
proxy_set_header X-Server-Port $server_port;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Content-Length "";
# Pass along auth related info
proxy_set_header Authorization $http_authorization;
+50 -11
View File
@@ -2,9 +2,32 @@
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
ARG DEBIAN_FRONTEND=noninteractive
ARG ROCM=1
ARG HSA_OVERRIDE_GFX_VERSION
ARG HSA_OVERRIDE
#######################################################################
FROM wget AS rocm
ARG ROCM
RUN apt update -qq && \
apt install -y wget gpg && \
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.2.3/ubuntu/jammy/amdgpu-install_7.2.3.70203-1_all.deb && \
apt install -y ./rocm.deb && \
apt update && \
apt install -qq -y rocm
RUN mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib
RUN cd /opt/rocm-$ROCM/lib && \
cp -dpr libMIOpen*.so* libamd*.so* libhip*.so* libhsa*.so* libmigraphx*.so* librocm*.so* librocblas*.so* libroctracer*.so* librocsolver*.so* librocfft*.so* librocprofiler*.so* libroctx*.so* librocroller.so* /opt/rocm-dist/opt/rocm-$ROCM/lib/ && \
mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib && \
cp -dpr migraphx/lib/* /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib
RUN cd /opt/rocm-dist/opt/ && ln -s rocm-$ROCM rocm
RUN mkdir -p /opt/rocm-dist/etc/ld.so.conf.d/
RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf
#######################################################################
FROM deps AS deps-prelim
@@ -19,35 +42,51 @@ RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.
rm -f /etc/apt/sources.list.d/trixie.list && \
rm -rf /var/lib/apt/lists/*
WORKDIR /opt/frigate
COPY --from=rootfs / /
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
&& sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \
&& python3 get-pip.py "pip" --break-system-packages
RUN python3 -m pip config set global.break-system-packages true
# ROCm installs from AMD's wheels ahead of Frigate's code so code changes
# reuse this layer, and is pruned in the same layer so the removed files never
# reach the image. /opt/rocm links to the runtime for rocminfo and HIP headers.
COPY docker/rocm/requirements-wheels-rocm.txt /requirements.txt
RUN --mount=type=bind,source=docker/rocm/prune_rocm.sh,target=/deps/prune_rocm.sh \
pip3 uninstall -y onnxruntime \
&& pip3 install --no-cache-dir -r /requirements.txt \
&& ROCM_SDK_CORE="$(python3 -c 'import _rocm_sdk_core, os; print(os.path.dirname(_rocm_sdk_core.__file__))')" \
&& /deps/prune_rocm.sh "$(dirname "${ROCM_SDK_CORE}")" \
&& ln -s "${ROCM_SDK_CORE}" /opt/rocm
RUN pip3 uninstall -y onnxruntime \
&& pip3 install -r /requirements.txt
WORKDIR /opt/frigate
COPY --from=rootfs / /
#######################################################################
FROM scratch AS rocm-dist
ARG ROCM
# Copy HIP headers required for MIOpen JIT (BuildHip) / HIPRTC at runtime
COPY --from=rocm /opt/rocm-${ROCM}/include/ /opt/rocm-${ROCM}/include/
COPY --from=rocm /opt/rocm-$ROCM/bin/rocminfo /opt/rocm-$ROCM/bin/migraphx-driver /opt/rocm-$ROCM/bin/
# Copy MIOpen database files for gfx10xx, gfx11xx, and gfx12xx only (RDNA2/RDNA3/RDNA4)
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx10* /opt/rocm-$ROCM/share/miopen/db/
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx11* /opt/rocm-$ROCM/share/miopen/db/
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx12* /opt/rocm-$ROCM/share/miopen/db/
# Copy rocBLAS library files for gfx10xx, gfx11xx, and gfx12xx only
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx10* /opt/rocm-$ROCM/lib/rocblas/library/
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx11* /opt/rocm-$ROCM/lib/rocblas/library/
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx12* /opt/rocm-$ROCM/lib/rocblas/library/
COPY --from=rocm /opt/rocm-dist/ /
#######################################################################
FROM deps-prelim AS rocm-prelim-hsa-override0
ENV MIGRAPHX_DISABLE_MIOPEN_FUSION=1
ENV MIGRAPHX_DISABLE_SCHEDULE_PASS=1
ENV MIGRAPHX_DISABLE_REDUCE_FUSION=1
ENV MIGRAPHX_ENABLE_HIPRTC_WORKAROUNDS=1
ENV MIOPEN_CUSTOM_CACHE_DIR=/config/model_cache/migraphx
ENV MIOPEN_USER_DB_PATH=/config/model_cache/migraphx
ENV AMD_COMGR_CACHE=1
ENV AMD_COMGR_CACHE_DIR=/config/model_cache/migraphx
COPY --from=rocm-dist / /
RUN ldconfig
#######################################################################
FROM rocm-prelim-hsa-override0 as rocm-prelim-hsa-override1
-63
View File
@@ -1,63 +0,0 @@
#!/bin/bash
# Remove the parts of AMD's ROCm wheels that MIGraphX inference never loads:
# compilers, debuggers, profilers, management tools, and the math libraries
# (sparse, solver, FFT, random, RCCL, image processing) outside MIGraphX's
# dependencies. Runs in the same layer as the install so the files never
# reach the image.
set -euo pipefail
site_packages="$1"
core="${site_packages}/_rocm_sdk_core"
libraries="${site_packages}/_rocm_sdk_libraries"
# rocminfo stays for checking that the GPU is visible in the container
find "${core}/bin" -mindepth 1 -maxdepth 1 \
! -name rocminfo ! -name rocm_agent_enumerator -exec rm -rf {} +
# hiprtc compiles MIGraphX's kernels through comgr, which needs libLLVM,
# libclang-cpp, the clang resource headers, and the amdgcn device libraries
rm -rf "${core}/lib/llvm/bin" "${core}/lib/llvm/include"
find "${core}/lib/llvm/lib" -mindepth 1 -maxdepth 1 \
! -name 'libLLVM.so*' ! -name 'libclang-cpp.so*' ! -name clang \
-exec rm -rf {} +
rm -rf "${core}"/lib/llvm/lib/clang/*/lib
rm -rf \
"${core}/lib/host-math" \
"${core}"/lib/python3* \
"${core}/lib/rocprofiler-sdk" \
"${core}"/lib/librocprofiler-sdk*.so* \
"${core}"/lib/librocprof-trace-decoder.so* \
"${core}"/lib/librocm-dbgapi.so* \
"${core}"/lib/librocm-debug-agent.so* \
"${core}"/lib/libamd_smi*.so* \
"${core}"/lib/libgoamdsmi_shim64.so* \
"${core}"/lib/librocdecode.so* \
"${core}"/lib/librocjpeg.so* \
"${core}"/lib/librocdxg.so* \
"${core}"/lib/librocjitsu_*.so* \
"${core}"/lib/libhsa_hotswap_rocjitsu.so* \
"${core}"/lib/*.a
rm -rf \
"${libraries}"/lib/librpp.so* \
"${libraries}"/lib/librocsparse.so* \
"${libraries}"/lib/libhipsparse.so* \
"${libraries}"/lib/libhipsparselt.so* \
"${libraries}/lib/rocsparse" \
"${libraries}"/lib/librocsolver.so* \
"${libraries}"/lib/libhipsolver.so* \
"${libraries}"/lib/libhipblas.so* \
"${libraries}"/lib/librocrand.so* \
"${libraries}"/lib/libhiprand.so* \
"${libraries}"/lib/librocfft.so* \
"${libraries}"/lib/libhipfft.so* \
"${libraries}"/lib/libhipfftw.so* \
"${libraries}"/lib/librccl.so* \
"${libraries}"/.kpack/sparse_lib_*.kpack \
"${libraries}"/.kpack/solver_lib_*.kpack \
"${libraries}"/.kpack/rand_lib_*.kpack \
"${libraries}"/.kpack/fft_lib_*.kpack \
"${libraries}"/.kpack/rccl_lib_*.kpack
+1 -19
View File
@@ -1,19 +1 @@
# ROCm 10.1.0, MIGraphX, and its ONNX Runtime plugin provider from AMD's wheels.
onnxruntime == 1.29.*
onnxruntime-ep-migraphx @ https://stable.repo.amd.com/rocm/onnxruntime/whl-next/onnxruntime-ep-migraphx/onnxruntime_ep_migraphx-1.0.0%2Brocm10.1.0-cp311-cp311-manylinux_2_28_x86_64.whl
migraphx-libs @ https://stable.repo.amd.com/rocm/migraphx/whl-next/migraphx-libs/migraphx_libs-2.18.0%2Brocm10.1.0-py3-none-manylinux_2_28_x86_64.whl
rocm-sdk-core @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-core/rocm_sdk_core-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-libraries @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-libraries/rocm_sdk_libraries-10.1.0-py3-none-linux_x86_64.whl
# Kernels for each supported GPU target. MIGraphX has no gfx1031-gfx1036 kernels,
# so those GPUs run as gfx1030 with HSA_OVERRIDE_GFX_VERSION=10.3.0
rocm-sdk-device-gfx1030 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1030/rocm_sdk_device_gfx1030-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1100 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1100/rocm_sdk_device_gfx1100-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1101 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1101/rocm_sdk_device_gfx1101-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1102 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1102/rocm_sdk_device_gfx1102-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1103 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1103/rocm_sdk_device_gfx1103-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1150 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1150/rocm_sdk_device_gfx1150-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1151 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1151/rocm_sdk_device_gfx1151-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1152 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1152/rocm_sdk_device_gfx1152-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1153 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1153/rocm_sdk_device_gfx1153-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1200 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1200/rocm_sdk_device_gfx1200-10.1.0-py3-none-linux_x86_64.whl
rocm-sdk-device-gfx1201 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1201/rocm_sdk_device_gfx1201-10.1.0-py3-none-linux_x86_64.whl
onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.2.3-1/onnxruntime_migraphx-1.24.4-cp311-cp311-linux_x86_64.whl
+11
View File
@@ -1,3 +1,6 @@
variable "ROCM" {
default = "7.2.3"
}
variable "HSA_OVERRIDE_GFX_VERSION" {
default = ""
}
@@ -5,6 +8,12 @@ variable "HSA_OVERRIDE" {
default = "1"
}
target wget {
dockerfile = "docker/main/Dockerfile"
platforms = ["linux/amd64"]
target = "wget"
}
target deps {
dockerfile = "docker/main/Dockerfile"
platforms = ["linux/amd64"]
@@ -21,10 +30,12 @@ target rocm {
dockerfile = "docker/rocm/Dockerfile"
contexts = {
deps = "target:deps",
wget = "target:wget",
rootfs = "target:rootfs"
}
platforms = ["linux/amd64"]
args = {
ROCM = ROCM,
HSA_OVERRIDE_GFX_VERSION = HSA_OVERRIDE_GFX_VERSION,
HSA_OVERRIDE = HSA_OVERRIDE
}
@@ -117,7 +117,9 @@ auth:
# Optional: Cookie to store the JWT token for native auth (default: shown below)
cookie_name: frigate_token
# Optional: Set secure flag on cookie. (default: shown below)
# NOTE: This should be set to True if you are using TLS
# NOTE: Frigate sets the flag automatically when serving TLS. Set this to True
# when a reverse proxy terminates TLS in front of Frigate. A proxy that connects
# to the TLS port must serve Frigate to browsers over HTTPS
cookie_secure: False
# Optional: Session length in seconds (default: shown below)
session_length: 86400 # 24 hours
+18 -26
View File
@@ -394,22 +394,6 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
Support for AMD GPUs is provided using the [ONNX detector](#onnx). In order to utilize the AMD GPU for object detection use a frigate docker image with `-rocm` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-rocm`.
### Supported GPUs {#supported-gpus-rocm}
The `-rocm` image includes ROCm support for these GPU chipsets, which work without any chipset override:
| Chipset | Example GPUs |
| -------------------------------------------- | ------------------------------------------------------------------------ |
| gfx1030 (RDNA2) | Radeon RX 6800 / 6800 XT / 6900 XT / 6950 XT, Radeon PRO W6800 |
| gfx1100, gfx1101, gfx1102 (RDNA3) | Radeon RX 7900 / 7800 / 7700 / 7600 series, Radeon PRO W7000 series |
| gfx1103 (RDNA3 integrated) | Radeon 780M / 760M in Ryzen 7040, 8040, 8000G, and 200 series processors |
| gfx1150, gfx1151, gfx1152, gfx1153 (RDNA3.5) | Integrated GPUs in Ryzen AI 300 / 400 series and Ryzen AI Max processors |
| gfx1200, gfx1201 (RDNA4) | Radeon RX 9060 / 9070 series, Radeon AI PRO R9700 |
Other RDNA2 GPUs (gfx1031 through gfx1036), such as the Radeon RX 6700 / 6600 / 6500 series and the Radeon 680M / 660M integrated GPUs, run on the gfx1030 support by [overriding the chipset](#docker-settings-for-overriding-the-gpu-chipset) to `10.3.0`.
Older GPUs, including RDNA1 (Radeon RX 5000 series) and Vega based dedicated and integrated GPUs, are not supported.
### Docker settings for GPU access
ROCm needs access to the `/dev/kfd` and `/dev/dri` devices. When docker or frigate is not run under root then also `video` (and possibly `render` and `ssl/_ssl`) groups should be added.
@@ -436,10 +420,19 @@ For reference on recommended settings see [running ROCm/pytorch in Docker](https
### Docker settings for overriding the GPU chipset
A GPU whose chipset isn't in the [supported GPUs](#supported-gpus-rocm) list can run on the support for a closely related chipset by setting the `HSA_OVERRIDE_GFX_VERSION` environment variable. RDNA2 GPUs other than gfx1030 (gfx1031 through gfx1036) need this, using `10.3.0`:
Your GPU might work just fine without any special configuration but in many cases they need manual settings. AMD/ROCm software stack comes with a limited set of GPU drivers and for newer or missing models you will have to override the chipset version to an older/generic version to get things working.
Also AMD/ROCm does not "officially" support integrated GPUs. It still does work with most of them just fine but requires special settings. One has to configure the `HSA_OVERRIDE_GFX_VERSION` environment variable. See the [ROCm bug report](https://github.com/ROCm/ROCm/issues/1743) for context and examples.
For the rocm frigate build there is some automatic detection:
- gfx1031 -> 10.3.0
- gfx1103 -> 11.0.0
If you have something else you might need to override the `HSA_OVERRIDE_GFX_VERSION` at Docker launch. Suppose the version you want is `10.0.0`, then you should configure it from command line as:
```bash
$ docker run -e HSA_OVERRIDE_GFX_VERSION=10.3.0 \
$ docker run -e HSA_OVERRIDE_GFX_VERSION=10.0.0 \
...
```
@@ -450,17 +443,16 @@ services:
frigate:
...
environment:
HSA_OVERRIDE_GFX_VERSION: "10.3.0"
HSA_OVERRIDE_GFX_VERSION: "10.0.0"
```
GPUs in the supported list don't need an override. An override that was set for the Radeon 780M / 760M (gfx1103) on an earlier version of Frigate is no longer needed and can be removed.
Figuring out what version you need can be complicated as you can't tell the chipset name and driver from the AMD brand name.
You can't tell the chipset from the AMD brand name, so check it in the container:
- first make sure that the ROCm environment is running properly by running `/opt/rocm/bin/rocminfo` in the Frigate container. It should list both the CPU and the GPU with their properties
- find the chipset version you have (gfxNNN) from the output of `rocminfo` (see below)
- if the chipset isn't in the supported list, set `HSA_OVERRIDE_GFX_VERSION` to the closest supported chipset, written as major.minor.stepping (for example gfx1030 is `10.3.0`)
- if things are not working check the Frigate docker logs
- first make sure that rocm environment is running properly by running `/opt/rocm/bin/rocminfo` in the frigate container -- it should list both the CPU and the GPU with their properties
- find the chipset version you have (gfxNNN) from the output of the `rocminfo` (see below)
- use a search engine to query what `HSA_OVERRIDE_GFX_VERSION` you need for the given gfx name ("gfxNNN ROCm HSA_OVERRIDE_GFX_VERSION")
- override the `HSA_OVERRIDE_GFX_VERSION` with relevant value
- if things are not working check the frigate docker logs
#### Figuring out if AMD/ROCm is working and found your GPU
+2 -2
View File
@@ -72,7 +72,7 @@ Frigate supports multiple different detectors that work on different types of ha
**AMD**
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD discrete and recent integrated GPUs to provide efficient object detection
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection
- [Supports limited model architectures](../../configuration/object_detectors#amdrocm-gpu-detector)
- Runs best on discrete AMD GPUs
- <CommunityBadge /> [XDNA2 (Ryzen AI)](#amd-xdna2): AMD XDNA2 NPU (sub-watt power AI/ML processor separate to the GPU) inside Strix and other "AI" branded AMD platforms
@@ -240,7 +240,7 @@ Apple Silicon can not run within a container, so a ZMQ proxy is utilized to comm
### ROCm - AMD GPU
With the [ROCm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many AMD discrete and integrated GPUs, see the [supported GPUs](../configuration/object_detectors.md#supported-gpus-rocm).
With the [ROCm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many discrete AMD GPUs.
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time |
| -------------- | --------------------------- | ------------------------- | ---------------------- |
+33 -172
View File
@@ -61,7 +61,6 @@ from frigate.util.builtin import (
clean_camera_user_pass,
deep_merge,
flatten_config_data,
is_masked_camera_path,
load_labels,
process_config_query_string,
split_config_key_path,
@@ -72,7 +71,6 @@ from frigate.util.config import (
find_config_file,
redact_credential,
)
from frigate.util.env import substitute_frigate_vars
from frigate.util.live_streams import (
generated_transcode_streams,
sync_transcode_streams,
@@ -404,8 +402,6 @@ def config(request: Request):
config["go2rtc"]["streams"][stream_name] = cleaned
_redact_go2rtc_credentials(config["go2rtc"])
config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()}
for index, model in enumerate(config_obj.models):
@@ -432,28 +428,6 @@ def config(request: Request):
return JSONResponse(content=config)
def _redact_go2rtc_credentials(go2rtc: dict[str, Any]) -> None:
"""Redact go2rtc secrets outside of stream URLs, in place."""
for section in ("rtsp", "api", "mqtt"):
if isinstance(go2rtc.get(section), dict):
redact_credential(go2rtc[section], "username")
redact_credential(go2rtc[section], "password")
if isinstance(go2rtc.get("ngrok"), dict):
redact_credential(go2rtc["ngrok"], "command")
if isinstance(go2rtc.get("homekit"), dict):
for accessory in go2rtc["homekit"].values():
if isinstance(accessory, dict):
for key in ("pin", "device_private", "pairings"):
redact_credential(accessory, key)
# publish destinations carry stream keys in the URL path
if isinstance(go2rtc.get("publish"), dict):
for name in list(go2rtc["publish"]):
redact_credential(go2rtc["publish"], name)
@router.get("/profiles", dependencies=[Depends(allow_any_authenticated())])
def get_profiles(request: Request):
"""List all available profiles and the currently active profile."""
@@ -768,24 +742,17 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
)
def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None:
"""Substitute incoming masked camera paths with the saved ones.
def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None:
"""Substitute incoming `*:*` masked credentials with the in-memory ones.
The /config response masks ffmpeg input credentials, so the settings UI
sends the masked path back when sibling fields (e.g. hwaccel_args) are
edited. Each masked path is matched to the saved input that masks to the
same value, with roles breaking a tie, so deleting or reordering inputs
keeps each path's own credentials. The saved path is restored as written,
keeping any {FRIGATE_*} placeholder. Mutates `config_data` in place and
returns an error message when a masked path has no single match.
edited. Without this we'd write `rtsp://*:*@host` into YAML and lose
the real credentials. Mutates `config_data` in place.
"""
cameras = config_data.get("cameras")
if not isinstance(cameras, dict):
return None
saved_cameras = saved.get("cameras")
if not isinstance(saved_cameras, dict):
saved_cameras = {}
return
for camera_name, camera_data in cameras.items():
if not isinstance(camera_data, dict):
@@ -793,122 +760,20 @@ def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None:
inputs = camera_data.get("ffmpeg", {}).get("inputs")
if not isinstance(inputs, list):
continue
saved_camera = saved_cameras.get(camera_name)
saved_ffmpeg = (
saved_camera.get("ffmpeg") if isinstance(saved_camera, dict) else None
)
saved_inputs = [
inp
for inp in (
saved_ffmpeg.get("inputs") if isinstance(saved_ffmpeg, dict) else None
)
or []
if isinstance(inp, dict) and isinstance(inp.get("path"), str)
]
existing = config.cameras.get(camera_name)
if existing is None:
continue
existing_paths = [inp.path for inp in existing.ffmpeg.inputs]
for index, input_obj in enumerate(inputs):
if not isinstance(input_obj, dict):
continue
path = input_obj.get("path")
if not isinstance(path, str) or not is_masked_camera_path(path):
if not isinstance(path, str):
continue
candidates = [
inp
for inp in saved_inputs
if clean_camera_user_pass(_resolve_frigate_vars(inp["path"])) == path
]
if len({inp["path"] for inp in candidates}) > 1:
candidates = [
inp
for inp in candidates
if inp.get("roles") == input_obj.get("roles")
]
real_paths = {inp["path"] for inp in candidates}
if len(real_paths) != 1:
return (
f"Input {index} of camera {camera_name} has masked credentials "
"that could not be matched; resend it with real credentials"
)
input_obj["path"] = real_paths.pop()
return None
def _resolve_frigate_vars(value: str) -> str:
"""Resolve {FRIGATE_*} placeholders the way /config shows them."""
try:
return substitute_frigate_vars(value)
except ValueError:
return value
def _saved_config_from_memory(config: FrigateConfig) -> dict[str, Any]:
"""Shape the in-memory camera inputs like the saved YAML."""
return {
"cameras": {
name: {
"ffmpeg": {
"inputs": [
{"path": inp.path, "roles": [role.value for role in inp.roles]}
for inp in camera.ffmpeg.inputs
]
}
}
for name, camera in config.cameras.items()
},
}
def _config_data_to_updates(
config_data: dict, saved: dict[str, Any]
) -> tuple[dict[str, Any], str | None]:
"""Flatten a /config/set payload after restoring masked credentials.
`saved` is the config the masked values were taken from. Returns the flat
updates, or an error message when a masked value can't be restored.
"""
error = _restore_masked_camera_paths(config_data, saved)
if error:
return {}, error
updates = flatten_config_data(config_data)
# Convert None values to empty strings for deletion (e.g., when deleting masks)
updates = {k: ("" if v is None else v) for k, v in updates.items()}
# Drop sentinel-valued fields so untouched credential placeholders don't
# clobber the saved YAML value.
updates = {k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL}
error = _find_masked_update(updates)
if error:
return {}, error
return updates, None
def _holds_masked_value(value: Any) -> bool:
if isinstance(value, str):
return REDACTED_CREDENTIAL_SENTINEL in value or is_masked_camera_path(value)
if isinstance(value, dict):
return any(_holds_masked_value(v) for v in value.values())
if isinstance(value, list):
return any(_holds_masked_value(v) for v in value)
return False
def _find_masked_update(updates: dict[str, Any]) -> str | None:
"""Return an error naming the first update that still holds a masked value.
Runs after the restores so a masked path or redaction sentinel that could
not be restored is never written over the real credential.
"""
for key, value in updates.items():
if _holds_masked_value(value):
return (
f"{key} contains masked or saved credentials; "
"resend it with real values"
)
return None
if ("://*:*@" in path or "user=*&password=*" in path) and index < len(
existing_paths
):
input_obj["path"] = existing_paths[index]
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
@@ -921,13 +786,13 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
try:
updates = {}
if body.config_data:
updates, error = _config_data_to_updates(
body.config_data, _saved_config_from_memory(request.app.frigate_config)
)
if error:
return JSONResponse(
content={"success": False, "message": error}, status_code=400
)
_restore_masked_camera_paths(body.config_data, request.app.frigate_config)
updates = flatten_config_data(body.config_data)
updates = {k: ("" if v is None else v) for k, v in updates.items()}
# Drop any field whose value is still the redaction sentinel
updates = {
k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL
}
if not updates:
return JSONResponse(
@@ -1074,26 +939,22 @@ def config_set(request: Request, body: AppConfigSetBody):
# Filter out empty keys but keep blank values for non-empty keys
query_string = {k: v for k, v in query_string.items() if k}
error = None
if query_string:
updates = process_config_query_string(query_string)
error = _find_masked_update(updates)
elif body.config_data:
try:
saved_config = ruamel.yaml.YAML(typ="safe").load(old_raw_config)
except ruamel.yaml.YAMLError:
saved_config = None
updates, error = _config_data_to_updates(
body.config_data,
saved_config if isinstance(saved_config, dict) else {},
)
if error:
return JSONResponse(
content={"success": False, "message": error},
status_code=400,
_restore_masked_camera_paths(
body.config_data, request.app.frigate_config
)
updates = flatten_config_data(body.config_data)
# Convert None values to empty strings for deletion (e.g., when deleting masks)
updates = {k: ("" if v is None else v) for k, v in updates.items()}
# Drop sentinel-valued fields so untouched credential
# placeholders don't clobber the saved YAML value.
updates = {
k: v
for k, v in updates.items()
if v != REDACTED_CREDENTIAL_SENTINEL
}
if not updates:
return JSONResponse(
+71 -42
View File
@@ -5,6 +5,7 @@ import hashlib
import ipaddress
import json
import logging
import math
import os
import re
import secrets
@@ -463,16 +464,47 @@ def validate_password_strength(password: str) -> tuple[bool, str | None]:
return True, None
def create_encoded_jwt(user, role, expiration, secret):
def create_encoded_jwt(user, role, expiration, secret, iat: int | None = None):
if iat is None:
iat = int(time.time())
return jwt.encode(
{"alg": "HS256"},
{"sub": user, "role": role, "exp": expiration, "iat": int(time.time())},
{"sub": user, "role": role, "exp": expiration, "iat": iat},
secret,
)
def _user_session(user: User) -> tuple[str, int | None]:
changed_at = user.password_changed_at
# rounded up so a token issued earlier in the same second is revoked
return user.role, math.ceil(changed_at.timestamp()) if changed_at else None
def load_user_sessions() -> dict[str, tuple[str, int | None]]:
"""Map each username to its role and password change time for /auth."""
return {user.username: _user_session(user) for user in User.select()}
def refresh_user_session(request: Request, username: str) -> None:
"""Sync the /auth session map after a user is created, changed or deleted."""
user = User.get_or_none(User.username == username)
if user is None:
request.app.user_sessions.pop(username, None)
else:
request.app.user_sessions[username] = _user_session(user)
def is_secure_request(request: Request) -> bool:
# nginx sets X-Forwarded-Proto to its own scheme, replacing any client value
return (
request.app.frigate_config.auth.cookie_secure
or request.headers.get("x-forwarded-proto") == "https"
)
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
# TODO: ideally this would set secure as well, but that requires TLS
# Starlette sets SameSite=Lax by default. The cookie is still sent to
# same-site iframes (e.g. Home Assistant on the same host or domain), but
# not to cross-site ones. CSRF is also mitigated by requiring a custom
@@ -750,7 +782,6 @@ def auth(request: Request):
fail_response.headers["location"] = "/login"
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
JWT_REFRESH = request.app.frigate_config.auth.refresh_time
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
@@ -776,20 +807,27 @@ def auth(request: Request):
if "sub" not in token.claims:
logger.debug("user not set in jwt token")
return fail_response
if "role" not in token.claims:
logger.debug("role not set in jwt token")
return fail_response
if "exp" not in token.claims:
logger.debug("exp not set in jwt token")
return fail_response
user = token.claims.get("sub")
role = token.claims.get("role")
session = request.app.user_sessions.get(user)
if session is None:
logger.debug("jwt user %s no longer exists", user)
return fail_response
role, password_changed_at = session
if password_changed_at is not None and (
int(token.claims.get("iat", 0)) < password_changed_at
):
logger.debug("jwt token issued before password change")
return fail_response
# the token keeps the role it was issued with, so a role removed from
# the config since then must send the user back through login
if role not in auth_config.roles:
logger.debug("jwt role %s is not in the config", role)
logger.debug(
"jwt user %s has role %s, which is not in the config", user, role
)
return fail_response
current_time = int(time.time())
@@ -812,25 +850,6 @@ def auth(request: Request):
# if the jwt cookie is expiring soon
if jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time:
logger.debug("jwt token expiring soon, refreshing cookie")
# Check if password has been changed since token was issued
# If so, force re-login by rejecting the refresh
try:
user_obj = User.get_by_id(user)
if user_obj.password_changed_at is not None:
token_iat = int(token.claims.get("iat", 0))
password_changed_timestamp = int(
user_obj.password_changed_at.timestamp()
)
if token_iat < password_changed_timestamp:
logger.debug(
"jwt token issued before password change, rejecting refresh"
)
return fail_response
except DoesNotExist:
logger.debug("user not found")
return fail_response
new_expiration = current_time + JWT_SESSION_LENGTH
new_encoded_jwt = create_encoded_jwt(
user, role, new_expiration, request.app.jwt_token
@@ -840,7 +859,7 @@ def auth(request: Request):
JWT_COOKIE_NAME,
new_encoded_jwt,
JWT_SESSION_LENGTH,
JWT_COOKIE_SECURE,
is_secure_request(request),
)
success_response.headers["remote-user"] = user
@@ -929,7 +948,6 @@ def login(request: Request, body: AppPostLoginBody):
)
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
user = body.user
password = body.password
@@ -945,13 +963,16 @@ def login(request: Request, body: AppPostLoginBody):
password_hash = db_user.password_hash
if verify_password(password, password_hash):
role = getattr(db_user, "role", "viewer")
config_roles_set = set(request.app.frigate_config.auth.roles.keys())
if role not in config_roles_set:
role = db_user.role
if role not in request.app.frigate_config.auth.roles:
logger.warning(
f"User {db_user.username} has an invalid role {role}, falling back to 'viewer'."
"Login refused for user '%s' with role '%s', which is not in the config",
user,
role,
)
role = "viewer"
failed_logins.record(user, time.time(), known=True)
return JSONResponse(content={"message": "Login failed"}, status_code=401)
expiration = int(time.time()) + JWT_SESSION_LENGTH
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
response = Response("", 200)
@@ -960,7 +981,7 @@ def login(request: Request, body: AppPostLoginBody):
JWT_COOKIE_NAME,
encoded_jwt,
JWT_SESSION_LENGTH,
JWT_COOKIE_SECURE,
is_secure_request(request),
)
# Clear admin_first_time_login flag after successful admin login so the
# UI stops showing the first-time login documentation link.
@@ -1029,6 +1050,7 @@ def create_user(
User.notification_tokens: [],
}
).execute()
refresh_user_session(request, body.username)
request.app.config_publisher.publisher.publish("config/auth", None)
return JSONResponse(content={"username": body.username})
@@ -1047,6 +1069,7 @@ def delete_user(request: Request, username: str):
)
User.delete_by_id(username)
refresh_user_session(request, username)
request.app.config_publisher.publisher.publish("config/auth", None)
return JSONResponse(content={"success": True})
@@ -1108,24 +1131,29 @@ async def update_password(
)
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
changed_at = datetime.now()
User.update(
{
User.password_hash: password_hash,
User.password_changed_at: datetime.now(),
User.password_changed_at: changed_at,
}
).where(User.username == username).execute()
refresh_user_session(request, username)
response = JSONResponse(content={"success": True})
# If user changed their own password, issue a new JWT to keep them logged in
if current_username == username:
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
expiration = int(time.time()) + JWT_SESSION_LENGTH
encoded_jwt = create_encoded_jwt(
username, current_role, expiration, request.app.jwt_token
username,
current_role,
expiration,
request.app.jwt_token,
iat=math.ceil(changed_at.timestamp()),
)
# Set new JWT cookie on response
set_jwt_cookie(
@@ -1133,7 +1161,7 @@ async def update_password(
JWT_COOKIE_NAME,
encoded_jwt,
JWT_SESSION_LENGTH,
JWT_COOKIE_SECURE,
is_secure_request(request),
)
return response
@@ -1173,6 +1201,7 @@ async def update_role(
)
User.set_by_id(username, {User.role: body.role})
refresh_user_session(request, username)
request.app.config_publisher.publisher.publish("config/auth", None)
return JSONResponse(content={"success": True})
+2
View File
@@ -196,7 +196,9 @@ def create_fastapi_app(
key_bytes = str(secret).encode("utf-8")
app.jwt_token = OctKey.import_key(key_bytes)
app.user_sessions = auth.load_user_sessions()
else:
app.jwt_token = None
app.user_sessions = {}
return app
+1
View File
@@ -622,6 +622,7 @@ class FrigateApp:
username="admin",
role="admin",
password_hash=password_hash,
password_changed_at=datetime.datetime.now(),
notification_tokens=[],
).execute()
+1 -1
View File
@@ -27,7 +27,7 @@ class AuthConfig(FrigateBaseModel):
cookie_secure: bool = Field(
default=False,
title="Secure cookie flag",
description="Set the secure flag on the auth cookie; should be true when using TLS.",
description="Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS.",
)
session_length: int = Field(
default=86400,
+1 -13
View File
@@ -117,19 +117,7 @@ SUPPORTED_RK_SOCS = ["rk3562", "rk3566", "rk3568", "rk3576", "rk3588"]
REGEX_CAMERA_NAME = r"^[a-zA-Z0-9_-]+$"
REGEX_RTSP_CAMERA_USER_PASS = r":\/\/[a-zA-Z0-9_-]+:[\S]+@"
# A "user:" userinfo may hold "/", "#" or "@" in the password, so it runs to the
# last "@" before the next URL. Anything else stops at the authority end. Group 1
# is the userinfo and group 2 the rest of the URL.
REGEX_URL_USERINFO = (
r"://((?:[^\s/?#:\[\]]*:(?:(?!://)\S)*|[^\s/?#]+))@((?:(?!://)\S)*)"
)
_CREDENTIAL_QUERY_KEYS = (
r"(?:user(?:name)?|usr|login|pass(?:word|wd)?|pwd"
r"|(?:[a-z_]*_)?token|(?:[a-z_]*_)?secret|client_private|api_?key)"
)
REGEX_CREDENTIAL_QUERY_KEY = rf"(?i)[?&]{_CREDENTIAL_QUERY_KEYS}="
REGEX_CREDENTIAL_QUERY_PARAM = rf"(?i)([?&]{_CREDENTIAL_QUERY_KEYS}=)[^&\s]*"
REGEX_MASKED_CREDENTIAL = rf"(?i)://\*:\*@|[?&]{_CREDENTIAL_QUERY_KEYS}=\*(?![^&\s])"
REGEX_HTTP_CAMERA_USER_PASS = r"user=[a-zA-Z0-9_-]+&password=[\S]+"
REGEX_JSON = re.compile(r"^\s*\{")
# Known Driver Names
+11 -71
View File
@@ -10,7 +10,6 @@ from typing import Any
import numpy as np
import onnxruntime as ort
from frigate.const import MODEL_CACHE_DIR
from frigate.util.model import get_ort_providers
from frigate.util.rknn_converter import auto_convert_model, is_rknn_compatible
@@ -56,9 +55,6 @@ _PROVIDER_LABELS = {
LIGHTER_ANE_EP_NAME = "LighterANE"
LIGHTER_ANE_LIBRARY = "/usr/lib/lighter/liblighter_ane_ep.so"
# AMD's MIGraphX plugin execution provider, installed in the ROCm image
MIGRAPHX_EP_NAME = "MIGraphXExecutionProvider"
def get_lighter_ane_devices() -> list[Any]:
"""Get the Neural Engine devices lighter's provider offers, registering it once.
@@ -87,42 +83,6 @@ def get_lighter_ane_devices() -> list[Any]:
return devices
def get_migraphx_devices() -> list[Any]:
"""Get the AMD GPU devices the MIGraphX provider offers, registering it once.
Returns:
The provider's ONNX Runtime devices, or an empty list outside the ROCm
image or without an AMD GPU
"""
# Import here to avoid circular imports
from frigate.detectors.hardware import detect_amd_gpu
try:
import onnxruntime_ep_migraphx
except ImportError:
return []
# ONNX Runtime finds the provider's GPUs in the same /sys/class/drm entries,
# so an AMD GPU here means the provider lists it once registered
if detect_amd_gpu() is None:
return []
devices = [d for d in ort.get_ep_devices() if d.ep_name == MIGRAPHX_EP_NAME]
if not devices:
try:
ort.register_execution_provider_library(
MIGRAPHX_EP_NAME, onnxruntime_ep_migraphx.get_library_path()
)
except Exception as e:
logger.warning("Failed to load the MIGraphX provider: %s", e)
return []
devices = [d for d in ort.get_ep_devices() if d.ep_name == MIGRAPHX_EP_NAME]
return devices
def is_arm64_platform() -> bool:
"""Check if we're running on an ARM platform."""
machine = platform.machine().lower()
@@ -796,41 +756,12 @@ def get_optimized_runner(
ONNXModelRunner(session, model_type=model_type),
)
if (
device != "CPU"
and not ONNXModelRunner.is_migraphx_complex_model(model_type)
and (migraphx_devices := get_migraphx_devices())
):
migraphx_cache_dir = os.path.join(MODEL_CACHE_DIR, "migraphx/ort")
os.makedirs(migraphx_cache_dir, exist_ok=True)
sess_options = get_ort_session_options(model_type) or ort.SessionOptions()
# only the first AMD GPU is used, matching detect_amd_gpu
sess_options.add_provider_for_devices(
migraphx_devices[:1], {"cache_dir": migraphx_cache_dir}
)
try:
session = ort.InferenceSession(model_path, sess_options=sess_options)
except Exception as e:
logger.warning(
"Failed to load %s with MIGraphX, using the default providers: %s",
model_path,
e,
)
else:
return _record_runner(
model_path,
model_type,
ONNXModelRunner(session, model_type=model_type),
)
providers, options = get_ort_providers(device == "CPU", device, **kwargs)
if providers[0] == "CPUExecutionProvider":
# In the default image, ONNXRuntime is used so we will only get CPUExecutionProvider
# In other images we will get CUDA which is preferred over OpenVINO, while
# MIGraphX in the ROCm image is already handled above
# There is currently no way to prioritize OpenVINO over CUDA in these images
# In other images we will get CUDA / ROCm which are preferred over OpenVINO
# There is currently no way to prioritize OpenVINO over CUDA / ROCm in these images
if device != "CPU" and is_openvino_gpu_npu_available():
return _record_runner(
model_path,
@@ -859,6 +790,15 @@ def get_optimized_runner(
),
)
if (
providers
and providers[0] == "MIGraphXExecutionProvider"
and ONNXModelRunner.is_migraphx_complex_model(model_type)
):
# Don't use MIGraphX for models that are not supported
providers.pop(0)
options.pop(0)
return _record_runner(
model_path,
model_type,
+238 -17
View File
@@ -1,22 +1,29 @@
"""Tests that /auth only accepts a JWT whose role is still in the config."""
"""Tests that /auth takes the role and revocation state from the user, not the JWT."""
import os
import time
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
from frigate.api.auth import create_encoded_jwt
from joserfc import jwt
from frigate.api.auth import create_encoded_jwt, hash_password
from frigate.api.fastapi_app import create_fastapi_app
from frigate.app import FrigateApp
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.const import JWT_SECRET_ENV_VAR
from frigate.models import Event, Recordings, ReviewSegment
from frigate.models import Event, Recordings, ReviewSegment, User
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
PASSWORD = "correct-horse-battery"
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
class TestAuthJwtRole(BaseTestHttp):
def setUp(self):
super().setUp(models=[Event, Recordings, ReviewSegment])
super().setUp(models=[Event, Recordings, ReviewSegment, User])
self.minimal_config = {
"mqtt": {"host": "mqtt"},
"auth": {"enabled": True, "roles": {"garage": ["front_door"]}},
@@ -36,6 +43,19 @@ class TestAuthJwtRole(BaseTestHttp):
}
},
}
User.delete().execute()
self._insert_user("admin", "admin")
self._insert_user("bob", "garage")
def _insert_user(self, username: str, role: str):
User.insert(
{
User.username: username,
User.role: role,
User.password_hash: hash_password(PASSWORD, iterations=1),
User.notification_tokens: [],
}
).execute()
def _create_app(self):
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
@@ -55,27 +75,228 @@ class TestAuthJwtRole(BaseTestHttp):
enforce_default_admin=False,
)
def _auth(self, app, role: str):
token = create_encoded_jwt("bob", role, int(time.time()) + 3600, app.jwt_token)
with AuthTestClient(app) as client:
return client.get(
"/auth",
headers={
"x-server-port": "8971",
"authorization": f"Bearer {token}",
},
def _token(self, app, user: str, role: str, iat: int | None = None) -> str:
if iat is None:
return create_encoded_jwt(
user, role, int(time.time()) + 3600, app.jwt_token
)
return jwt.encode(
{"alg": "HS256"},
{"sub": user, "role": role, "exp": int(time.time()) + 3600, "iat": iat},
app.jwt_token,
)
def _auth(self, client, token: str, source: str = "bearer"):
headers = {"x-server-port": "8971"}
if source == "bearer":
headers["authorization"] = f"Bearer {token}"
else:
client.cookies.set("frigate_token", token)
resp = client.get("/auth", headers=headers)
client.cookies.clear()
return resp
def test_configured_role_is_accepted(self):
resp = self._auth(self._create_app(), "garage")
app = self._create_app()
with AuthTestClient(app) as client:
resp = self._auth(client, self._token(app, "bob", "garage"))
self.assertEqual(resp.status_code, 202)
self.assertEqual(resp.headers["remote-user"], "bob")
self.assertEqual(resp.headers["remote-role"], "garage")
def test_role_claim_is_ignored(self):
app = self._create_app()
with AuthTestClient(app) as client:
resp = self._auth(client, self._token(app, "bob", "admin"))
self.assertEqual(resp.status_code, 202)
self.assertEqual(resp.headers["remote-role"], "garage")
def test_role_removed_from_config_is_rejected(self):
resp = self._auth(self._create_app(), "removed_role")
User.update(role="removed_role").where(User.username == "bob").execute()
app = self._create_app()
with AuthTestClient(app) as client:
for source in ("bearer", "cookie"):
resp = self._auth(client, self._token(app, "bob", "garage"), source)
self.assertEqual(resp.status_code, 401)
self.assertNotIn("remote-role", resp.headers)
def test_login_refused_for_role_removed_from_config(self):
User.update(role="removed_role").where(User.username == "bob").execute()
with AuthTestClient(self._create_app()) as client:
resp = client.post("/login", json={"user": "bob", "password": PASSWORD})
self.assertEqual(resp.status_code, 401)
self.assertNotIn("remote-role", resp.headers)
self.assertNotIn("set-cookie", resp.headers)
def test_demoted_user_gets_new_role_on_next_request(self):
app = self._create_app()
token = self._token(app, "bob", "admin")
User.update(role="admin").where(User.username == "bob").execute()
app.user_sessions["bob"] = ("admin", None)
with AuthTestClient(app) as client:
self.assertEqual(self._auth(client, token).headers["remote-role"], "admin")
resp = client.put("/users/bob/role", json={"role": "viewer"})
self.assertEqual(resp.status_code, 200)
for source in ("bearer", "cookie"):
resp = self._auth(client, token, source)
self.assertEqual(resp.status_code, 202)
self.assertEqual(resp.headers["remote-role"], "viewer")
def test_deleted_user_is_rejected(self):
app = self._create_app()
token = self._token(app, "bob", "garage")
with AuthTestClient(app) as client:
self.assertEqual(self._auth(client, token).status_code, 202)
self.assertEqual(client.delete("/users/bob").status_code, 200)
for source in ("bearer", "cookie"):
self.assertEqual(self._auth(client, token, source).status_code, 401)
def test_created_user_is_accepted(self):
app = self._create_app()
with AuthTestClient(app) as client:
resp = client.post(
"/users",
json={"username": "carol", "password": PASSWORD, "role": "viewer"},
)
self.assertEqual(resp.status_code, 200)
resp = self._auth(client, self._token(app, "carol", "viewer"))
self.assertEqual(resp.status_code, 202)
self.assertEqual(resp.headers["remote-role"], "viewer")
def test_password_change_revokes_older_tokens(self):
app = self._create_app()
old_token = self._token(app, "bob", "garage", iat=int(time.time()) - 10)
with AuthTestClient(app) as client:
self.assertEqual(self._auth(client, old_token).status_code, 202)
resp = client.put(
"/users/bob/password",
json={"password": "a-brand-new-password"},
)
self.assertEqual(resp.status_code, 200)
for source in ("bearer", "cookie"):
self.assertEqual(self._auth(client, old_token, source).status_code, 401)
changed_at = app.user_sessions["bob"][1]
resp = self._auth(client, self._token(app, "bob", "garage", changed_at))
self.assertEqual(resp.status_code, 202)
def test_password_change_revokes_token_from_the_same_second(self):
app = self._create_app()
changed_at = datetime.now().replace(microsecond=500000)
old_token = self._token(app, "bob", "garage", iat=int(changed_at.timestamp()))
with (
patch("frigate.api.auth.datetime") as mock_datetime,
AuthTestClient(app) as client,
):
mock_datetime.now.return_value = changed_at
resp = client.put(
"/users/bob/password",
json={"password": "a-brand-new-password"},
)
self.assertEqual(resp.status_code, 200)
for source in ("bearer", "cookie"):
self.assertEqual(self._auth(client, old_token, source).status_code, 401)
def test_own_password_change_cookie_stays_valid(self):
app = self._create_app()
with AuthTestClient(app) as client:
resp = client.put(
"/users/bob/password",
json={"password": "a-brand-new-password", "old_password": PASSWORD},
headers={"remote-user": "bob", "remote-role": "garage"},
)
self.assertEqual(resp.status_code, 200)
new_token = resp.cookies["frigate_token"]
resp = self._auth(client, new_token, "cookie")
self.assertEqual(resp.status_code, 202)
self.assertEqual(resp.headers["remote-role"], "garage")
def test_admin_password_reset_revokes_older_tokens(self):
config = FrigateConfig(
**{**self.minimal_config, "auth": {"reset_admin_password": True}}
)
FrigateApp.init_auth(SimpleNamespace(config=config))
app = self._create_app()
old_token = self._token(app, "admin", "admin", iat=int(time.time()) - 10)
with AuthTestClient(app) as client:
for source in ("bearer", "cookie"):
self.assertEqual(self._auth(client, old_token, source).status_code, 401)
def test_auth_does_not_query_users(self):
app = self._create_app()
# expires inside the refresh window so the cookie refresh runs too
token = jwt.encode(
{"alg": "HS256"},
{"sub": "bob", "role": "garage", "exp": int(time.time()) + 60, "iat": 0},
app.jwt_token,
)
with (
patch.object(User, "select", side_effect=AssertionError("db query")),
AuthTestClient(app) as client,
):
client.cookies.set("frigate_token", token)
resp = client.get("/auth", headers={"x-server-port": "8971"})
self.assertEqual(resp.status_code, 202)
self.assertIn("frigate_token=", resp.headers["set-cookie"])
def _login_cookie(self, headers: dict | None = None) -> str:
with AuthTestClient(self._create_app()) as client:
resp = client.post(
"/login",
json={"user": "bob", "password": PASSWORD},
headers=headers or {},
)
self.assertEqual(resp.status_code, 200)
return resp.headers["set-cookie"].lower()
def test_login_cookie_is_secure_over_tls(self):
cookie = self._login_cookie({"x-forwarded-proto": "https"})
self.assertIn("; secure", cookie)
def test_login_cookie_is_not_secure_over_http(self):
cookie = self._login_cookie({"x-forwarded-proto": "http"})
self.assertNotIn("; secure", cookie)
def test_cookie_secure_config_forces_secure(self):
self.minimal_config["auth"]["cookie_secure"] = True
cookie = self._login_cookie()
self.assertIn("; secure", cookie)
def test_refreshed_cookie_is_secure_over_tls(self):
app = self._create_app()
token = jwt.encode(
{"alg": "HS256"},
{"sub": "bob", "exp": int(time.time()) + 60, "iat": int(time.time())},
app.jwt_token,
)
with AuthTestClient(app) as client:
client.cookies.set("frigate_token", token)
resp = client.get(
"/auth",
headers={"x-server-port": "8971", "x-forwarded-proto": "https"},
)
self.assertEqual(resp.status_code, 202)
self.assertIn("; secure", resp.headers["set-cookie"].lower())
@@ -14,7 +14,6 @@ from frigate.config.camera.updater import (
CameraConfigUpdateTopic,
)
from frigate.config.holder import ConfigHolder
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@@ -504,358 +503,6 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
finally:
os.unlink(config_path)
camera_path = "rtsp://10.0.0.1:554/video?password=s3cret&user=admin"
def _add_credentials(self):
"""Give the config a credentialed camera path and go2rtc secrets."""
self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"] = (
self.camera_path
)
self.minimal_config["go2rtc"] = {
"rtsp": {"username": "rtspuser", "password": "rtsppass"},
"api": {"username": "apiuser", "password": "apipass"},
"mqtt": {"username": "mquser", "password": "mqpass"},
"ngrok": {"command": "ngrok tcp 8555 --authtoken ngroksecret"},
"homekit": {"front_door": {"pin": "19550224"}},
"publish": {"front_door": "rtmp://a.rtmp.youtube.com/live2/streamkey"},
"webrtc": {
"candidates": ["10.0.0.5:8555"],
"ice_servers": [
{
"urls": ["turn:turn.example.com:3478"],
"username": "turnuser",
"credential": "turnpass",
}
],
},
}
def _read_config_file(self, config_path):
with open(config_path) as f:
raw = f.read()
return raw, ruamel.yaml.YAML().load(raw)
@patch("frigate.api.app.find_config_file")
def test_masked_camera_path_round_trips(self, mock_find_config):
self._add_credentials()
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, _ = self._create_app_with_publisher()
with AuthTestClient(app) as client:
camera = client.get("/config").json()["cameras"]["front_door"]
inputs = camera["ffmpeg"]["inputs"]
self.assertNotIn("s3cret", inputs[0]["path"])
resp = client.put(
"/config/set",
json={
"config_data": {
"cameras": {
"front_door": {
"ffmpeg": {"inputs": inputs},
"detect": {"fps": 7},
}
}
},
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
_, saved = self._read_config_file(config_path)
self.assertEqual(
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"],
self.camera_path,
)
finally:
os.unlink(config_path)
def _save(self, client, config_data):
return client.put(
"/config/set",
json={"config_data": config_data, "requires_restart": 0},
)
def _set_inputs(self, paths):
self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"] = [
{"path": path, "roles": [role]} for path, role in paths
]
# config parsing rebuilds FRIGATE_ENV_VARS from its sources
@patch.dict("frigate.util.env._CONTAINER_ENV", {"FRIGATE_RTSP_PASSWORD": "s3cret"})
@patch.dict(
"frigate.util.env.FRIGATE_ENV_VARS", {"FRIGATE_RTSP_PASSWORD": "s3cret"}
)
@patch("frigate.api.app.find_config_file")
def test_masked_path_keeps_env_placeholder(self, mock_find_config):
path = "rtsp://admin:{FRIGATE_RTSP_PASSWORD}@10.0.0.1:554/live"
self._set_inputs([(path, "detect")])
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, _ = self._create_app_with_publisher()
with AuthTestClient(app) as client:
camera = client.get("/config").json()["cameras"]["front_door"]
self.assertNotIn("s3cret", str(camera))
resp = self._save(
client,
{
"cameras": {
"front_door": {
"ffmpeg": {"inputs": camera["ffmpeg"]["inputs"]},
"detect": {"fps": 7},
}
}
},
)
self.assertEqual(resp.status_code, 200)
raw, saved = self._read_config_file(config_path)
self.assertNotIn("s3cret", raw)
self.assertEqual(
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], path
)
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_over_masked_path_round_trips(self, mock_find_config):
path = "rtsp://10.0.0.1:554/live?x=a@b"
self._set_inputs([(path, "detect")])
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, _ = self._create_app_with_publisher()
with AuthTestClient(app) as client:
inputs = client.get("/config").json()["cameras"]["front_door"][
"ffmpeg"
]["inputs"]
self.assertNotEqual(inputs[0]["path"], path)
resp = self._save(
client,
{"cameras": {"front_door": {"ffmpeg": {"inputs": inputs}}}},
)
self.assertEqual(resp.status_code, 200)
_, saved = self._read_config_file(config_path)
self.assertEqual(
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], path
)
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_unrestorable_masked_values_are_rejected(self, mock_find_config):
self._add_credentials()
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, _ = self._create_app_with_publisher()
with AuthTestClient(app) as client:
for name, request in (
(
"index-keyed inputs",
{
"json": {
"config_data": {
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": {
"0": {"path": "rtsp://*:*@h/x"}
}
}
}
}
},
"requires_restart": 0,
}
},
),
(
"go2rtc streams",
{
"json": {
"config_data": {
"go2rtc": {"streams": {"cam": ["rtsp://*:*@h/x"]}}
},
"requires_restart": 0,
}
},
),
(
"query string",
{
"params": {
"cameras.front_door.ffmpeg.inputs.0.path": (
"rtsp://*:*@h/x"
),
"go2rtc.rtsp.password": REDACTED_CREDENTIAL_SENTINEL,
},
"json": {"requires_restart": 0},
},
),
):
with self.subTest(name):
resp = client.put("/config/set", **request)
self.assertEqual(resp.status_code, 400)
self.assertIn("credentials", resp.json()["message"])
raw, _ = self._read_config_file(config_path)
self.assertNotIn("*:*", raw)
self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw)
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_go2rtc_credentials_redacted_and_round_trip(self, mock_find_config):
self._add_credentials()
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, _ = self._create_app_with_publisher()
with AuthTestClient(app) as client:
response = client.get("/config").json()
body = str(response)
for secret in (
"rtsppass",
"apipass",
"mqpass",
"ngroksecret",
"19550224",
"streamkey",
):
self.assertNotIn(secret, body)
go2rtc = response["go2rtc"]
self.assertEqual(go2rtc["webrtc"]["candidates"], ["10.0.0.5:8555"])
# browsers need the TURN account to relay WebRTC
self.assertEqual(
go2rtc["webrtc"]["ice_servers"][0]["credential"], "turnpass"
)
go2rtc.pop("ngrok")
go2rtc["webrtc"]["candidates"] = ["10.0.0.9:8555"]
self.assertEqual(
self._save(client, {"go2rtc": go2rtc}).status_code, 200
)
raw, saved = self._read_config_file(config_path)
self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw)
self.assertEqual(saved["go2rtc"]["rtsp"]["password"], "rtsppass")
self.assertEqual(saved["go2rtc"]["mqtt"]["password"], "mqpass")
self.assertEqual(
saved["go2rtc"]["homekit"]["front_door"]["pin"], "19550224"
)
self.assertIn("streamkey", saved["go2rtc"]["publish"]["front_door"])
self.assertEqual(saved["go2rtc"]["webrtc"]["candidates"], ["10.0.0.9:8555"])
self.assertEqual(
saved["go2rtc"]["webrtc"]["ice_servers"][0]["credential"], "turnpass"
)
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_masked_inputs_restore_by_value(self, mock_find_config):
main = "rtsp://u:MAIN@10.0.0.1:554/main"
sub = "rtsp://u:SUB@10.0.0.1:554/sub"
for name, edit, expected in (
("delete first", lambda inputs: [inputs[1]], [sub]),
("reorder", lambda inputs: [inputs[1], inputs[0]], [sub, main]),
):
with self.subTest(name):
self._set_inputs([(main, "record"), (sub, "detect")])
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, _ = self._create_app_with_publisher()
with AuthTestClient(app) as client:
inputs = client.get("/config").json()["cameras"]["front_door"][
"ffmpeg"
]["inputs"]
resp = self._save(
client,
{
"cameras": {
"front_door": {"ffmpeg": {"inputs": edit(inputs)}}
}
},
)
self.assertEqual(resp.status_code, 200)
_, saved = self._read_config_file(config_path)
saved_inputs = saved["cameras"]["front_door"]["ffmpeg"]["inputs"]
self.assertEqual([i["path"] for i in saved_inputs], expected)
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_ambiguous_or_unmatched_masked_input(self, mock_find_config):
self._set_inputs(
[
("rtsp://u:one@10.0.0.1:554/live", "record"),
("rtsp://u:two@10.0.0.1:554/live", "detect"),
]
)
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, _ = self._create_app_with_publisher()
with AuthTestClient(app) as client:
inputs = client.get("/config").json()["cameras"]["front_door"][
"ffmpeg"
]["inputs"]
# both inputs mask to the same value and roles no longer tell
# them apart
remaining = dict(inputs[1], roles=["detect", "record"])
resp = self._save(
client,
{"cameras": {"front_door": {"ffmpeg": {"inputs": [remaining]}}}},
)
self.assertEqual(resp.status_code, 400)
inputs[0]["path"] = "rtsp://*:*@10.0.0.99:554/live"
resp = self._save(
client,
{"cameras": {"front_door": {"ffmpeg": {"inputs": inputs}}}},
)
self.assertEqual(resp.status_code, 400)
# roles break the tie when the second input stays as it was
resp = self._save(
client,
{"cameras": {"front_door": {"ffmpeg": {"inputs": [inputs[1]]}}}},
)
self.assertEqual(resp.status_code, 200)
raw, saved = self._read_config_file(config_path)
self.assertNotIn("*:*", raw)
self.assertEqual(
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"],
"rtsp://u:two@10.0.0.1:554/live",
)
finally:
os.unlink(config_path)
if __name__ == "__main__":
unittest.main()
@@ -33,6 +33,7 @@ class TestGo2rtcProxyAuth(BaseTestHttp):
self.minimal_config = _CONFIG
self.app = super().create_app()
self.app.jwt_token = OctKey.import_key(b"0" * 32)
self.app.user_sessions = {"admin": ("admin", None), "viewer": ("viewer", None)}
get_patch = patch(
"frigate.api.auth.requests.get",
+1 -93
View File
@@ -1,13 +1,8 @@
"""Test camera user and password cleanup."""
import time
import unittest
from frigate.util.builtin import (
clean_camera_user_pass,
escape_special_characters,
is_masked_camera_path,
)
from frigate.util.builtin import clean_camera_user_pass, escape_special_characters
class TestUserPassCleanup(unittest.TestCase):
@@ -50,90 +45,3 @@ class TestUserPassMasking(unittest.TestCase):
escaped = clean_camera_user_pass(self.rtsp_log_message)
print(f"The escaped is {escaped}")
assert escaped == "Did you mean file:rtsp://*:*@192.168.1.3:554"
def test_masks_userinfo_and_credential_query_forms(self):
cases = {
"rtsp://first.last@mail.com:p%40w@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
"rtsp://user+tag:pw@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
"rtsp://:pw@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
"tapo://cloudpassword@10.0.0.2": "tapo://*:*@10.0.0.2",
"rtsp://u:p@ss@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
"rtsp://user:pa/ss@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
"rtsp://u:p@ss#x@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
"rtsp://admin:2024/pw@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x",
"rtsp://admin:12#abc@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x",
"rtsp://admin:7?x@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x",
"rtsps://u:1/2@h/x": "rtsps://*:*@h/x",
"rtsp://admin:pre&token=suf@10.0.0.2/x": "rtsp://*:*@",
"rtsp://admin:a?pwd=b@10.0.0.2/x": "rtsp://*:*@",
"rtsp://10.0.0.2:554/live?password=pre@suf": "rtsp://*:*@",
"http://10.0.0.2:88/cgi?cmd=snap&usr=admin&pwd=P@ss": "http://*:*@",
"ffmpeg:http://h:80/flv?user=u&password=p@ss#video=copy": (
"ffmpeg:http://*:*@"
),
"rtsp://u:p@10.0.0.2:554/live?token=t": "rtsp://*:*@10.0.0.2:554/live?token=*",
"http://h/x?api_key=a&apikey=b&access_token=c": (
"http://h/x?api_key=*&apikey=*&access_token=*"
),
"http://h/cgi?user=admin&password=x": "http://h/cgi?user=*&password=*",
"http://h/cgi?password=x&user=admin&channel=1": (
"http://h/cgi?password=*&user=*&channel=1"
),
"http://h/x?usr=a&pwd=b": "http://h/x?usr=*&pwd=*",
"http://h/x?username=a&password=b": "http://h/x?username=*&password=*",
"rtsp://host/live?user=admin&password=p@ss": (
"rtsp://host/live?user=*&password=*"
),
"ring:?refresh_token=abc.def": "ring:?refresh_token=*",
"nest:?client_id=1&client_secret=s&refresh_token=t&project_id=p": (
"nest:?client_id=1&client_secret=*&refresh_token=*&project_id=p"
),
"homekit://10.0.0.2:51827?client_id=c&client_private=k&device_id=d": (
"homekit://10.0.0.2:51827?client_id=c&client_private=*&device_id=d"
),
"exec:ffmpeg -i rtsp://u:p@h/s -c copy -f rtsp {output}": (
"exec:ffmpeg -i rtsp://*:*@h/s -c copy -f rtsp {output}"
),
"ffmpeg -i rtsp://u:p@h/1 -i http://h/snap?user=a&password=b -f null": (
"ffmpeg -i rtsp://*:*@h/1 -i http://h/snap?user=*&password=* -f null"
),
}
for line, expected in cases.items():
with self.subTest(line=line):
masked = clean_camera_user_pass(line)
self.assertEqual(masked, expected)
self.assertTrue(is_masked_camera_path(masked))
self.assertEqual(clean_camera_user_pass(masked), masked)
def test_safe_strings_unchanged(self):
for line in (
"rtsp://10.0.0.2:554/live",
"ffmpeg:front_door#video=copy",
"rtsp://127.0.0.1:8554/front_door?video=copy&audio=aac",
"homekit://10.0.0.2:51827?device_id=d",
"http://10.0.0.1/snap.jpg?email=me@x.com",
"http://h/x?auth=a&author=b&authorization=c&user_agent=d&autoken=e",
"http://h/x?private=1&passthrough=1",
"Camera front_door is not responding",
):
with self.subTest(line=line):
self.assertEqual(clean_camera_user_pass(line), line)
self.assertFalse(is_masked_camera_path(line))
def test_commas_outside_credentials_survive(self):
"""ffprobe splits multiple paths on commas left after masking."""
for line in (
"rtsp://a/x,rtsp://u:p@b/y",
"rtsp://a:554/x,rtsp://u:p@b/y",
):
with self.subTest(line=line):
self.assertIn(",", clean_camera_user_pass(line))
self.assertNotIn(",", clean_camera_user_pass("rtsp://u:p,q@b/y"))
def test_long_line_masks_in_linear_time(self):
"""Log lines run through the mask, so a long one must stay fast."""
start = time.perf_counter()
clean_camera_user_pass("rtsp://u:p" * 20000)
clean_camera_user_pass("rtsp://" + "a." * 40000)
self.assertLess(time.perf_counter() - start, 1)
+1 -215
View File
@@ -1,13 +1,12 @@
"""Tests for the device each model runner reports after loading."""
import os
import sys
import tempfile
import threading
import unittest
from unittest.mock import MagicMock, patch
from frigate.detectors import detection_runners, hardware
from frigate.detectors import detection_runners
from frigate.detectors.detection_runners import (
CudaGraphRunner,
ONNXModelRunner,
@@ -18,7 +17,6 @@ from frigate.detectors.detection_runners import (
record_loaded_device,
snapshot_loaded_devices,
)
from frigate.util import model as model_util
class TestRunnerDeviceName(unittest.TestCase):
@@ -319,215 +317,3 @@ class TestLighterANE(unittest.TestCase):
get_optimized_runner("/models/arcface.onnx", "CPU", "arcface")
ane.assert_not_called()
class TestMIGraphX(unittest.TestCase):
"""AMD's MIGraphX plugin provider, selected through its devices in the ROCm image."""
def setUp(self):
loaded_devices.clear()
self.root = tempfile.TemporaryDirectory()
self.addCleanup(self.root.cleanup)
for patcher in (
patch.object(hardware, "detect_amd_gpu", return_value=MagicMock()),
patch.object(detection_runners, "MODEL_CACHE_DIR", self.root.name),
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
patch.object(detection_runners, "get_lighter_ane_devices", return_value=[]),
):
patcher.start()
self.addCleanup(patcher.stop)
self.plugin = MagicMock()
self.plugin.get_library_path.return_value = "/site/libmigraphx-ep.so"
def _device(self) -> MagicMock:
device = MagicMock()
device.ep_name = "MIGraphXExecutionProvider"
return device
def test_no_devices_outside_the_rocm_image(self):
with (
patch.dict(sys.modules, {"onnxruntime_ep_migraphx": None}),
patch.object(
detection_runners.ort, "register_execution_provider_library"
) as register,
):
self.assertEqual(detection_runners.get_migraphx_devices(), [])
self.assertEqual(detection_runners.get_migraphx_devices(), [])
register.assert_not_called()
def test_no_devices_without_an_amd_gpu(self):
with (
patch.dict(sys.modules, {"onnxruntime_ep_migraphx": self.plugin}),
patch.object(hardware, "detect_amd_gpu", return_value=None),
patch.object(detection_runners.ort, "get_ep_devices") as get_ep_devices,
patch.object(
detection_runners.ort, "register_execution_provider_library"
) as register,
):
self.assertEqual(detection_runners.get_migraphx_devices(), [])
get_ep_devices.assert_not_called()
register.assert_not_called()
def test_the_provider_is_registered_once(self):
device = self._device()
other = MagicMock()
other.ep_name = "CPUExecutionProvider"
with (
patch.dict(sys.modules, {"onnxruntime_ep_migraphx": self.plugin}),
patch.object(
detection_runners.ort,
"get_ep_devices",
side_effect=[[other], [other, device], [other, device]],
),
patch.object(
detection_runners.ort, "register_execution_provider_library"
) as register,
):
self.assertEqual(detection_runners.get_migraphx_devices(), [device])
self.assertEqual(detection_runners.get_migraphx_devices(), [device])
register.assert_called_once_with(
"MIGraphXExecutionProvider", "/site/libmigraphx-ep.so"
)
def test_a_provider_that_will_not_load_is_skipped(self):
with (
patch.dict(sys.modules, {"onnxruntime_ep_migraphx": self.plugin}),
patch.object(detection_runners.ort, "get_ep_devices", return_value=[]),
patch.object(
detection_runners.ort,
"register_execution_provider_library",
side_effect=RuntimeError("libamdhip64.so.7: cannot open shared object"),
),
self.assertLogs(detection_runners.logger, level="WARNING"),
):
self.assertEqual(detection_runners.get_migraphx_devices(), [])
def test_the_session_runs_on_the_first_gpu(self):
device = self._device()
session = MagicMock()
session.get_providers.return_value = [
"MIGraphXExecutionProvider",
"CPUExecutionProvider",
]
options = MagicMock()
with (
patch.object(
detection_runners,
"get_migraphx_devices",
return_value=[device, self._device()],
),
patch.object(
detection_runners, "get_ort_session_options", return_value=options
),
patch.object(
detection_runners.ort, "InferenceSession", return_value=session
) as inference_session,
):
runner = get_optimized_runner("/models/yolo.onnx", "AUTO", "yolo-generic")
cache_dir = os.path.join(self.root.name, "migraphx/ort")
options.add_provider_for_devices.assert_called_once_with(
[device], {"cache_dir": cache_dir}
)
inference_session.assert_called_once_with(
"/models/yolo.onnx", sess_options=options
)
self.assertTrue(os.path.isdir(cache_dir))
self.assertIsInstance(runner, ONNXModelRunner)
self.assertEqual(
loaded_devices["/models/yolo.onnx"], ("yolo-generic", "MIGraphX")
)
def test_a_model_migraphx_cannot_load_uses_the_default_providers(self):
session = MagicMock()
session.get_providers.return_value = ["CPUExecutionProvider"]
with (
patch.object(
detection_runners,
"get_migraphx_devices",
return_value=[self._device()],
),
patch.object(
detection_runners,
"get_ort_providers",
return_value=(["CPUExecutionProvider"], [{}]),
),
patch.object(
detection_runners, "is_openvino_gpu_npu_available", return_value=False
),
patch.object(
detection_runners.ort,
"InferenceSession",
side_effect=[RuntimeError("compile failed"), session],
) as inference_session,
patch.object(
detection_runners, "get_ort_session_options", return_value=MagicMock()
),
self.assertLogs(detection_runners.logger, level="WARNING"),
):
runner = get_optimized_runner("/models/arcface.onnx", "AUTO", "arcface")
self.assertEqual(inference_session.call_count, 2)
self.assertIsInstance(runner, ONNXModelRunner)
self.assertEqual(loaded_devices["/models/arcface.onnx"], ("arcface", "CPU"))
def test_unsupported_models_and_cpu_models_skip_migraphx(self):
for model_path, device, model_type in [
("/models/paddleocr.onnx", "AUTO", "paddleocr"),
("/models/dfine.onnx", "AUTO", "dfine"),
("/models/yolo.onnx", "CPU", "yolo-generic"),
]:
session = MagicMock()
session.get_providers.return_value = ["CPUExecutionProvider"]
with (
self.subTest(model_type=model_type, device=device),
patch.object(
detection_runners,
"get_migraphx_devices",
return_value=[self._device()],
) as migraphx,
patch.object(
detection_runners,
"get_ort_providers",
return_value=(["CPUExecutionProvider"], [{}]),
),
patch.object(
detection_runners,
"is_openvino_gpu_npu_available",
return_value=False,
),
patch.object(
detection_runners.ort, "InferenceSession", return_value=session
),
patch.object(
detection_runners, "get_ort_session_options", return_value=None
),
):
get_optimized_runner(model_path, device, model_type)
migraphx.assert_not_called()
self.assertEqual(loaded_devices[model_path], (model_type, "CPU"))
def test_migraphx_is_never_requested_by_name(self):
with patch.object(
model_util.ort,
"get_available_providers",
return_value=[
"AzureExecutionProvider",
"CPUExecutionProvider",
"MIGraphXExecutionProvider",
],
):
providers, options = model_util.get_ort_providers()
self.assertEqual(providers, ["CPUExecutionProvider"])
self.assertEqual(options, [{"enable_cpu_mem_arena": False}])
+4 -22
View File
@@ -21,11 +21,8 @@ import numpy as np
from ruamel.yaml import YAML
from frigate.const import (
REGEX_CREDENTIAL_QUERY_KEY,
REGEX_CREDENTIAL_QUERY_PARAM,
REGEX_MASKED_CREDENTIAL,
REGEX_HTTP_CAMERA_USER_PASS,
REGEX_RTSP_CAMERA_USER_PASS,
REGEX_URL_USERINFO,
STREAM_TYPE_MAIN,
STREAM_TYPE_SUB,
)
@@ -120,24 +117,9 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic
def clean_camera_user_pass(line: str) -> str:
"""Mask URL userinfo and credential query parameters in line."""
line = re.sub(REGEX_URL_USERINFO, _mask_userinfo, line)
return re.sub(REGEX_CREDENTIAL_QUERY_PARAM, r"\1*", line)
def _mask_userinfo(match: re.Match[str]) -> str:
# A credential key in the userinfo means a query password holds "@" or a
# password holds a query key. Neither split is safe, so mask the whole URL.
if re.search(REGEX_CREDENTIAL_QUERY_KEY, match.group(1)):
return "://*:*@"
return f"://*:*@{match.group(2)}"
def is_masked_camera_path(path: str) -> bool:
"""Return True if path holds credentials masked by clean_camera_user_pass."""
return re.search(REGEX_MASKED_CREDENTIAL, path) is not None
"""Removes user and password from line."""
rtsp_cleaned = re.sub(REGEX_RTSP_CAMERA_USER_PASS, "://*:*@", line)
return re.sub(REGEX_HTTP_CAMERA_USER_PASS, "user=*&password=*", rtsp_cleaned)
def escape_special_characters(path: str) -> str:
+9 -3
View File
@@ -385,9 +385,15 @@ def get_ort_providers(
}
)
elif provider == "MIGraphXExecutionProvider":
# MIGraphX is a plugin provider, which ONNX Runtime can't create by
# name, so get_optimized_runner selects it through its devices
continue
migraphx_cache_dir = os.path.join(MODEL_CACHE_DIR, "migraphx")
os.makedirs(migraphx_cache_dir, exist_ok=True)
providers.append(provider)
options.append(
{
"migraphx_model_cache_dir": migraphx_cache_dir,
}
)
elif provider == "CPUExecutionProvider":
providers.append(provider)
options.append(
File diff suppressed because one or more lines are too long
@@ -32,11 +32,7 @@ type CameraInput = {
input_args?: string;
};
async function installRoutes(
page: Page,
frontDoorInputs: CameraInput[],
rawInputs: CameraInput[] = frontDoorInputs,
) {
async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) {
const config = configFactory({
go2rtc: { streams: GO2RTC_STREAMS },
cameras: {
@@ -60,7 +56,7 @@ async function installRoutes(
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({
json: {
cameras: { front_door: { ffmpeg: { inputs: rawInputs } } },
cameras: { front_door: { ffmpeg: { inputs: frontDoorInputs } } },
go2rtc: { streams: GO2RTC_STREAMS },
},
}),
@@ -204,64 +200,4 @@ test.describe("camera ffmpeg input source toggle @medium", () => {
payload?.config_data?.cameras?.front_door?.ffmpeg?.inputs?.[0];
expect(input?.input_args).not.toBe("preset-rtsp-restream");
});
test("a masked path reveals its own saved path after an earlier input is deleted", async ({
frigateApp,
}) => {
const main = "rtsp://admin:mainpw@10.0.0.1:554/main";
const sub = "rtsp://admin:subpw@10.0.0.1:554/sub";
await installRoutes(
frigateApp.page,
[
{ path: "rtsp://*:*@10.0.0.1:554/main", roles: ["record"] },
{ path: "rtsp://*:*@10.0.0.1:554/sub", roles: ["detect"] },
],
[
{ path: main, roles: ["record"] },
{ path: sub, roles: ["detect"] },
],
);
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
const paths = frigateApp.page.getByRole("textbox", { name: "Input path" });
await expect(paths).toHaveCount(2);
await frigateApp.page
.getByRole("button", { name: "Delete" })
.first()
.click();
await expect(paths).toHaveCount(1);
await paths.focus();
await expect(paths).toHaveValue(sub);
});
test("inputs with the same masked path each reveal their own path by role", async ({
frigateApp,
}) => {
const record = "rtsp://admin:one@10.0.0.1:554/live";
const detect = "rtsp://viewer:two@10.0.0.1:554/live";
const masked = "rtsp://*:*@10.0.0.1:554/live";
await installRoutes(
frigateApp.page,
[
{ path: masked, roles: ["record"] },
{ path: masked, roles: ["detect"] },
],
[
{ path: record, roles: ["record"] },
{ path: detect, roles: ["detect"] },
],
);
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
const paths = frigateApp.page.getByRole("textbox", { name: "Input path" });
await expect(paths).toHaveCount(2);
await paths.nth(0).focus();
await expect(paths.nth(0)).toHaveValue(record);
await paths.nth(1).focus();
await expect(paths.nth(1)).toHaveValue(detect);
});
});
+426 -305
View File
File diff suppressed because it is too large Load Diff
+23 -22
View File
@@ -23,28 +23,28 @@
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.2",
"@hookform/resolvers": "^5.9.1",
"@radix-ui/react-alert-dialog": "^1.2.1",
"@radix-ui/react-aspect-ratio": "^1.2.0",
"@radix-ui/react-checkbox": "^1.4.1",
"@radix-ui/react-collapsible": "^1.2.1",
"@radix-ui/react-context-menu": "^2.4.1",
"@radix-ui/react-alert-dialog": "^1.1.24",
"@radix-ui/react-aspect-ratio": "^1.1.16",
"@radix-ui/react-checkbox": "^1.3.12",
"@radix-ui/react-collapsible": "^1.1.21",
"@radix-ui/react-context-menu": "^2.3.8",
"@radix-ui/react-dialog": "^1.2.0",
"@radix-ui/react-dropdown-menu": "^2.2.1",
"@radix-ui/react-hover-card": "^1.2.1",
"@radix-ui/react-label": "^2.1.17",
"@radix-ui/react-popover": "^1.3.1",
"@radix-ui/react-progress": "^1.2.1",
"@radix-ui/react-radio-group": "^1.5.1",
"@radix-ui/react-scroll-area": "^1.4.0",
"@radix-ui/react-select": "^2.4.1",
"@radix-ui/react-separator": "^1.2.0",
"@radix-ui/react-slider": "^1.6.1",
"@radix-ui/react-slot": "1.5.0",
"@radix-ui/react-switch": "^1.4.1",
"@radix-ui/react-tabs": "^1.2.1",
"@radix-ui/react-toggle": "^1.2.1",
"@radix-ui/react-toggle-group": "^1.2.1",
"@radix-ui/react-tooltip": "^1.4.1",
"@radix-ui/react-dropdown-menu": "^2.1.25",
"@radix-ui/react-hover-card": "^1.1.24",
"@radix-ui/react-label": "^2.1.16",
"@radix-ui/react-popover": "^1.2.0",
"@radix-ui/react-progress": "^1.1.17",
"@radix-ui/react-radio-group": "^1.4.8",
"@radix-ui/react-scroll-area": "^1.3.0",
"@radix-ui/react-select": "^2.3.8",
"@radix-ui/react-separator": "^1.1.16",
"@radix-ui/react-slider": "^1.5.0",
"@radix-ui/react-slot": "1.4.0",
"@radix-ui/react-switch": "^1.3.8",
"@radix-ui/react-tabs": "^1.1.22",
"@radix-ui/react-toggle": "^1.1.19",
"@radix-ui/react-toggle-group": "^1.1.20",
"@radix-ui/react-tooltip": "^1.3.0",
"@rjsf/core": "^6.10.1",
"@rjsf/shadcn": "^6.10.1",
"@rjsf/utils": "^6.10.1",
@@ -127,6 +127,7 @@
},
"overrides": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-slot": "1.5.0"
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-slot": "1.4.0"
}
}
+1 -1
View File
@@ -40,7 +40,7 @@
},
"cookie_secure": {
"label": "Secure cookie flag",
"description": "Set the secure flag on the auth cookie; should be true when using TLS."
"description": "Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS."
},
"session_length": {
"label": "Session length",
@@ -1,7 +1,5 @@
import type { WidgetProps } from "@rjsf/utils";
import useSWR from "swr";
import get from "lodash/get";
import isEqual from "lodash/isEqual";
import { useMemo, useState, type FocusEvent } from "react";
import { useTranslation } from "react-i18next";
import { LuEye, LuEyeOff } from "react-icons/lu";
@@ -23,7 +21,6 @@ type RawPathsResponse = {
ffmpeg?: {
inputs?: Array<{
path?: string;
roles?: string[];
}>;
};
}
@@ -69,35 +66,17 @@ export function CameraPathWidget(props: WidgetProps) {
shouldFetchRawPaths ? "config/raw_paths" : null,
);
const rawValue = typeof value === "string" ? value : "";
const roles =
inputIndex === undefined
? undefined
: get(formContext?.formData, ["inputs", inputIndex, "roles"]);
// Match by masked value with roles breaking a tie, as the backend restores
// on save, so deleting or reordering inputs never reveals another input's path
const rawPath = useMemo(() => {
if (!cameraName || !isMaskedPath(rawValue)) {
if (!cameraName || inputIndex === undefined) {
return undefined;
}
let matches = (
rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs ?? []
).filter(
(input): input is { path: string; roles?: string[] } =>
typeof input.path === "string" &&
maskCredentials(input.path) === rawValue,
);
if (new Set(matches.map((input) => input.path)).size > 1) {
matches = matches.filter((input) => isEqual(input.roles, roles));
}
const paths = new Set(matches.map((input) => input.path));
return paths.size === 1 ? [...paths][0] : undefined;
}, [cameraName, rawPaths, rawValue, roles]);
const path =
rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs?.[inputIndex]?.path;
return typeof path === "string" ? path : undefined;
}, [cameraName, inputIndex, rawPaths]);
const rawValue = typeof value === "string" ? value : "";
const resolvedValue =
isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? "");
const canReveal =
@@ -210,7 +210,7 @@ export function VideoPreview({
}, [playerRef]);
const onProgressHover = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
(event: React.MouseEvent<HTMLDivElement>) => {
if (!sliderRef.current) {
return;
}
@@ -401,7 +401,7 @@ export function InProgressPreview({
);
const onProgressHover = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
(event: React.MouseEvent<HTMLDivElement>) => {
if (!sliderRef.current || !previewFrames) {
return;
}
+31 -28
View File
@@ -1,37 +1,40 @@
// Mirrors clean_camera_user_pass and REGEX_MASKED_CREDENTIAL in the backend.
const CREDENTIAL_QUERY_KEYS =
"(?:user(?:name)?|usr|login|pass(?:word|wd)?|pwd|(?:[a-z_]*_)?token|(?:[a-z_]*_)?secret|client_private|api_?key)";
const URL_USERINFO_PATTERN =
/:\/\/((?:[^\s/?#:[\]]*:(?:(?!:\/\/)\S)*|[^\s/?#]+))@((?:(?!:\/\/)\S)*)/g;
const CREDENTIAL_QUERY_KEY_PATTERN = new RegExp(
`[?&]${CREDENTIAL_QUERY_KEYS}=`,
"i",
);
const CREDENTIAL_QUERY_PATTERN = new RegExp(
`([?&]${CREDENTIAL_QUERY_KEYS}=)[^&\\s]*`,
"gi",
);
const MASKED_PATTERN = new RegExp(
`://\\*:\\*@|[?&]${CREDENTIAL_QUERY_KEYS}=\\*(?![^&\\s])`,
"i",
);
const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i;
const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i;
export const isMaskedPath = (value: string): boolean =>
MASKED_PATTERN.test(value);
MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value);
export const hasCredentials = (value: string): boolean => {
if (!value) {
return false;
}
if (isMaskedPath(value)) {
return true;
}
try {
const parsed = new URL(value);
if (parsed.username || parsed.password) {
return true;
}
return (
parsed.searchParams.has("user") && parsed.searchParams.has("password")
);
} catch {
return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value);
}
};
export const maskCredentials = (value: string): string => {
if (!value) {
return value;
}
return value
.replace(URL_USERINFO_PATTERN, (_match, userinfo: string, rest: string) =>
CREDENTIAL_QUERY_KEY_PATTERN.test(userinfo)
? "://*:*@"
: `://*:*@${rest}`,
)
.replace(CREDENTIAL_QUERY_PATTERN, "$1*");
};
const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@");
export const hasCredentials = (value: string): boolean =>
!!value && (isMaskedPath(value) || maskCredentials(value) !== value);
return maskedAuth
.replace(/([?&]user=)[^&]*/gi, "$1*")
.replace(/([?&]password=)[^&]*/gi, "$1*");
};