#!/usr/bin/env bash
# Pre-release check (docs/architecture.md ยง39): Nginx never serves a symbolic link from the
# library through /media-internal/, even when an X-Accel-Redirect points straight at it.
#
# Django already refuses symlinks before redirecting (get_media_path(), covered by pytest).
# This checks the second line of defense on its own: the real Nginx configs
# (docker/nginx.conf and docker/nginx.dev.conf) run against a stub backend that redirects
# to any path it is asked for. A control run with "disable_symlinks off" must serve the
# outside file through every link: it proves the links work, so a broken link can never
# make the check pass. Everything runs in throwaway containers on a private network, with
# a temporary library; nothing of the development environment is touched.
#
# Usage: scripts/check-nginx-symlinks.sh (exit code 0 = every check passed)
set -euo pipefail
cd "$(dirname "$0")/.."
NGINX_IMAGE=nginx:alpine
STUB_IMAGE=python:3.13-slim
RUN_ID="vhs-symlink-check-$$"
SECRET="SECRET-OUTSIDE-THE-LIBRARY"
WORK=$(mktemp -d)
cleanup() {
docker rm -f "$RUN_ID-nginx" "$RUN_ID-backend" >/dev/null 2>&1 || true
docker network rm "$RUN_ID" >/dev/null 2>&1 || true
rm -rf "$WORK"
}
trap cleanup EXIT
# A library with a regular file (positive control) and every kind of symlink, plus a file
# outside the library that must never be served.
mkdir -p "$WORK/library/youtube/channel" "$WORK/library/.incomplete/1" "$WORK/outside"
echo "REAL-VIDEO" >"$WORK/library/youtube/channel/real.mp4"
echo "PARTIAL" >"$WORK/library/.incomplete/1/partial.mp4"
echo "$SECRET" >"$WORK/outside/secret.mp4"
# Paths as seen inside the Nginx container: the library on /srv/video-library, the
# outside folder on /srv/outside.
ln -s /srv/outside/secret.mp4 "$WORK/library/youtube/channel/absolute-link.mp4"
ln -s ../../../outside/secret.mp4 "$WORK/library/youtube/channel/relative-link.mp4"
ln -s real.mp4 "$WORK/library/youtube/channel/inside-link.mp4"
ln -s /srv/outside "$WORK/library/youtube/linked-dir"
# Stub backend: GET /api/redirect?path=
answers with X-Accel-Redirect: /media-internal/
.
cat >"$WORK/stub.py" <<'EOF'
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
path = parse_qs(urlparse(self.path).query).get("path", [""])[0]
self.send_response(200)
self.send_header("X-Accel-Redirect", "/media-internal/" + path)
self.send_header("Content-Length", "0")
self.end_headers()
def log_message(self, *args):
pass
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
EOF
docker network create "$RUN_ID" >/dev/null
docker run -d --name "$RUN_ID-backend" --network "$RUN_ID" --network-alias backend \
-v "$WORK/stub.py:/stub.py:ro" "$STUB_IMAGE" python /stub.py >/dev/null
failures=0
check() { # check
# The outside file must never be served, except in the control run, where it must be.
local description=$1 expected=$2 needle=$3 path=$4 status body
body=$(docker exec "$RUN_ID-nginx" sh -c \
"wget -S -q -O - 'http://127.0.0.1$path' 2>/tmp/headers; cat /tmp/headers >&2" \
2>"$WORK/headers" || true)
# The last HTTP status line: "HTTP/1.1 200 OK", or "... error: HTTP/1.1 403 Forbidden".
status=$(grep -o 'HTTP/[0-9.]* [0-9]*' "$WORK/headers" | tail -n 1 | awk '{print $2}')
local ok=1
[ "$status" = "$expected" ] || ok=0
if [ "$needle" != "$SECRET" ]; then
case "$body" in *"$SECRET"*) ok=0 ;; esac
fi
if [ "$needle" != "-" ]; then
case "$body" in *"$needle"*) ;; *) ok=0 ;; esac
fi
if [ $ok = 1 ]; then
printf ' ok %-48s %s\n' "$description" "$status"
else
printf ' FAIL %-48s status %s (expected %s)%s\n' "$description" "${status:-none}" "$expected" \
"$(case "$body" in *"$SECRET"*) echo ', OUTSIDE FILE SERVED' ;; esac)"
failures=$((failures + 1))
fi
}
start_nginx() { # start_nginx
docker run -d --name "$RUN_ID-nginx" --network "$RUN_ID" \
-v "$1:/etc/nginx/conf.d/default.conf:ro" \
-v "$WORK/library:/srv/video-library:ro" \
-v "$WORK/outside:/srv/outside:ro" \
"$NGINX_IMAGE" >/dev/null
for _ in $(seq 1 50); do
docker exec "$RUN_ID-nginx" sh -c 'wget -q -O /dev/null http://127.0.0.1/api/redirect?path=youtube/channel/real.mp4' \
>/dev/null 2>&1 && break
sleep 0.2
done
}
redirect="/api/redirect?path="
for config in docker/nginx.conf docker/nginx.dev.conf; do
echo "== $config"
start_nginx "$PWD/$config"
check "regular file through the redirect (control)" 200 REAL-VIDEO "${redirect}youtube/channel/real.mp4"
# A symlinked file: open() with O_NOFOLLOW fails with ELOOP, and Nginx answers 403.
check "absolute symlink to a file outside" 403 - "${redirect}youtube/channel/absolute-link.mp4"
check "relative symlink to a file outside" 403 - "${redirect}youtube/channel/relative-link.mp4"
check "symlink to a file inside the library" 403 - "${redirect}youtube/channel/inside-link.mp4"
# A symlinked directory is not walked: ENOTDIR, and Nginx answers 404.
check "file under a symlinked directory" 404 - "${redirect}youtube/linked-dir/secret.mp4"
check "work area of in-progress downloads" 404 - "${redirect}.incomplete/1/partial.mp4"
check "direct request to /media-internal/" 404 - "/media-internal/youtube/channel/real.mp4"
docker rm -f "$RUN_ID-nginx" >/dev/null
done
echo "== control: docker/nginx.conf with disable_symlinks off (every link must work)"
sed 's/disable_symlinks on;/disable_symlinks off;/' docker/nginx.conf >"$WORK/control.conf"
grep -q 'disable_symlinks off;' "$WORK/control.conf" || { echo "control config not built" >&2; exit 1; }
start_nginx "$WORK/control.conf"
check "absolute symlink is followed" 200 "$SECRET" "${redirect}youtube/channel/absolute-link.mp4"
check "relative symlink is followed" 200 "$SECRET" "${redirect}youtube/channel/relative-link.mp4"
check "symlinked directory is followed" 200 "$SECRET" "${redirect}youtube/linked-dir/secret.mp4"
docker rm -f "$RUN_ID-nginx" >/dev/null
if [ "$failures" -gt 0 ]; then
echo "Symlink check FAILED: $failures check(s) did not pass." >&2
exit 1
fi
echo "Symlink check passed: Nginx serves no symbolic link from the library."