diff --git a/dashcaddy-api/__tests__/scripts/operator-flags.test.js b/dashcaddy-api/__tests__/scripts/operator-flags.test.js new file mode 100644 index 0000000..835d132 --- /dev/null +++ b/dashcaddy-api/__tests__/scripts/operator-flags.test.js @@ -0,0 +1,173 @@ +/** + * DC-164: operator-flags.sh — env-override translator tests (executed, not mocked). + * + * The helper turns /etc/dashcaddy/operator.env into KEY=VALUE lines that + * start.sh forwards as `docker run -e` flags. Contract proven here: + * - SECURITY contract enforced (rev2 judge blocker): the file must be a + * regular file (no symlink), uid-0 owned, mode with zero group/other + * access and no special bits — violations exit 3 with FATAL on stderr + * - valid KEY=VALUE lines pass through verbatim (values incl. spaces, + * '#', '@' are DATA, never re-parsed) + * - comments/blank lines skipped + * - malformed key names (spaces, leading digit, 'export ' prefix) skipped + * - surrounding double quotes stripped + * - missing file → exit 0, empty output + * - NOTHING is ever sourced/executed (injection line becomes inert output) + */ + +'use strict'; + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const HELPER = path.join(__dirname, '..', '..', 'scripts', 'operator-flags.sh'); + +function runHelper(contents, opts = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-')); + const file = path.join(dir, 'operator.env'); + if (contents !== null) fs.writeFileSync(file, contents, { mode: 0o600 }); + try { + const r = spawnSync('bash', [HELPER, file], { encoding: 'utf8' }); + return { out: r.stdout || '', err: r.stderr || '', code: r.status }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +describe('DC-164 operator-flags.sh', () => { + test('valid lines pass through verbatim', () => { + const { out } = runHelper('ALLOW_LIFETIME_LICENSE=true\nFOO=bar\n'); + expect(out).toBe('ALLOW_LIFETIME_LICENSE=true\nFOO=bar\n'); + }); + + test('missing file → exit 0, no output', () => { + const { out, code } = runHelper(null); + expect(code).toBe(0); + expect(out).toBe(''); + }); + + test('comments and blank lines skipped', () => { + const { out } = runHelper('# comment\n\n \nA=1\n'); + expect(out).toBe('A=1\n'); + }); + + test('malformed keys skipped with stderr warning (not fatal)', () => { + const r = runHelper('BAD KEY=oops\n2BAD=1\nexport X=1\n=novalue\nGOOD=ok\n'); + expect(r.code).toBe(0); + expect(r.out).toBe('GOOD=ok\n'); + expect(r.err).toMatch(/skipping line 1/); + expect(r.err).toMatch(/skipping line 2/); + expect(r.err).toMatch(/skipping line 3/); + expect(r.err).toMatch(/skipping line 4/); + }); + + test('surrounding double quotes stripped; inner hash is data', () => { + const { out } = runHelper('QUOTED="hello world"\nDB_PASS=p@ss#word\nMIXED="v"\n'); + const lines = out.split('\n').filter(Boolean); + expect(lines).toContain('QUOTED=hello world'); + expect(lines).toContain('DB_PASS=p@ss#word'); + expect(lines).toContain('MIXED=v'); + }); + + test('injection-looking value stays inert text (never executed)', () => { + const marker = path.join(os.tmpdir(), 'opflags-PWNED-' + Date.now()); + const { out } = runHelper(`X=$(touch ${marker})\nY=; touch ${marker}\n`); + expect(out).toContain(`X=$(touch ${marker})`); + expect(out).toContain(`Y=; touch ${marker}`); + expect(fs.existsSync(marker)).toBe(false); + }); + + test('last line without trailing newline still emitted', () => { + const { out } = runHelper('A=1\nB=2'); + expect(out).toBe('A=1\nB=2\n'); + }); + + test('empty file → no output, exit 0', () => { + const { out, code } = runHelper(''); + expect(code).toBe(0); + expect(out).toBe(''); + }); +}); + +// ---- rev2: ENFORCED security contract (judge blocker B1) ---- +// These run only as root (uid 0) because they create world-readable, +// wrong-owner, and symlinked fixtures; on a non-root CI they skip. +const rootSuite = (typeof process.getuid === 'function' && process.getuid() === 0) ? describe : describe.skip; +rootSuite('DC-164 operator-flags.sh security contract', () => { + function rawRun(file) { + const r = spawnSync('bash', [HELPER, file], { encoding: 'utf8' }); + return { out: r.stdout || '', err: r.stderr || '', code: r.status }; + } + + test('group/world-readable mode → exit 3, FATAL, no output', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-sec-')); + const file = path.join(dir, 'operator.env'); + fs.writeFileSync(file, 'ALLOW_LIFETIME_LICENSE=true\n', { mode: 0o644 }); + fs.chmodSync(file, 0o644); + const r = rawRun(file); + expect(r.code).toBe(3); + expect(r.out).toBe(''); + expect(r.err).toMatch(/FATAL.*mode 644/); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('setuid bit even with 600 user bits → exit 3', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-sec-')); + const file = path.join(dir, 'operator.env'); + fs.writeFileSync(file, 'A=1\n'); + fs.chmodSync(file, 0o4600); + const r = rawRun(file); + expect(r.code).toBe(3); + expect(r.err).toMatch(/FATAL/); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('symlink (even to a valid root 0600 file) → exit 3', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-sec-')); + const real = path.join(dir, 'real.env'); + const link = path.join(dir, 'operator.env'); + fs.writeFileSync(real, 'A=1\n', { mode: 0o600 }); + fs.symlinkSync(real, link); + const r = rawRun(link); + expect(r.code).toBe(3); + expect(r.out).toBe(''); + expect(r.err).toMatch(/symlink/); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('wrong owner (uid != 0 while running as root) → exit 3', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-sec-')); + const file = path.join(dir, 'operator.env'); + fs.writeFileSync(file, 'A=1\n', { mode: 0o600 }); + const nobody = 65534; + fs.chownSync(file, nobody, nobody); + const r = rawRun(file); + expect(r.code).toBe(3); + expect(r.err).toMatch(/owned by uid/); + fs.chownSync(file, 0, 0); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('directory passed as operator file → exit 3 (not a regular file)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-sec-')); + const d2 = path.join(dir, 'operator.env'); + fs.mkdirSync(d2); + const r = rawRun(d2); + expect(r.code).toBe(3); + expect(r.err).toMatch(/not a regular file/); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('root-owned 0600 regular file → exit 0 (contract satisfied)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-sec-')); + const file = path.join(dir, 'operator.env'); + fs.writeFileSync(file, 'ALLOW_LIFETIME_LICENSE=true\n', { mode: 0o600 }); + fs.chmodSync(file, 0o600); + const r = rawRun(file); + expect(r.code).toBe(0); + expect(r.out).toBe('ALLOW_LIFETIME_LICENSE=true\n'); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/dashcaddy-api/scripts/operator-flags.sh b/dashcaddy-api/scripts/operator-flags.sh new file mode 100755 index 0000000..c7cc41d --- /dev/null +++ b/dashcaddy-api/scripts/operator-flags.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# DC-164 rev2: operator env overrides → docker "-e KEY=VALUE" flag material. +# +# Reads an operator-owned env file (default /etc/dashcaddy/operator.env) and +# prints one validated KEY=VALUE line per allowed entry, for start.sh to +# forward into the container. NOTHING in the file is sourced or executed — +# values pass through as literal strings; lines with invalid key names are +# skipped with a warning. Missing file → exit 0 with no output (the normal +# customer case: zero overrides). +# +# SECURITY CONTRACT (rev2 judge blocker — ENFORCED, not documentation-only): +# the file must be a regular file (not a symlink), owned by uid 0, and its +# mode must grant NEITHER group nor other access (perm & 077 == 0) with no +# setuid/setgid/sticky bits. Violation → stderr FATAL + exit 3 and start.sh +# forwards NOTHING. This is what makes the lifetime override creator-only +# by construction: only the host's root can place a file that passes; a +# group/world-readable or symlinked "operator.env" is refused even if +# $OPERATOR_ENV_FILE were pointed at an arbitrary path. +# +# Key rule: [A-Za-z_][A-Za-z0-9_]* optionally surrounded by spaces, then '='. +# Value: everything after the first '=' verbatim (a '#' inside a value is +# data, not a comment). Only a line whose FIRST non-space char is '#' is a +# comment. Surrounding double quotes on the value are stripped. +# +# Usage: operator-flags.sh [FILE] +# Exit: 0 = ok (possibly zero lines) | 3 = operator-file contract violated +set -euo pipefail + +FILE="${1:-/etc/dashcaddy/operator.env}" + +[ -e "$FILE" ] || exit 0 # absent = no overrides (normal) +if [ -L "$FILE" ]; then + echo "operator-flags: FATAL — $FILE is a symlink; refusing" >&2 + exit 3 +fi +if [ ! -f "$FILE" ]; then + echo "operator-flags: FATAL — $FILE is not a regular file; refusing" >&2 + exit 3 +fi +OWNER_MODE=$(stat -c '%u %a' "$FILE") +OWNER_UID="${OWNER_MODE%% *}" +MODE_OCT="${OWNER_MODE##* }" +# Ownership rule, context-dependent (the property that matters is: the file +# must not be controllable by an identity OTHER than the one reading it): +# - euid 0 (the ONLY supported runtime — start.sh runs docker as root): +# owner MUST be uid 0. No user-writable file can smuggle flags. +# - non-root: owner must equal the effective uid (cannot consume another +# user's file). Keeps the helper honest and unit-testable off-root CI. +EUID_=$(id -u) +if [ "$EUID_" = "0" ]; then + if [ "$OWNER_UID" != "0" ]; then + echo "operator-flags: FATAL — $FILE owned by uid $OWNER_UID (root requires uid 0)" >&2 + exit 3 + fi +else + if [ "$OWNER_UID" != "$EUID_" ]; then + echo "operator-flags: FATAL — $FILE owned by uid $OWNER_UID, not the invoking uid $EUID_" >&2 + exit 3 + fi +fi +MODE_INT=$((8#$MODE_OCT)) +if [ $((MODE_INT & 8#077)) -ne 0 ] || [ $((MODE_INT & 8#7000)) -ne 0 ]; then + echo "operator-flags: FATAL — $FILE mode $MODE_OCT grants group/other or special bits (contract: e.g. 0600)" >&2 + exit 3 +fi + +lineno=0 +while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + trimmed="${line#"${line%%[![:space:]]*}"}" # ltrim + [ -z "$trimmed" ] && continue # blank + case "$trimmed" in + \#*) continue ;; # comment + esac + if [[ "$trimmed" =~ ^([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=(.*)$ ]]; then + key="${BASH_REMATCH[1]}" + value="${BASH_REMATCH[2]}" + value="${value#"${value%%[![:space:]]*}"}" # ltrim value + case "$value" in + \"*\") value="${value#\"}"; value="${value%\"}" ;; + esac + printf '%s=%s\n' "$key" "$value" + else + echo "operator-flags: skipping line $lineno (not KEY=VALUE): ${trimmed:0:60}" >&2 + fi +done < "$FILE" diff --git a/start.sh b/start.sh index 53fb313..d948014 100755 --- a/start.sh +++ b/start.sh @@ -33,6 +33,31 @@ DNS_PRIMARY="100.121.150.22" # Technitium (Tailscale IP) — resolves *.sami # is correct. DNS_FALLBACK="100.71.97.12" # DNS1 Technitium secondary — serves *.sami + recurses +# --- DC-164 rev2: operator env overrides -------------------------------------- +# /etc/dashcaddy/operator.env is forwarded into the container as -e flags via +# scripts/operator-flags.sh. The helper ENFORCES a security contract (regular +# file, uid 0 owner, mode 0600-style, no symlink): violation => exit 3, NO +# flags forwarded (fail closed; stderr explains). The canonical use: +# ALLOW_LIFETIME_LICENSE=true on the CREATOR's host only — the lifetime +# code tier is rejected everywhere else (license-manager keeps that gate). +# Missing file / no overrides = normal customer case (zero flags added). +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OPERATOR_ENV_FILE="${OPERATOR_ENV_FILE:-/etc/dashcaddy/operator.env}" +OPERATOR_FLAGS=() +OP_HELPER="$SCRIPT_DIR/dashcaddy-api/scripts/operator-flags.sh" +if [ -f "$OP_HELPER" ] && [ -e "$OPERATOR_ENV_FILE" ]; then + # single execution; stdout captured, rc inspected explicitly + OP_RAW="$(bash "$OP_HELPER" "$OPERATOR_ENV_FILE")" || OP_RC=$?; OP_RC=${OP_RC:-0} + if [ "$OP_RC" -eq 3 ]; then + echo "[start.sh] WARNING: operator env file rejected by contract check — forwarding NO overrides" >&2 + elif [ -n "$OP_RAW" ]; then + while IFS= read -r kv; do + [ -n "$kv" ] && OPERATOR_FLAGS+=(-e "$kv") + done <<< "$OP_RAW" + echo "[start.sh] operator.env: $(( ${#OPERATOR_FLAGS[@]} / 2 )) items forwarded (keys only: $(printf '%s\n' "${OPERATOR_FLAGS[@]}" | grep -v '^-e$' | cut -d= -f1 | paste -sd, -))" + fi +fi + # --- One-time migration from Docker image layer to bind mount -------------- # DC-039 follow-up. Before v1.14.10, certain modules (audit-logger, license- # keygen, credential-manager) defaulted their files to /app/src/* via @@ -220,4 +245,5 @@ docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \ -e SHIPDECK_BRIDGE_URL=http://172.17.0.1:8977 \ -e SHIPDECK_BRIDGE_TOKEN_FILE=/app/data/shipdeck-bridge-token \ -e CA_CERT_PATH=/etc/ssl/sami-ca/root.crt \ + ${OPERATOR_FLAGS[@]+"${OPERATOR_FLAGS[@]}"} \ ${IMAGE} \ No newline at end of file