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<week>.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.
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent 396a2156b2
commit c004d0a1c9
18 changed files with 348 additions and 54 deletions
+55
View File
@@ -42,6 +42,61 @@ jobs:
tags: ${{ steps.setup.outputs.image-name }}-amd64 tags: ${{ steps.setup.outputs.image-name }}-amd64
cache-from: type=registry,ref=${{ steps.setup.outputs.cache-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 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: arm64_build:
runs-on: ubuntu-22.04-arm runs-on: ubuntu-22.04-arm
name: ARM Build name: ARM Build
+3 -3
View File
@@ -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 \ 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 /deps/build_intel_media_driver.sh
FROM scratch AS go2rtc FROM wget AS go2rtc
ARG TARGETARCH ARG TARGETARCH
WORKDIR /rootfs/usr/local/go2rtc/bin RUN --mount=type=bind,source=docker/main/install_go2rtc.sh,target=/deps/install_go2rtc.sh \
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.14/go2rtc_linux_${TARGETARCH}" go2rtc /deps/install_go2rtc.sh
FROM wget AS tempio FROM wget AS tempio
ARG TARGETARCH ARG TARGETARCH
+77 -37
View File
@@ -28,7 +28,13 @@ update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1
mkdir -p -m 600 /root/.gnupg mkdir -p -m 600 /root/.gnupg
# install coral runtime # 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" 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 unset DEBIAN_FRONTEND
yes | dpkg -i /tmp/libedgetpu1-max.deb && export DEBIAN_FRONTEND=noninteractive yes | dpkg -i /tmp/libedgetpu1-max.deb && export DEBIAN_FRONTEND=noninteractive
rm /tmp/libedgetpu1-max.deb rm /tmp/libedgetpu1-max.deb
@@ -45,36 +51,41 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then
fi fi
fi fi
# sha256 digests of the ffmpeg builds, keyed "<install dir>-<arch>".
# 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 # ffmpeg -> amd64
if [[ "${TARGETARCH}" == "amd64" ]]; then if [[ "${TARGETARCH}" == "amd64" ]]; then
mkdir -p /usr/lib/ffmpeg/5.0 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"
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" 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"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe 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"
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
fi fi
# ffmpeg -> arm64 # ffmpeg -> arm64
if [[ "${TARGETARCH}" == "arm64" ]]; then if [[ "${TARGETARCH}" == "arm64" ]]; then
mkdir -p /usr/lib/ffmpeg/5.0 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"
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" 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"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe 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"
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
fi fi
# arch specific packages # arch specific packages
@@ -120,27 +131,56 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
apt-get -qq install -y libtbb12 apt-get -qq install -y libtbb12
# install legacy and standard intel compute packages # install legacy and standard intel compute packages
# sha256 digests of the driver debs, taken from the ww<week>.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 <url> | 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 # see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info
# needed core package # 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 dpkg -i libigdgmm12_22.9.0_amd64.deb
rm libigdgmm12_22.9.0_amd64.deb rm libigdgmm12_22.9.0_amd64.deb
# legacy compute-runtime packages # 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 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
wget 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/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 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
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/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb
# standard compute-runtime packages # 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 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
wget 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/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 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
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/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb
# npu packages # npu packages
wget 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/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 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
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 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
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/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 dpkg -i *.deb
rm *.deb rm *.deb
+19
View File
@@ -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"
+20 -2
View File
@@ -4,11 +4,29 @@ set -euxo pipefail
hailo_version="4.21.0" 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 if [[ "${TARGETARCH}" == "amd64" ]]; then
arch="x86_64" arch="x86_64"
elif [[ "${TARGETARCH}" == "arm64" ]]; then elif [[ "${TARGETARCH}" == "arm64" ]]; then
arch="aarch64" arch="aarch64"
fi fi
wget -qO- "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-debian12-${TARGETARCH}.tar.gz" | tar -C / -xzf - # downloaded rather than streamed into tar because streaming and verifying the
wget -P /wheels/ "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl" # 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 -
+20 -4
View File
@@ -4,6 +4,15 @@ set -euxo pipefail
s6_version="3.2.1.0" 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 if [[ "${TARGETARCH}" == "amd64" ]]; then
s6_arch="x86_64" s6_arch="x86_64"
elif [[ "${TARGETARCH}" == "arm64" ]]; then elif [[ "${TARGETARCH}" == "arm64" ]]; then
@@ -12,8 +21,15 @@ fi
mkdir -p /rootfs/ mkdir -p /rootfs/
wget -qO- "https://github.com/just-containers/s6-overlay/releases/download/v${s6_version}/s6-overlay-noarch.tar.xz" | download_and_extract() {
tar -C /rootfs/ -Jxpf - 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" | download_and_extract "noarch"
tar -C /rootfs/ -Jxpf - download_and_extract "${s6_arch}"
+9
View File
@@ -4,6 +4,14 @@ set -euxo pipefail
tempio_version="2021.09.0" 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 if [[ "${TARGETARCH}" == "amd64" ]]; then
arch="amd64" arch="amd64"
elif [[ "${TARGETARCH}" == "arm64" ]]; then elif [[ "${TARGETARCH}" == "arm64" ]]; then
@@ -13,4 +21,5 @@ fi
mkdir -p /rootfs/usr/local/tempio/bin 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}" 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 chmod 755 /rootfs/usr/local/tempio/bin/tempio
@@ -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 \ openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \
-subj "/O=FRIGATE DEFAULT CERT/CN=*" \ -subj "/O=FRIGATE DEFAULT CERT/CN=*" \
-keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem" 2>/dev/null -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 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 # 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 \ tempio -template /usr/local/nginx/templates/base_path.gotmpl \
-out /usr/local/nginx/conf/base_path.conf -out /usr/local/nginx/conf/base_path.conf
# build templates for additional network settings # 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 \ tempio -template /usr/local/nginx/templates/listen.gotmpl \
-out /usr/local/nginx/conf/listen.conf -out /usr/local/nginx/conf/listen.conf
@@ -189,3 +189,6 @@ if config.get("birdseye", {}).get("restream", False):
# Write go2rtc_config to /dev/shm/go2rtc.yaml # Write go2rtc_config to /dev/shm/go2rtc.yaml
with open("/dev/shm/go2rtc.yaml", "w") as f: with open("/dev/shm/go2rtc.yaml", "w") as f:
yaml.dump(go2rtc_config, f) yaml.dump(go2rtc_config, f)
# config contains camera credentials; do not leave it world-readable
os.chmod("/dev/shm/go2rtc.yaml", 0o640)
@@ -11,6 +11,7 @@ events {
http { http {
map_hash_bucket_size 256; map_hash_bucket_size 256;
server_tokens off;
include mime.types; include mime.types;
default_type application/octet-stream; default_type application/octet-stream;
@@ -62,6 +63,7 @@ http {
server { server {
include listen.conf; include listen.conf;
include security_headers.conf;
# enable HTTP/2 for TLS connections to eliminate browser 6-connection limit # enable HTTP/2 for TLS connections to eliminate browser 6-connection limit
http2 on; http2 on;
@@ -123,6 +125,7 @@ http {
secure_token $args; secure_token $args;
secure_token_types application/vnd.apple.mpegurl; secure_token_types application/vnd.apple.mpegurl;
include security_headers.conf;
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
expires off; expires off;
@@ -139,6 +142,7 @@ http {
location /stream/ { location /stream/ {
include auth_request.conf; include auth_request.conf;
include security_headers.conf;
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
expires off; expires off;
@@ -162,6 +166,7 @@ http {
} }
expires 7d; expires 7d;
include security_headers.conf;
add_header Cache-Control "public"; add_header Cache-Control "public";
autoindex on; autoindex on;
root /media/frigate; root /media/frigate;
@@ -254,6 +259,7 @@ http {
location /api/ { location /api/ {
include auth_request.conf; include auth_request.conf;
include security_headers.conf;
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
expires off; expires off;
proxy_pass http://frigate_api/; proxy_pass http://frigate_api/;
@@ -320,29 +326,34 @@ http {
location / { location / {
# do not require auth for static assets # do not require auth for static assets
include security_headers.conf;
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
expires off; expires off;
location /assets/ { location /assets/ {
access_log off; access_log off;
expires 1y; expires 1y;
include security_headers.conf;
add_header Cache-Control "public"; add_header Cache-Control "public";
} }
location /fonts/ { location /fonts/ {
access_log off; access_log off;
expires 1y; expires 1y;
include security_headers.conf;
add_header Cache-Control "public"; add_header Cache-Control "public";
} }
location /locales/ { location /locales/ {
access_log off; access_log off;
include security_headers.conf;
add_header Cache-Control "public"; add_header Cache-Control "public";
} }
location ~ ^/.*-([A-Za-z0-9]+)\.webmanifest$ { location ~ ^/.*-([A-Za-z0-9]+)\.webmanifest$ {
access_log off; access_log off;
expires 1y; expires 1y;
include security_headers.conf;
add_header Cache-Control "public"; add_header Cache-Control "public";
default_type application/json; default_type application/json;
proxy_set_header Accept-Encoding ""; proxy_set_header Accept-Encoding "";
@@ -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;
@@ -312,8 +312,9 @@ ffmpeg:
:::note :::note
If running Frigate through Docker, you either need to run in privileged mode or If running Frigate through Docker, map the relevant `/dev/video*` devices into
map the `/dev/video*` devices to Frigate. With Docker Compose add: the container. Running in privileged mode also works but grants far more access
than needed. With Docker Compose add:
```yaml {4-5} ```yaml {4-5}
services: services:
+28 -1
View File
@@ -514,7 +514,7 @@ Generate a Frigate Docker Compose configuration based on your hardware and requi
services: services:
frigate: frigate:
container_name: 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 restart: unless-stopped
stop_grace_period: 30s # allow enough time to shut down the various services stop_grace_period: 30s # allow enough time to shut down the various services
image: ghcr.io/blakeblackshear/frigate:stable image: ghcr.io/blakeblackshear/frigate:stable
@@ -546,6 +546,33 @@ services:
</TabItem> </TabItem>
</Tabs> </Tabs>
### 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** **Docker CLI**
If you can't use Docker Compose, you can run the container with something similar to this: If you can't use Docker Compose, you can run the container with something similar to this:
@@ -219,6 +219,8 @@ hardware:
- host: "/run/mxa_manager" - host: "/run/mxa_manager"
container: "/run/mxa_manager" container: "/run/mxa_manager"
comment: "MemryX manager" comment: "MemryX manager"
privileged: true
privilegedReason: "required by MemryX to reach the max-manager"
- id: "axera" - id: "axera"
label: "AXERA Accelerator" label: "AXERA Accelerator"
@@ -104,6 +104,10 @@ export interface DeviceConfig {
extraHosts?: string[]; extraHosts?: string[];
/** Security options, e.g. ["apparmor=unconfined"] */ /** Security options, e.g. ["apparmor=unconfined"] */
securityOpt?: string[]; 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 */ /** Whether this device type needs the NVIDIA GPU config UI */
needsNvidiaConfig?: boolean; needsNvidiaConfig?: boolean;
} }
@@ -127,6 +131,10 @@ export interface HardwareOption {
volumes?: VolumeMapping[]; volumes?: VolumeMapping[];
/** Extra environment variables */ /** Extra environment variables */
env?: Record<string, string>; env?: Record<string, string>;
/** 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 */ /** Port definition */
@@ -1,6 +1,7 @@
import type { import type {
DeviceConfig, DeviceConfig,
DeviceMapping, DeviceMapping,
HardwareOption,
VolumeMapping, VolumeMapping,
} from "../config/types"; } from "../config/types";
import { hardwareMap } from "../config"; import { hardwareMap } from "../config";
@@ -194,13 +195,32 @@ function buildExtraHosts(device: DeviceConfig): string[] {
} }
function buildSecurityOpt(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 [ return [
" security_opt:", " 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 // Public API
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -217,11 +237,14 @@ export function generateDockerCompose(input: GeneratorInput): string {
const hwVolumes: VolumeMapping[] = []; const hwVolumes: VolumeMapping[] = [];
const hwEnv: Record<string, string> = {}; const hwEnv: Record<string, string> = {};
const selectedHw: HardwareOption[] = [];
for (const hwId of input.selectedHardware) { for (const hwId of input.selectedHardware) {
const hw = hardwareMap.get(hwId); const hw = hardwareMap.get(hwId);
if (!hw) continue; if (!hw) continue;
// Skip GPU device mapping for tensorrt images (it uses deploy instead) // Skip GPU device mapping for tensorrt images (it uses deploy instead)
if (hw.id === "gpu" && device.imageTag === "stable-tensorrt") continue; if (hw.id === "gpu" && device.imageTag === "stable-tensorrt") continue;
selectedHw.push(hw);
hwDevices.push(...(hw.devices ?? [])); hwDevices.push(...(hw.devices ?? []));
hwVolumes.push(...(hw.volumes ?? [])); hwVolumes.push(...(hw.volumes ?? []));
Object.assign(hwEnv, hw.env ?? {}); Object.assign(hwEnv, hw.env ?? {});
@@ -231,7 +254,7 @@ export function generateDockerCompose(input: GeneratorInput): string {
"services:", "services:",
" frigate:", " frigate:",
" container_name: frigate", " container_name: frigate",
" privileged: true # This may not be necessary for all setups", ...buildPrivileged(device, selectedHw),
" restart: unless-stopped", " restart: unless-stopped",
" stop_grace_period: 30s # Allow enough time to shut down the various services", " stop_grace_period: 30s # Allow enough time to shut down the various services",
...buildImage(device), ...buildImage(device),
+7
View File
@@ -859,9 +859,12 @@ def login(request: Request, body: AppPostLoginBody):
user = body.user user = body.user
password = body.password password = body.password
remote_addr = get_remote_addr(request)
try: try:
db_user: User = User.get_by_id(user) db_user: User = User.get_by_id(user)
except DoesNotExist: except DoesNotExist:
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
return JSONResponse(content={"message": "Login failed"}, status_code=401) return JSONResponse(content={"message": "Login failed"}, status_code=401)
password_hash = db_user.password_hash 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 request.app.frigate_config.auth.admin_first_time_login = False
return response return response
logger.warning(
f"Login failed for user '{user}' (invalid password) from {remote_addr}"
)
return JSONResponse(content={"message": "Login failed"}, status_code=401) return JSONResponse(content={"message": "Login failed"}, status_code=401)
+45
View File
@@ -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)