From dcda458a8277939a124f677e2312d91e60f74dc8 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:50:03 -0500 Subject: [PATCH] Container security hardening (phase 1) (#24061) * Verify s6-overlay downloads against pinned checksums * Verify go2rtc download against pinned checksums The v1.9.14 release publishes no checksums file, just the bare per-platform binaries, so these digests come from a one-time fetch rather than upstream. That pins the artifact against later substitution, which is the realistic threat for a version we stay on for months, but it does not verify the original download. The stage moves from `ADD --link` to a script because `ADD --checksum` can't express an architecture-dependent URL. * Verify main image downloads against pinned checksums Covers everything the main image downloads on the default path: tempio, the hailort runtime tarball and wheel, the six ffmpeg builds, the libedgetpu deb, and the thirteen Intel driver debs. The hailort tarball was streamed straight into `tar`, which can't be verified before extraction, so it downloads to `/tmp` first. The three ffmpeg blocks per arch collapse into one `install_ffmpeg` helper since they only differed by URL and install dir, and the Intel debs go through a `fetch_intel_deb` helper for the same reason. The Intel debs are the ones that mattered most here. They're installed as root with `dpkg` on the default amd64 path and had no verification at all. compute-runtime publishes a `ww.sum` asset with every release and npu-driver published `checksum.sha256` on v1.19.0, so those eight digests came from upstream rather than from us. intel-graphics-compiler and level-zero publish none, so those five and everything else here come from a one-time fetch, which pins the artifact against later substitution but doesn't verify the original download. The comment above the map says which is which and how to refresh them, since npu-driver has stopped publishing sums since v1.19.0 and that provenance won't survive the next bump. Still unpinned: `get-pip.py`, which is a rolling URL where a digest would just break the build on pypa's next edit, and the per-variant artifacts for Axera, Synaptics, and Jetson. apt repositories are out of scope since apt already verifies signatures. * Restrict generated TLS key permissions OpenSSL 3.x already writes the key at 600 on its own, so this pins the guarantee rather than fixing an observed leak: the mode no longer depends on the openssl version or the umask the service happens to start with. Only the generated pair is touched. User-mounted certs take the other branch and are never chmod'd, which matters when they're mounted read-only. * Add security headers and server_tokens off Adds `X-Content-Type-Options: nosniff` and `Referrer-Policy: strict-origin-when-cross-origin`, and turns off nginx version disclosure. No `X-Frame-Options` and no CSP `frame-ancestors`. HA's Webpage card and iframe panels frame Frigate's own address cross-origin, and either header would break them silently with nothing in Frigate's logs to explain it. Ingress is same-origin and would survive `SAMEORIGIN`, but Frigate can't tell the two apart from inside the container. `security_headers.conf` is a plain file in the image rather than a generated one, so anyone who does want framing restrictions can bind-mount it. `add_header` doesn't inherit into a block that declares its own, so the include goes in per block, all nine of them, including the four nested static-asset locations that serve the JS bundles. Those are the ones nosniff actually matters for. The run script now reads `get_nginx_settings.py` once into a variable instead of shelling out per template. That script imports the frigate config machinery, which is noticeable on an SBC. Not fixed here: `listen.conf` is included at server level and carries `Strict-Transport-Security`, so those same nine blocks already drop HSTS under TLS today. Folding it into this file would change existing TLS behavior on nine paths, so it needs its own PR. * Restrict go2rtc config file permissions * Log failed login attempts with source address Failed logins returned a bare 401 and left nothing behind, so credential stuffing was invisible unless you were already watching nginx access logs. Both failure branches now log a warning with the attempted username and the client address. The address comes from `get_remote_addr()`, the same helper the login rate limiter keys on, so the two agree on who the client is and the trusted-proxy handling is consistent. Logging a raw `x-forwarded-for` instead would let an attacker forge the source address in the very log line meant to catch them. The response is unchanged and identical either way. Which factor failed is only visible in the log, never to the client, and the password is never logged. * Recommend least-privilege container options in install docs The compose generator pushed `privileged: true` into every file it produced, no matter what hardware you picked, and it's the default tab on the install page so it's what most people copy. It now emits `security_opt: no-new-privileges:true` instead, and only adds `privileged: true` for hardware that actually needs it, with the reason inline. MemryX is the only one today, since it needs to reach the max-manager. Rockchip and Synaptics only want privileged during initial setup and their documented end state is device mappings, so neither gets it. `no-new-privileges` merges into the same `security_opt` block as any device-specific entries, so Rockchip still gets its `apparmor=unconfined` and `systempaths=unconfined` without a duplicate key. The static example now has `privileged` commented out, and there's a short section on the options worth adding, with a note that `cap_drop: ALL` breaks `telemetry.stats.network_bandwidth` since nethogs needs NET_ADMIN/NET_RAW. * Add amd64 container smoke test to CI Boots the built amd64 image against a minimal config and asserts the two security headers, that the Server header no longer carries a version, that no frame-ancestors is present, that nginx accepts its own config, and the two file modes. This is also the harness the rest of the hardening work extends. The two negative assertions are written as `if grep; then exit 1; fi` rather than `! grep`. Bash exempts a negated command from `set -e`, so the `!` form would have passed even with the version and frame-ancestors both present, which is the opposite of what a regression net is for. --- .github/workflows/ci.yml | 55 +++++++++ docker/main/Dockerfile | 6 +- docker/main/install_deps.sh | 114 ++++++++++++------ docker/main/install_go2rtc.sh | 19 +++ docker/main/install_hailort.sh | 22 +++- docker/main/install_s6_overlay.sh | 24 +++- docker/main/install_tempio.sh | 9 ++ .../rootfs/etc/s6-overlay/s6-rc.d/nginx/run | 9 +- .../rootfs/usr/local/go2rtc/create_config.py | 3 + .../rootfs/usr/local/nginx/conf/nginx.conf | 11 ++ .../local/nginx/conf/security_headers.conf | 5 + .../hardware_acceleration_video.md | 5 +- docs/docs/frigate/installation.md | 29 ++++- .../DockerComposeGenerator/config/config.yaml | 2 + .../DockerComposeGenerator/config/types.ts | 8 ++ .../DockerComposeGenerator/generator/index.ts | 29 ++++- frigate/api/auth.py | 7 ++ frigate/test/http_api/test_http_auth.py | 45 +++++++ 18 files changed, 348 insertions(+), 54 deletions(-) create mode 100755 docker/main/install_go2rtc.sh create mode 100644 docker/main/rootfs/usr/local/nginx/conf/security_headers.conf create mode 100644 frigate/test/http_api/test_http_auth.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41080be5d9..398bc9e09f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,61 @@ jobs: tags: ${{ steps.setup.outputs.image-name }}-amd64 cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64 cache-to: type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64,mode=max + smoke_test: + runs-on: ubuntu-22.04 + name: AMD64 Smoke Test + needs: + - amd64_build + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Set up QEMU and Buildx + id: setup + uses: ./.github/actions/setup + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Start container + run: | + mkdir -p /tmp/frigate-config + printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config/config.yml + docker run -d --name frigate --shm-size 256m \ + -v /tmp/frigate-config:/config \ + -p 5000:5000 -p 8971:8971 \ + ${{ steps.setup.outputs.image-name }}-amd64 + - name: Wait for API + run: | + for i in $(seq 1 60); do + curl -fs http://127.0.0.1:5000/api/version && exit 0 + sleep 5 + done + echo "API never came up"; docker logs frigate; exit 1 + - name: Assert security headers and permissions + run: | + headers=$(curl -ksI https://127.0.0.1:8971/) + echo "$headers" + echo "$headers" | grep -qi "x-content-type-options: nosniff" + echo "$headers" | grep -qi "referrer-policy: strict-origin-when-cross-origin" + # server_tokens off: Server header must not include a version. + # written as an if rather than "! grep", because bash exempts a + # negated command from set -e and the assertion would never fail + if echo "$headers" | grep -qiE "^server: nginx/[0-9]"; then + echo "Server header leaks the nginx version; server_tokens is not off" + exit 1 + fi + # Frigate never ships frame-ancestors: HA's Webpage card and iframe + # panels frame it cross-origin and it would break them silently + if echo "$headers" | grep -qi "frame-ancestors"; then + echo "response carries frame-ancestors, which breaks cross-origin iframe embedding" + exit 1 + fi + docker exec frigate /usr/local/nginx/sbin/nginx -t + docker exec frigate stat -c %a /etc/letsencrypt/live/frigate/privkey.pem | grep -qx 600 + docker exec frigate stat -c %a /dev/shm/go2rtc.yaml | grep -qx 640 + - name: Teardown + if: always() + run: docker rm -f frigate || true arm64_build: runs-on: ubuntu-22.04-arm name: ARM Build diff --git a/docker/main/Dockerfile b/docker/main/Dockerfile index bf17146e30..095eec91af 100644 --- a/docker/main/Dockerfile +++ b/docker/main/Dockerfile @@ -60,10 +60,10 @@ ARG DEBIAN_FRONTEND RUN --mount=type=bind,source=docker/main/build_intel_media_driver.sh,target=/deps/build_intel_media_driver.sh \ /deps/build_intel_media_driver.sh -FROM scratch AS go2rtc +FROM wget AS go2rtc ARG TARGETARCH -WORKDIR /rootfs/usr/local/go2rtc/bin -ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.14/go2rtc_linux_${TARGETARCH}" go2rtc +RUN --mount=type=bind,source=docker/main/install_go2rtc.sh,target=/deps/install_go2rtc.sh \ + /deps/install_go2rtc.sh FROM wget AS tempio ARG TARGETARCH diff --git a/docker/main/install_deps.sh b/docker/main/install_deps.sh index e197ce1b68..b66d914f00 100755 --- a/docker/main/install_deps.sh +++ b/docker/main/install_deps.sh @@ -28,7 +28,13 @@ update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 mkdir -p -m 600 /root/.gnupg # install coral runtime +# sha256 digests of the release debs; update when bumping the libedgetpu release. +declare -A edgetpu_checksums=( + ["amd64"]="63fd00989d29160fa9894e115156a9abe456e88751fc9be89d26e4696200441b" + ["arm64"]="eab8aa4576b4dbf738135d8094f32270b24117f77147d25cbe0f49d0144d85f2" +) wget -q -O /tmp/libedgetpu1-max.deb "https://github.com/feranick/libedgetpu/releases/download/16.0TF2.17.1-1/libedgetpu1-max_16.0tf2.17.1-1.bookworm_${TARGETARCH}.deb" +echo "${edgetpu_checksums[${TARGETARCH}]} /tmp/libedgetpu1-max.deb" | sha256sum -c - unset DEBIAN_FRONTEND yes | dpkg -i /tmp/libedgetpu1-max.deb && export DEBIAN_FRONTEND=noninteractive rm /tmp/libedgetpu1-max.deb @@ -45,36 +51,41 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then fi fi +# sha256 digests of the ffmpeg builds, keyed "-". +# Upstream publishes no checksums; these come from a one-time fetch and guard +# against later substitution. Update when bumping a build URL. +declare -A ffmpeg_checksums=( + ["5.0-amd64"]="377abec133f9d9e8014dee1b91c9684ac8bb0b5b7d80100a57116ff837c4c0d4" + ["7.0-amd64"]="e13860eb90409c8218319c928067834ce450128e86f24cfed5cfe91ce6e31037" + ["8.0-amd64"]="9bac85054d351cdc89c0a4f45c8ea5c44df94009aabd964b719bbadd56aedae9" + ["5.0-arm64"]="57ee475407bad49910ba9b946428396e30cf075ea28a7912fbe1aa2578085af0" + ["7.0-arm64"]="16c8b04e9d0ea9c769ad964c4c453fcf05121a1947237329d2e9d8a5e43e2a3c" + ["8.0-arm64"]="cd91948468d0f11ce795a2cdaa0c69911bd1db313b49bb19c22512beb88cde69" +) + +# the tarballs nest their binaries under a directory named for the arch, which +# matches TARGETARCH for both builds we consume +install_ffmpeg() { + local dir="$1" url="$2" + mkdir -p "/usr/lib/ffmpeg/${dir}" + wget -qO ffmpeg.tar.xz "${url}" + echo "${ffmpeg_checksums[${dir}-${TARGETARCH}]} ffmpeg.tar.xz" | sha256sum -c - + tar -xf ffmpeg.tar.xz -C "/usr/lib/ffmpeg/${dir}" --strip-components 1 "${TARGETARCH}/bin/ffmpeg" "${TARGETARCH}/bin/ffprobe" + rm -f ffmpeg.tar.xz +} + # ffmpeg -> amd64 if [[ "${TARGETARCH}" == "amd64" ]]; then - mkdir -p /usr/lib/ffmpeg/5.0 - wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linux64-gpl-5.1.tar.xz" - tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe - rm -rf ffmpeg.tar.xz - mkdir -p /usr/lib/ffmpeg/7.0 - wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linux64-gpl-7.0.tar.xz" - tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe - rm -rf ffmpeg.tar.xz - mkdir -p /usr/lib/ffmpeg/8.0 - wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linux64-gpl-8.1.tar.xz" - tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/8.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe - rm -rf ffmpeg.tar.xz + install_ffmpeg 5.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linux64-gpl-5.1.tar.xz" + install_ffmpeg 7.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linux64-gpl-7.0.tar.xz" + install_ffmpeg 8.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linux64-gpl-8.1.tar.xz" fi # ffmpeg -> arm64 if [[ "${TARGETARCH}" == "arm64" ]]; then - mkdir -p /usr/lib/ffmpeg/5.0 - wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linuxarm64-gpl-5.1.tar.xz" - tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe - rm -f ffmpeg.tar.xz - mkdir -p /usr/lib/ffmpeg/7.0 - wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linuxarm64-gpl-7.0.tar.xz" - tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe - rm -f ffmpeg.tar.xz - mkdir -p /usr/lib/ffmpeg/8.0 - wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linuxarm64-gpl-8.1.tar.xz" - tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/8.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe - rm -f ffmpeg.tar.xz + install_ffmpeg 5.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linuxarm64-gpl-5.1.tar.xz" + install_ffmpeg 7.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linuxarm64-gpl-7.0.tar.xz" + install_ffmpeg 8.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linuxarm64-gpl-8.1.tar.xz" fi # arch specific packages @@ -120,27 +131,56 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then apt-get -qq install -y libtbb12 # install legacy and standard intel compute packages + # sha256 digests of the driver debs, taken from the ww.sum asset + # compute-runtime ships per release and the checksum.sha256 on npu-driver + # v1.19.0; intel-graphics-compiler and level-zero publish none, so those + # five are hash-what-you-get. Refresh after a version bump with + # `curl -sL | sha256sum`, cross-checking upstream's sum where the + # release still has one. npu-driver stopped publishing them after v1.19.0. + declare -A intel_checksums=( + ["libigdgmm12_22.9.0_amd64.deb"]="9d712f71c18baee076de9961dda71e8089291e1bd0deb5d649ab5ba5de114f97" + ["intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb"]="bbe71e4f414259e06a10cde72c29a2bd78d41b2bb2f6f8463b1806797fe66e85" + ["intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb"]="40dfbd15ab62de036a00824b304a2aa1fa2d81ad60ef83da09cfe3c5a80c429f" + ["intel-igc-opencl_1.0.17537.24_amd64.deb"]="dd016400f87fa2b6a9fa9fbcca7eb4a2629174a29de679709f9bec5cede88b0e" + ["intel-igc-core_1.0.17537.24_amd64.deb"]="c1e1ecdfe2064c047c552651cfdcdafc504f2033afafba65654338b880048b67" + ["intel-opencl-icd_26.14.37833.4-0_amd64.deb"]="2e15eeb4fe9c1bba467a655967373eec6a20dd04cc7159de53c359f17ab53e41" + ["libze-intel-gpu1_26.14.37833.4-0_amd64.deb"]="34ce5791160d87ce6d54edb558a4030858ee1dad2afb067b9c5c58d4cde774c6" + ["intel-igc-opencl-2_2.32.7+21184_amd64.deb"]="3c9bddbfe558279402bbeaabcf9c63b8de46b956b0ad9625415fd35dda53ad52" + ["intel-igc-core-2_2.32.7+21184_amd64.deb"]="64e5230788e3a31e611e8d815a141b1facb91e5f0ef239233ef3f0614bfe3fd6" + ["level-zero_1.28.2+u22.04_amd64.deb"]="9015a579abef960166f8e943858d5c81fd4199a960f07260c1da66038257effb" + ["intel-driver-compiler-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb"]="8087bfcc0872d7976d0163203c7c783a4176f813c473766587e86c7b34135dff" + ["intel-fw-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb"]="740219c03495f8812c03ab74baf8199acf17d13929001105418d4ba226ba2290" + ["intel-level-zero-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb"]="f4f5eb97aa7da52c7fec97e4ddfb43aae01703bbadc767bae1f2d4faf342ba42" + ) + + fetch_intel_deb() { + local url="$1" name + name=$(basename "$url") + wget -q "$url" + echo "${intel_checksums[${name}]} ${name}" | sha256sum -c - + } + # see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info # needed core package - wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb + fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb dpkg -i libigdgmm12_22.9.0_amd64.deb rm libigdgmm12_22.9.0_amd64.deb # legacy compute-runtime packages - wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb - wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb - wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb - wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb + fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb + fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb + fetch_intel_deb https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb + fetch_intel_deb https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb # standard compute-runtime packages - wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb - wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libze-intel-gpu1_26.14.37833.4-0_amd64.deb - wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb - wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb + fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb + fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libze-intel-gpu1_26.14.37833.4-0_amd64.deb + fetch_intel_deb https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb + fetch_intel_deb https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb # npu packages - wget https://github.com/oneapi-src/level-zero/releases/download/v1.28.2/level-zero_1.28.2+u22.04_amd64.deb - wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-driver-compiler-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb - wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-fw-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb - wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-level-zero-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb + fetch_intel_deb https://github.com/oneapi-src/level-zero/releases/download/v1.28.2/level-zero_1.28.2+u22.04_amd64.deb + fetch_intel_deb https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-driver-compiler-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb + fetch_intel_deb https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-fw-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb + fetch_intel_deb https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-level-zero-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb dpkg -i *.deb rm *.deb diff --git a/docker/main/install_go2rtc.sh b/docker/main/install_go2rtc.sh new file mode 100755 index 0000000000..0912d43f0a --- /dev/null +++ b/docker/main/install_go2rtc.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -euxo pipefail + +go2rtc_version="1.9.14" + +# sha256 digests of the release binaries; update when bumping go2rtc_version. +declare -A go2rtc_checksums=( + ["amd64"]="32d616af226bd731678ffde328b94cfb94e30339bfefc469cfb76323144615a6" + ["arm64"]="359fabade8a7a51e81a55fe6df6b0ef81764a5e1d63179577534eaaa71904b50" +) + +dest_dir="/rootfs/usr/local/go2rtc/bin" +mkdir -p "${dest_dir}" + +wget -qO "${dest_dir}/go2rtc" \ + "https://github.com/AlexxIT/go2rtc/releases/download/v${go2rtc_version}/go2rtc_linux_${TARGETARCH}" +echo "${go2rtc_checksums[${TARGETARCH}]} ${dest_dir}/go2rtc" | sha256sum -c - +chmod 755 "${dest_dir}/go2rtc" diff --git a/docker/main/install_hailort.sh b/docker/main/install_hailort.sh index 2e568a14ed..efbc5fdbdc 100755 --- a/docker/main/install_hailort.sh +++ b/docker/main/install_hailort.sh @@ -4,11 +4,29 @@ set -euxo pipefail hailo_version="4.21.0" +# sha256 digests of the release artifacts; update when bumping hailo_version. +# The runtime tarball is keyed by TARGETARCH, the wheel by the python arch tag. +declare -A hailort_checksums=( + ["amd64"]="0a57ac5f7cc8c2c3668133189d9285b55f498e8cb219797e203f6f5015fec4b3" + ["arm64"]="dd840548eb5d0d147c99aee2cb013d39d64be09c5bc63061171fcfacf4547b3f" + ["x86_64"]="8112a973ab48095399b29d883f31987828df5861b8553f614c89f098a67b3fb6" + ["aarch64"]="658432a43573280d472f6402d7934669effe7f163ba3dffa31c50bbeeaa7c01d" +) + if [[ "${TARGETARCH}" == "amd64" ]]; then arch="x86_64" elif [[ "${TARGETARCH}" == "arm64" ]]; then arch="aarch64" fi -wget -qO- "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-debian12-${TARGETARCH}.tar.gz" | tar -C / -xzf - -wget -P /wheels/ "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl" +# downloaded rather than streamed into tar because streaming and verifying the +# digest before extraction are mutually exclusive +wget -qO /tmp/hailort.tar.gz "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-debian12-${TARGETARCH}.tar.gz" +echo "${hailort_checksums[${TARGETARCH}]} /tmp/hailort.tar.gz" | sha256sum -c - +tar -C / -xzf /tmp/hailort.tar.gz +rm -f /tmp/hailort.tar.gz + +wheel="/wheels/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl" +mkdir -p /wheels +wget -qO "${wheel}" "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl" +echo "${hailort_checksums[${arch}]} ${wheel}" | sha256sum -c - diff --git a/docker/main/install_s6_overlay.sh b/docker/main/install_s6_overlay.sh index 3ea387c9b2..34bbc465df 100755 --- a/docker/main/install_s6_overlay.sh +++ b/docker/main/install_s6_overlay.sh @@ -4,6 +4,15 @@ set -euxo pipefail s6_version="3.2.1.0" +# sha256 digests of the release artifacts, from the .sha256 files published at +# https://github.com/just-containers/s6-overlay/releases/tag/v3.2.1.0 +# Update these when bumping s6_version. +declare -A s6_checksums=( + ["noarch"]="42e038a9a00fc0fef70bf0bc42f625a9c14f8ecdfe77d4ad93281edf717e10c5" + ["x86_64"]="8bcbc2cada58426f976b159dcc4e06cbb1454d5f39252b3bb0c778ccf71c9435" + ["aarch64"]="c8fd6b1f0380d399422fc986a1e6799f6a287e2cfa24813ad0b6a4fb4fa755cc" +) + if [[ "${TARGETARCH}" == "amd64" ]]; then s6_arch="x86_64" elif [[ "${TARGETARCH}" == "arm64" ]]; then @@ -12,8 +21,15 @@ fi mkdir -p /rootfs/ -wget -qO- "https://github.com/just-containers/s6-overlay/releases/download/v${s6_version}/s6-overlay-noarch.tar.xz" | - tar -C /rootfs/ -Jxpf - +download_and_extract() { + local arch="$1" + local tarball="/tmp/s6-overlay-${arch}.tar.xz" + wget -qO "${tarball}" \ + "https://github.com/just-containers/s6-overlay/releases/download/v${s6_version}/s6-overlay-${arch}.tar.xz" + echo "${s6_checksums[${arch}]} ${tarball}" | sha256sum -c - + tar -C /rootfs/ -Jxpf "${tarball}" + rm -f "${tarball}" +} -wget -qO- "https://github.com/just-containers/s6-overlay/releases/download/v${s6_version}/s6-overlay-${s6_arch}.tar.xz" | - tar -C /rootfs/ -Jxpf - +download_and_extract "noarch" +download_and_extract "${s6_arch}" diff --git a/docker/main/install_tempio.sh b/docker/main/install_tempio.sh index 743a122889..468afad4a3 100755 --- a/docker/main/install_tempio.sh +++ b/docker/main/install_tempio.sh @@ -4,6 +4,14 @@ set -euxo pipefail tempio_version="2021.09.0" +# sha256 digests of the release binaries; update when bumping tempio_version. +# Upstream publishes no checksums, so these come from a one-time fetch and +# guard against later substitution rather than the original download. +declare -A tempio_checksums=( + ["amd64"]="b7b93ebfd24c1161cec7aecfad62ab51f2241149358cef354b86cdbc6a60546f" + ["aarch64"]="3a5c32981ba68b75ed9b28497429e5a5cecbeb74c3b821b035a48b37609bb895" +) + if [[ "${TARGETARCH}" == "amd64" ]]; then arch="amd64" elif [[ "${TARGETARCH}" == "arm64" ]]; then @@ -13,4 +21,5 @@ fi mkdir -p /rootfs/usr/local/tempio/bin wget -q -O /rootfs/usr/local/tempio/bin/tempio "https://github.com/home-assistant/tempio/releases/download/${tempio_version}/tempio_${arch}" +echo "${tempio_checksums[${arch}]} /rootfs/usr/local/tempio/bin/tempio" | sha256sum -c - chmod 755 /rootfs/usr/local/tempio/bin/tempio diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run index a3c7b32484..9a86e7c879 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run @@ -77,15 +77,20 @@ if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain. openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \ -subj "/O=FRIGATE DEFAULT CERT/CN=*" \ -keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem" 2>/dev/null + chmod 600 "$letsencrypt_path/privkey.pem" + chmod 644 "$letsencrypt_path/fullchain.pem" fi +# nginx settings are read once; both templates consume them +nginx_settings=$(python3 /usr/local/nginx/get_nginx_settings.py) + # build templates for optional FRIGATE_BASE_PATH environment variable -python3 /usr/local/nginx/get_nginx_settings.py | \ +echo "$nginx_settings" | \ tempio -template /usr/local/nginx/templates/base_path.gotmpl \ -out /usr/local/nginx/conf/base_path.conf # build templates for additional network settings -python3 /usr/local/nginx/get_nginx_settings.py | \ +echo "$nginx_settings" | \ tempio -template /usr/local/nginx/templates/listen.gotmpl \ -out /usr/local/nginx/conf/listen.conf diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index 0dee057ff5..7e6ff8ce78 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -189,3 +189,6 @@ if config.get("birdseye", {}).get("restream", False): # Write go2rtc_config to /dev/shm/go2rtc.yaml with open("/dev/shm/go2rtc.yaml", "w") as f: yaml.dump(go2rtc_config, f) + +# config contains camera credentials; do not leave it world-readable +os.chmod("/dev/shm/go2rtc.yaml", 0o640) diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index 262828f372..f177ffd88a 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -11,6 +11,7 @@ events { http { map_hash_bucket_size 256; + server_tokens off; include mime.types; default_type application/octet-stream; @@ -62,6 +63,7 @@ http { server { include listen.conf; + include security_headers.conf; # enable HTTP/2 for TLS connections to eliminate browser 6-connection limit http2 on; @@ -123,6 +125,7 @@ http { secure_token $args; secure_token_types application/vnd.apple.mpegurl; + include security_headers.conf; add_header Cache-Control "no-store"; expires off; @@ -139,6 +142,7 @@ http { location /stream/ { include auth_request.conf; + include security_headers.conf; add_header Cache-Control "no-store"; expires off; @@ -160,6 +164,7 @@ http { } expires 7d; + include security_headers.conf; add_header Cache-Control "public"; autoindex on; root /media/frigate; @@ -252,6 +257,7 @@ http { location /api/ { include auth_request.conf; + include security_headers.conf; add_header Cache-Control "no-store"; expires off; proxy_pass http://frigate_api/; @@ -318,29 +324,34 @@ http { location / { # do not require auth for static assets + include security_headers.conf; add_header Cache-Control "no-store"; expires off; location /assets/ { access_log off; expires 1y; + include security_headers.conf; add_header Cache-Control "public"; } location /fonts/ { access_log off; expires 1y; + include security_headers.conf; add_header Cache-Control "public"; } location /locales/ { access_log off; + include security_headers.conf; add_header Cache-Control "public"; } location ~ ^/.*-([A-Za-z0-9]+)\.webmanifest$ { access_log off; expires 1y; + include security_headers.conf; add_header Cache-Control "public"; default_type application/json; proxy_set_header Accept-Encoding ""; diff --git a/docker/main/rootfs/usr/local/nginx/conf/security_headers.conf b/docker/main/rootfs/usr/local/nginx/conf/security_headers.conf new file mode 100644 index 0000000000..f210d789b6 --- /dev/null +++ b/docker/main/rootfs/usr/local/nginx/conf/security_headers.conf @@ -0,0 +1,5 @@ +# Deliberately no X-Frame-Options or CSP frame-ancestors: HA's Webpage card and +# iframe panels frame Frigate cross-origin, and either would break them +# silently. Bind-mount this file to add your own. +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; diff --git a/docs/docs/configuration/hardware_acceleration_video.md b/docs/docs/configuration/hardware_acceleration_video.md index b9312c327f..26276f02ba 100644 --- a/docs/docs/configuration/hardware_acceleration_video.md +++ b/docs/docs/configuration/hardware_acceleration_video.md @@ -312,8 +312,9 @@ ffmpeg: :::note -If running Frigate through Docker, you either need to run in privileged mode or -map the `/dev/video*` devices to Frigate. With Docker Compose add: +If running Frigate through Docker, map the relevant `/dev/video*` devices into +the container. Running in privileged mode also works but grants far more access +than needed. With Docker Compose add: ```yaml {4-5} services: diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md index 3e1b4869c2..6105981f94 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -514,7 +514,7 @@ Generate a Frigate Docker Compose configuration based on your hardware and requi services: frigate: container_name: frigate - privileged: true # this may not be necessary for all setups + # privileged: true # ONLY enable if your hardware requires it (see hardware-specific docs); prefer the device mappings below restart: unless-stopped stop_grace_period: 30s # allow enough time to shut down the various services image: ghcr.io/blakeblackshear/frigate:stable @@ -546,6 +546,33 @@ services: +### Recommended security options + +Frigate does not need elevated container privileges for most setups. The +following hardens the container; add the `devices`/`group_add` entries your +hardware requires (see the hardware acceleration docs): + +```yaml +services: + frigate: + ... + security_opt: + - no-new-privileges:true + cap_drop: + - ALL +``` + +:::note + +`telemetry.stats.network_bandwidth` uses nethogs, which requires root with +NET_ADMIN/NET_RAW capabilities. If you enable that stat, omit `cap_drop: [ALL]` +or add `cap_add: [NET_ADMIN, NET_RAW]`. + +Platforms that genuinely require `privileged: true` (MemryX, some QNAP setups) +are called out in their own sections and are unaffected by this guidance. + +::: + **Docker CLI** If you can't use Docker Compose, you can run the container with something similar to this: diff --git a/docs/src/components/DockerComposeGenerator/config/config.yaml b/docs/src/components/DockerComposeGenerator/config/config.yaml index 42199ffca1..b06e0342f9 100644 --- a/docs/src/components/DockerComposeGenerator/config/config.yaml +++ b/docs/src/components/DockerComposeGenerator/config/config.yaml @@ -219,6 +219,8 @@ hardware: - host: "/run/mxa_manager" container: "/run/mxa_manager" comment: "MemryX manager" + privileged: true + privilegedReason: "required by MemryX to reach the max-manager" - id: "axera" label: "AXERA Accelerator" diff --git a/docs/src/components/DockerComposeGenerator/config/types.ts b/docs/src/components/DockerComposeGenerator/config/types.ts index 87bcb608dd..d45691d623 100644 --- a/docs/src/components/DockerComposeGenerator/config/types.ts +++ b/docs/src/components/DockerComposeGenerator/config/types.ts @@ -104,6 +104,10 @@ export interface DeviceConfig { extraHosts?: string[]; /** Security options, e.g. ["apparmor=unconfined"] */ securityOpt?: string[]; + /** Set only when this device type cannot work without full privileged mode */ + privileged?: boolean; + /** Why privileged mode is required, rendered as an inline comment */ + privilegedReason?: string; /** Whether this device type needs the NVIDIA GPU config UI */ needsNvidiaConfig?: boolean; } @@ -127,6 +131,10 @@ export interface HardwareOption { volumes?: VolumeMapping[]; /** Extra environment variables */ env?: Record; + /** Set only when this hardware cannot work without full privileged mode */ + privileged?: boolean; + /** Why privileged mode is required, rendered as an inline comment */ + privilegedReason?: string; } /** Port definition */ diff --git a/docs/src/components/DockerComposeGenerator/generator/index.ts b/docs/src/components/DockerComposeGenerator/generator/index.ts index f6091f7c01..d94d792558 100644 --- a/docs/src/components/DockerComposeGenerator/generator/index.ts +++ b/docs/src/components/DockerComposeGenerator/generator/index.ts @@ -1,6 +1,7 @@ import type { DeviceConfig, DeviceMapping, + HardwareOption, VolumeMapping, } from "../config/types"; import { hardwareMap } from "../config"; @@ -194,13 +195,32 @@ function buildExtraHosts(device: DeviceConfig): string[] { } function buildSecurityOpt(device: DeviceConfig): string[] { - if (!device.securityOpt?.length) return []; + // no-new-privileges is the baseline for every setup; device-specific entries + // are appended so only one security_opt key is ever emitted return [ " security_opt:", - ...device.securityOpt.map((s) => ` - ${s}`), + " - no-new-privileges:true", + ...(device.securityOpt ?? []).map((s) => ` - ${s}`), ]; } +/** + * Emit privileged mode only for hardware that genuinely cannot work without it. + * Everything else gets device mappings, which grant far less access. + */ +function buildPrivileged( + device: DeviceConfig, + selectedHardware: HardwareOption[] +): string[] { + const requiring = [device, ...selectedHardware].filter((c) => c.privileged); + if (!requiring.length) return []; + const reasons = requiring + .map((c) => c.privilegedReason) + .filter((r): r is string => Boolean(r)); + const comment = reasons.length ? ` # ${reasons.join("; ")}` : ""; + return [` privileged: true${comment}`]; +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -217,11 +237,14 @@ export function generateDockerCompose(input: GeneratorInput): string { const hwVolumes: VolumeMapping[] = []; const hwEnv: Record = {}; + const selectedHw: HardwareOption[] = []; + for (const hwId of input.selectedHardware) { const hw = hardwareMap.get(hwId); if (!hw) continue; // Skip GPU device mapping for tensorrt images (it uses deploy instead) if (hw.id === "gpu" && device.imageTag === "stable-tensorrt") continue; + selectedHw.push(hw); hwDevices.push(...(hw.devices ?? [])); hwVolumes.push(...(hw.volumes ?? [])); Object.assign(hwEnv, hw.env ?? {}); @@ -231,7 +254,7 @@ export function generateDockerCompose(input: GeneratorInput): string { "services:", " frigate:", " container_name: frigate", - " privileged: true # This may not be necessary for all setups", + ...buildPrivileged(device, selectedHw), " restart: unless-stopped", " stop_grace_period: 30s # Allow enough time to shut down the various services", ...buildImage(device), diff --git a/frigate/api/auth.py b/frigate/api/auth.py index a1997c02e2..6d6ff30d4e 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -859,9 +859,12 @@ def login(request: Request, body: AppPostLoginBody): user = body.user password = body.password + remote_addr = get_remote_addr(request) + try: db_user: User = User.get_by_id(user) except DoesNotExist: + logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}") return JSONResponse(content={"message": "Login failed"}, status_code=401) password_hash = db_user.password_hash @@ -889,6 +892,10 @@ def login(request: Request, body: AppPostLoginBody): request.app.frigate_config.auth.admin_first_time_login = False return response + + logger.warning( + f"Login failed for user '{user}' (invalid password) from {remote_addr}" + ) return JSONResponse(content={"message": "Login failed"}, status_code=401) diff --git a/frigate/test/http_api/test_http_auth.py b/frigate/test/http_api/test_http_auth.py new file mode 100644 index 0000000000..3a3a986ced --- /dev/null +++ b/frigate/test/http_api/test_http_auth.py @@ -0,0 +1,45 @@ +"""Tests for authentication endpoints.""" + +import os +from unittest.mock import patch + +from frigate.api.auth import hash_password +from frigate.const import JWT_SECRET_ENV_VAR +from frigate.models import User +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"}) +class TestHttpAuth(BaseTestHttp): + def setUp(self): + super().setUp([User]) + self.app = super().create_app() + + def tearDown(self): + User.delete().execute() + super().tearDown() + + def test_login_unknown_user_logs_warning(self): + with self.assertLogs("frigate.api.auth", level="WARNING") as logs: + with AuthTestClient(self.app) as client: + response = client.post( + "/login", json={"user": "ghost", "password": "irrelevant"} + ) + assert response.status_code == 401 + assert any("Login failed" in m and "ghost" in m for m in logs.output) + + def test_login_bad_password_logs_warning(self): + password_hash = hash_password("correct-horse-battery", iterations=1000) + User.insert( + username="admin", + password_hash=password_hash, + role="admin", + notification_tokens=[], + ).execute() + with self.assertLogs("frigate.api.auth", level="WARNING") as logs: + with AuthTestClient(self.app) as client: + response = client.post( + "/login", json={"user": "admin", "password": "wrong"} + ) + assert response.status_code == 401 + assert any("Login failed" in m and "admin" in m for m in logs.output)