mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-31 07:27:57 +00:00
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:
parent
6c6683034e
commit
dcda458a82
55
.github/workflows/ci.yml
vendored
55
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 "<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
|
||||
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<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
|
||||
# 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
|
||||
|
||||
19
docker/main/install_go2rtc.sh
Executable file
19
docker/main/install_go2rtc.sh
Executable 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"
|
||||
@ -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 -
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 "";
|
||||
|
||||
@ -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
|
||||
|
||||
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:
|
||||
|
||||
@ -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:
|
||||
</TabItem>
|
||||
</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**
|
||||
|
||||
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"
|
||||
container: "/run/mxa_manager"
|
||||
comment: "MemryX manager"
|
||||
privileged: true
|
||||
privilegedReason: "required by MemryX to reach the max-manager"
|
||||
|
||||
- id: "axera"
|
||||
label: "AXERA Accelerator"
|
||||
|
||||
@ -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<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 */
|
||||
|
||||
@ -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<string, string> = {};
|
||||
|
||||
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),
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
45
frigate/test/http_api/test_http_auth.py
Normal file
45
frigate/test/http_api/test_http_auth.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user