Files
Josh HawkinsandNicolas Mowen 3be59c9c18 Container security hardening (phase 3, breaking) (#24081)
* Run the frigate service as the frigate user

* Run go2rtc as its own restricted user

* Run nginx as the frigate user with writable state in /tmp/nginx

* Disable bandwidth stats gracefully when not running as root

* Hand TensorRT model cache ownership to the runtime user

* Document non-root operation and per-hardware device access

* Create /media/frigate after the ownership sweep

* Assert non-root services, JWT migration, and escape hatch in CI

* only write the sweep sentinel when a media volume is mounted

* tolerate homekit config chown failures in the go2rtc run script

* chown the s6 log pipe so non-root nginx can reopen /dev/stdout

* set HOME to /config for non-root services

* run smoke nginx -t and the write probe as the runtime user

* re-own the nginx shm cache on service restart

* discard stdout for the unprivileged smoke nginx -t

* unwrap hard-wrapped prose in the installation docs

* report progress during the ownership sweep

* document EXTRA_GROUPS as the only device access path for dropped services

* expand the non-root device access docs with diagnosis steps and udev rules

* document network storage ownership and the remaining detector hardware

* skip lost+found during the ownership sweep

* hand /tmp/cache to the runtime user before services start

* make bundled models readable by the runtime user

* reload nginx by signaling the master instead of parsing its config as root

* harden root writes into unprivileged-owned paths

Restrict the sweep sentinel to a mount at or below /media/frigate so a
parent /media mount cannot bless a later-shadowed volume. Rebuild
/tmp/nginx root-owned each start so root's cp and tempio writes cannot
follow a symlink an unprivileged nginx planted in the previous run.

* collapse the duplicated sentinel comment

* add a service-runs-as-root helper for granular root services

* validate FRIGATE_ROOT_SERVICES and fail fast on unknown names

* let services listed in FRIGATE_ROOT_SERVICES skip the privilege drop

* record the root-services mode in the sentinel and sweep small trees each boot

* cache the runtime ids in the ownership helper

* chown recordings, previews, and exports to the runtime user at create

* chown the database files after init

* recommend FRIGATE_ROOT_SERVICES in the bandwidth stats warning

* assert granular root services in CI

* document FRIGATE_ROOT_SERVICES

* own every directory level created for a recording segment

* clear the cached runtime ids when ownership tests finish

* skip missing media paths in the per-boot ownership sweep

* clarify granular root services docs

* clean up

* install acl for device access grants

* grant runtime users access to mapped device nodes at boot

* assert device access grants in CI

* document automatic device access grants

* stop telling users device access needs host side setup

* clarify the non-root docs

* link the migration script to the repo

* group the manual device setup under one section

* harden against symlink attacks

/config is owned by the unprivileged runtime user after the ownership sweep, so root operations on files there could be redirected by a planted symlink.

- go2rtc HomeKit setup: replace the root yq/jq normalization and chown with an O_NOFOLLOW helper (prepare_homekit.py), so a symlink at go2rtc_homekit.yml can't redirect a root write or chown onto another file
- go2rtc binary override: ignore /config/go2rtc whenever the service runs as root, so a planted binary can't exec as root under FRIGATE_ROOT_SERVICES
- sweep sentinel: read and write it through safe-sentinel, which trusts only a root-owned regular file and never follows a symlink, so it can't be forged to skip the migration or symlinked to clobber a root file
- ownership sweep: chown with -execdir so a parent directory swapped for a symlink mid-walk can't redirect the chown out of the volume
- validate inputs: restrict DEVICE_ACL_PATHS to /dev, require nonzero numeric EXTRA_GROUPS, and reject PUID/PGID that collide with the go2rtc ids
- docs: correct the TLS key ownership note to match what actually happens

* tweak docs

* stop the ownership sweep chasing entries other mechanisms own

* keep custom binaries out of root services only under granular root
2026-09-12 07:30:04 -06:00

195 lines
8.2 KiB
Bash
Executable File

#!/bin/bash
# Single source of truth for aligning volume ownership with the runtime user.
#
# Usage: fix-ownership [--dry-run] [--sentinel FILE] [--mode STRING] UID GID PATH [PATH...]
#
# --dry-run report what would change, touch nothing
# --sentinel skip entirely when FILE already records "SCHEMA:UID:GID";
# write it after a successful run (used by the boot path so
# multi-TB volumes are swept once per UID/schema change, not
# on every boot)
# --mode append STRING to the sentinel, so changing it re-sweeps once
#
# Only files whose uid OR gid differs are touched, so re-runs are cheap.
# lost+found is skipped: fsck fills it with root-only recovered fragments.
# Top-level /config additionally grants group frigate-data TRAVERSE ONLY
# (g+rx) so the separate go2rtc user can reach its pre-created HomeKit file
# on hosts where /config is mounted 0700. Never g+w: directory write means
# unlink rights over frigate.db/config.yml, and would let a compromised
# go2rtc plant /config/go2rtc, which the go2rtc run script executes
# preferentially, as root under the escape hatch.
set -o errexit -o nounset -o pipefail
# Permissions-layout epoch. Bump to force a one-time re-sweep on upgrade
# (e.g. when the privilege-drop release must capture files created as root
# since the previous sweep).
schema=2
dry_run=0
sentinel=""
mode=""
while [[ "${1:-}" == --* ]]; do
case "$1" in
--dry-run) dry_run=1; shift ;;
--sentinel)
if [[ -z "${2:-}" ]]; then
echo "[ERROR] fix-ownership: --sentinel requires a file argument" >&2
exit 2
fi
sentinel="$2"; shift 2 ;;
--mode)
if [[ -z "${2:-}" ]]; then
echo "[ERROR] fix-ownership: --mode requires a value" >&2
exit 2
fi
mode="$2"; shift 2 ;;
*) echo "[ERROR] fix-ownership: unknown option $1" >&2; exit 2 ;;
esac
done
if [[ $# -lt 3 ]]; then
echo "Usage: fix-ownership [--dry-run] [--sentinel FILE] [--mode STRING] UID GID PATH..." >&2
exit 2
fi
target_uid="$1"
target_gid="$2"
shift 2
if [[ "$(id -u)" -ne 0 ]]; then
echo "[INFO] fix-ownership: not running as root, skipping (ownership is managed by the host in --user mode)"
exit 0
fi
# The list folds into the sentinel so entering or leaving a granular root mode
# re-sweeps once, catching whatever the other ownership mechanisms missed.
sentinel_content="${schema}:${target_uid}:${target_gid}"
if [[ -n "$mode" ]]; then
sentinel_content="${sentinel_content}:${mode}"
fi
# safe-sentinel reports only a root-owned regular file, so a forged or
# symlinked sentinel in the runtime-user-owned /config can't suppress the sweep
if [[ "$dry_run" -eq 0 && -n "$sentinel" ]]; then
if existing=$(/usr/local/bin/safe-sentinel read "$sentinel" 2>/dev/null) && \
[[ "$existing" == "$sentinel_content" ]]; then
echo "[INFO] fix-ownership: ${target_uid}:${target_gid} (schema ${schema}) already applied, skipping"
exit 0
fi
fi
# A sweep that could not chown everything must not be recorded as complete:
# the sentinel would make every later boot skip it and the entries would stay
# unreachable once services run unprivileged.
swept_clean=1
# Entries another mechanism deliberately owns. Chowning them undoes that work
# and leaves the same "mismatch" waiting for the next boot, so /config could
# never report itself clean: /config is chgrp'd to frigate-data below so go2rtc
# can traverse it, and the HomeKit file is handed to the go2rtc user by the
# go2rtc service. Only the GROUP on /config is exempt; a root-owned /config
# must still be chowned or the runtime user cannot write there at all.
# Shared by the counting and the chowning walk so the two cannot disagree.
mismatch_expr=(
"(" -not -uid "$target_uid"
-o "(" -not -gid "$target_gid" -a ! -path /config ")"
")"
-a ! -path /config/go2rtc_homekit.yml
)
if [[ -n "$sentinel" ]]; then
# safe-sentinel keeps the sentinel root-owned on purpose and rejects one
# owned by anybody else, so chowning it here would suppress the skip and
# make every boot re-sweep. Only the trailing write puts it back today.
mismatch_expr+=(-a ! -path "$sentinel")
fi
for path in "$@"; do
# An absent root is an incomplete sweep, not a finished one: /media/frigate
# is not in the image, so a boot before the volume is mounted would
# otherwise record success and the volume would never be swept once added.
if [[ ! -d "$path" ]]; then
swept_clean=0
echo "[WARN] fix-ownership: $path does not exist, skipping; will retry on next boot"
continue
fi
echo "[INFO] fix-ownership: scanning ${path} for ownership mismatches; this may take a while on large filesystems"
# find may fail mid-walk on a live volume (file deleted under it) or on a
# stale mount. Tolerate it rather than aborting under errexit, but never
# read a failed scan as "nothing to do": that would record the sweep as
# complete without having looked.
if ! count=$(find "$path" -name lost+found -prune -o "${mismatch_expr[@]}" -printf '.' 2>/dev/null | wc -c); then
swept_clean=0
echo "[WARN] fix-ownership: could not scan ${path}; will retry on next boot"
continue
fi
if [[ "$count" -eq 0 ]]; then
echo "[INFO] fix-ownership: $path already owned by ${target_uid}:${target_gid}, nothing to do"
continue
fi
# find does not descend symlinks and chown -h retargets the link itself, so
# anything behind a symlinked directory is outside this sweep. Following
# them is not an option: a link could walk the chown out of the volume.
if [[ -n "$(find "$path" -type l -xtype d -print -quit 2>/dev/null)" ]]; then
echo "[WARN] fix-ownership: ${path} contains symlinked directories; ownership behind them is not managed and must be aligned by hand"
fi
echo "[WARN] fix-ownership: adjusting ownership of ${count} entries under ${path}"
if [[ "$dry_run" -eq 1 ]]; then
echo "[INFO] fix-ownership: dry run, not changing ${path}"
continue
fi
# -execdir chowns from the entry's own directory, so a parent swapped for a
# symlink mid-walk can't redirect the chown out of the volume
started=$SECONDS
if find "$path" -name lost+found -prune -o "${mismatch_expr[@]}" \
-print -execdir chown -h "${target_uid}:${target_gid}" {} + \
| awk -v total="$count" -v path="$path" '
BEGIN { next_pct = 5 }
{
pct = int(NR * 100 / total)
if (pct > 100) pct = 100
if (pct >= next_pct) {
printf "[INFO] fix-ownership: %s %d%% (%d/%d entries)\n", path, pct, NR, total
# mawk block-buffers to a pipe; without fflush the whole
# progress log arrives at once
fflush()
while (next_pct <= pct) next_pct += 5
}
}'; then
elapsed=$((SECONDS - started))
if [[ "$elapsed" -ge 60 ]]; then
elapsed="$((elapsed / 60))m $((elapsed % 60))s"
else
elapsed="${elapsed}s"
fi
echo "[INFO] fix-ownership: finished ${path} in ${elapsed}"
else
swept_clean=0
echo "[WARN] fix-ownership: some entries under ${path} could not be updated (deleted mid-sweep or chown denied); will retry on next mismatch"
fi
done
# go2rtc (separate user) must be able to REACH its HomeKit state in /config.
# Write access is per-file, not per-directory: go2rtc's PatchConfig rewrites
# the first -config file via os.WriteFile (in-place truncate, no rename,
# verified against go2rtc v1.9.14 internal/app/config.go), and the file is
# always pre-created by setup_homekit_config before go2rtc starts, so
# O_CREATE never needs directory write. See header comment for why g+w is
# forbidden here.
if [[ "$dry_run" -eq 0 && -d /config ]]; then
chgrp frigate-data /config 2>/dev/null || true
chmod g+rx /config 2>/dev/null || true
fi
if [[ "$dry_run" -eq 0 && -n "$sentinel" && "$swept_clean" -eq 1 ]]; then
/usr/local/bin/safe-sentinel write "$sentinel" "$sentinel_content" || \
echo "[WARN] fix-ownership: could not write ${sentinel}; the sweep will run again on next boot"
fi