#!/usr/bin/env bash set -uo pipefail # CASAN Plan-16 SEC-28 (X-04) — path-traversal / symlink guard. # # A tool that takes a file path as input/output can be pointed at an arbitrary # location via `..` or a symlink (e.g. a symlink named "input.txt" -> /etc/passwd), # reading or writing outside the workspace. This resolves the REAL path (following # every symlink) and refuses anything that escapes the allowed root. # # Usage: path-guard.sh [allowed-root] (default root: repo workspace) # Exit: 0 inside the root, 1 outside / unresolvable. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/casan-paths.sh" PROJECT_ROOT="$CASAN_APP_ROOT" PYTHON_BIN="${CASAN_PYTHON_BIN:-}" if [[ -z "$PYTHON_BIN" ]]; then for candidate in /usr/bin/python3 python3 python; do if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --version >/dev/null 2>&1; then PYTHON_BIN="$candidate" break fi done fi [[ -n "$PYTHON_BIN" ]] || { echo "PATH_GUARD_RUNTIME_UNAVAILABLE" >&2; exit 69; } TARGET="${1:-}" ROOT="${2:-$PROJECT_ROOT}" if [[ -z "$TARGET" ]]; then echo "Usage: path-guard.sh [allowed-root]" >&2 exit 64 fi "$PYTHON_BIN" - "$TARGET" "$ROOT" <<'PY' import os import sys target, root = sys.argv[1], sys.argv[2] # realpath resolves symlinks in every existing path component and normalizes ".."; # for a not-yet-created leaf it resolves the existing parent chain. real_target = os.path.realpath(target) real_root = os.path.realpath(root) if real_target == real_root or real_target.startswith(real_root + os.sep): print(f"PATH_OK {real_target}") sys.exit(0) sys.stderr.write(f"PATH_ESCAPES_ROOT target={target} real={real_target} root={real_root}\n") sys.exit(1) PY