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
@@ -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:
+28 -1
View File
@@ -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),