- P0 path-traversal fix: run-name class is dot-free with \z anchors, so '.', '..', and %0A-trailing values can never escape TRIMSP_TMPROOT. The deployed path allowlist is derived from the configured root instead of the old hardcoded /tmp prefix. - Replacing external with an alarm() watchdog that kills the engine's whole process group (setpgrp + kill KILL -pid), matching muSRFlow; describe_result maps -2/-1/signal to 124/127/128+sig. - prune_stale_jobs() ages out dirs (TrimSP.cfg marker only), orphaned .tgz, and leaked per-request logs; safe in shared /tmp (symlinks skipped, remove_tree safe mode, never touches non-trimsp dirs). - Archive is serialized per run with flock (.trimsp-lock); per-point engine runs stay concurrent. - Symlink/non-dir run dirs refused; mode 0700 so results are not world-readable. read/download also reject symlinked targets. - Request caps: MAX_REQUEST_LINES 20000, MAX_REQUEST_BYTES 4 MiB -> 413. - action=health deployment self-check; env overrides TRIMSP_BIN, TMPROOT, CMD_TIMEOUT, JOB_MAXAGE (sed anchor for the binary path kept intact). - Wire contract preserved: ok/phase/message/exitCode/archiveUrl/log JSON, text read, gzip download, real HTTP statuses. No TrimSPweb.js change. - New tests/cgi-tests.sh drives the real CGI offline (18 cases: traversal, %0A, escaping round-trip, success/fail/hang engine, 124 watchdog, archive+- download, pruner, symlink refusal, 413). deploy-web.sh now verifies the sed substitution embedded the selected engine path.
324 lines
11 KiB
Bash
324 lines
11 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# CGI hardening tests for singleTrimSP.cgi.
|
|
#
|
|
# Drives the real script directly (no web server) using REQUEST_METHOD /
|
|
# CONTENT_LENGTH / QUERY_STRING env vars, exactly like a CGI runtime would.
|
|
# A fake trimspNL engine provides success/fail/hang behavior so the watchdog,
|
|
# logging, and archive paths can be exercised deterministically.
|
|
#
|
|
# Usage: tests/cgi-tests.sh
|
|
# Exits non-zero on the first failing test.
|
|
|
|
set -u
|
|
|
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
CGI="$HERE/../singleTrimSP.cgi"
|
|
ROOT_REPO="$(cd "$HERE/.." && pwd)"
|
|
|
|
TMP="$(mktemp -d)"
|
|
trap 'rm -rf "$TMP"' EXIT
|
|
|
|
ROOT="$TMP/root" # TRIMSP_TMPROOT for every request
|
|
mkdir -p "$ROOT"
|
|
|
|
BIN="$TMP/bin/trimspNL" # fake engine
|
|
mkdir -p "$TMP/bin"
|
|
|
|
cat > "$BIN" <<'EOF'
|
|
#!/bin/sh
|
|
case "${FAKE_MODE:-ok}" in
|
|
fail)
|
|
echo "fake engine failed as requested" 1>&2
|
|
exit 3
|
|
;;
|
|
hang)
|
|
echo "$$" > "$FAKE_PIDFILE"
|
|
sleep 120
|
|
exit 0
|
|
;;
|
|
ok)
|
|
base="$1"
|
|
printf '# col1 col2\n1 5\n2 10\n' > "$base.rge"
|
|
printf 'SEQ %s line1\nSEQ %s line2\n' "$base" "$base" > "$base.seq"
|
|
echo "fake engine completed $base"
|
|
exit 0
|
|
;;
|
|
esac
|
|
EOF
|
|
chmod +x "$BIN"
|
|
|
|
PASS=0
|
|
FAIL=0
|
|
|
|
note() { printf '\n== %s\n' "$*"; }
|
|
ok() { PASS=$((PASS+1)); printf ' PASS: %s\n' "$*"; }
|
|
bad() { FAIL=$((FAIL+1)); printf ' FAIL: %s\n' "$*"; }
|
|
|
|
# run_request METHOD QUERY_STRING BODY_FILE -> sets RUN_HEADERS, RUN_BODY, RUN_STATUS
|
|
run_request() {
|
|
local method="$1" query="$2" body_file="$3"
|
|
local in_redir="/dev/null"
|
|
local BODY_LEN=0
|
|
if [ "$method" = "POST" ] && [ -n "$body_file" ]; then
|
|
BODY_LEN="$(wc -c < "$body_file")"
|
|
in_redir="$body_file"
|
|
fi
|
|
local out
|
|
out="$(REQUEST_METHOD="$method" \
|
|
CONTENT_TYPE="application/x-www-form-urlencoded" \
|
|
CONTENT_LENGTH="$BODY_LEN" \
|
|
QUERY_STRING="$query" \
|
|
TRIMSP_TMPROOT="$ROOT" \
|
|
TRIMSP_BIN="$BIN" \
|
|
TRIMSP_CMD_TIMEOUT="4" \
|
|
TRIMSP_JOB_MAXAGE="86400" \
|
|
/usr/bin/perl -X "$CGI" < "$in_redir" 2>&1)"
|
|
# Split header block from body.
|
|
RUN_HEADERS="$(printf '%s\n' "$out" | sed -n '1,/^\r*$/p' | head -n -1)"
|
|
RUN_BODY="$(printf '%s\n' "$out" | sed -n '/^\r*$/,$p' | tail -n +1)"
|
|
# Without a status line the response is implicitly 200.
|
|
if printf '%s' "$RUN_HEADERS" | grep -q '^Status:'; then
|
|
RUN_STATUS="$(printf '%s' "$RUN_HEADERS" | sed -n 's/^Status: *\([0-9][0-9][0-9]\).*/\1/p' | head -1)"
|
|
else
|
|
RUN_STATUS="200"
|
|
fi
|
|
}
|
|
|
|
body_contains() { printf '%s' "$RUN_BODY" | grep -q "$1"; }
|
|
|
|
# Percent-encode a value the way the browser's URLSearchParams does.
|
|
urlencode() {
|
|
printf '%s' "$1" | perl -pe 's/([^A-Za-z0-9._~-])/sprintf("%%%02X", ord($1))/ge'
|
|
}
|
|
|
|
# The CGI derives its path allowlist from TRIMSP_TMPROOT, so test bodies must
|
|
# reference the configured root (default /tmp in production).
|
|
FN_ROOT="$ROOT"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
note "health"
|
|
run_request GET "action=health" ""
|
|
if body_contains '"ok":true' && body_contains '"commandTimeoutSeconds"'; then
|
|
ok "health reports ok + settings"
|
|
else
|
|
bad "health response: $RUN_BODY"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
note "path traversal is rejected"
|
|
TRAV="$TMP/trav.txt"
|
|
printf 'action=run&fn=%s&line0=x\n' "$(urlencode "$FN_ROOT/../../escape/x.inp")" > "$TRAV"
|
|
run_request POST "" "$TRAV"
|
|
if [ "$RUN_STATUS" = "400" ] && body_contains 'Invalid simulation filename'; then
|
|
ok "fn contains /../ component -> 400"
|
|
else
|
|
bad "traversal not rejected (status=$RUN_STATUS body=$RUN_BODY)"
|
|
fi
|
|
|
|
printf 'action=run&fn=%s&line0=x\n' "$(urlencode "$FN_ROOT/../x.inp")" > "$TRAV"
|
|
run_request POST "" "$TRAV"
|
|
if [ "$RUN_STATUS" = "400" ]; then
|
|
ok "single-level /../ in run component -> 400"
|
|
else
|
|
bad "single-level traversal not rejected (status=$RUN_STATUS body=$RUN_BODY)"
|
|
fi
|
|
|
|
printf 'action=run&fn=%s&line0=x\n' "$(urlencode "$FN_ROOT/run12345/x.inp")%0A" > "$TRAV"
|
|
run_request POST "" "$TRAV"
|
|
if [ "$RUN_STATUS" = "400" ]; then
|
|
ok "trailing newline (%0A) in fn rejected"
|
|
else
|
|
bad "%0A tail not rejected (status=$RUN_STATUS body=$RUN_BODY)"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
note "write/read round-trip with special characters"
|
|
RUN=round1234
|
|
CFG="$TMP/cfgbody"
|
|
printf 'fn=%s&line0=%s&line1=%s\n' \
|
|
"$(urlencode "$FN_ROOT/$RUN/TrimSP.cfg")" \
|
|
"ProjectileParameters" \
|
|
"$(urlencode 'val=100¬e=semi+colon%,#hash')" \
|
|
> "$CFG"
|
|
run_request POST "" "$CFG"
|
|
if [ "$RUN_STATUS" = "200" ] && body_contains '"phase":"save"'; then
|
|
ok "TrimSP.cfg written (phase=save)"
|
|
else
|
|
bad "cfg write failed (status=$RUN_STATUS body=$RUN_BODY)"
|
|
fi
|
|
|
|
run_request GET "action=read&fn=$(urlencode "$FN_ROOT/$RUN/TrimSP.cfg")" ""
|
|
if body_contains 'val=100¬e=semi+colon%,#hash'; then
|
|
ok "read-back matches decoded content exactly (escaped +,%,&,#,=)"
|
|
else
|
|
bad "read-back mismatch: $RUN_BODY"
|
|
fi
|
|
|
|
# Invalid read path (symlink-leading component)
|
|
ln -s /etc "$ROOT/$RUN/linkdir" 2>/dev/null || true
|
|
run_request GET "action=read&fn=$(urlencode "$FN_ROOT/$RUN/linkdir")" ""
|
|
if [ "$RUN_STATUS" = "404" ]; then
|
|
ok "read of non-regular file -> 404"
|
|
else
|
|
bad "read of symlink not rejected (status=$RUN_STATUS)"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
note "engine success"
|
|
run_request POST "" "$TMP/cfgbody" # ensure run dir exists
|
|
INP="$TMP/inpbody"
|
|
printf 'fn=%s&rngType=pcg32&line0=X\n' "$(urlencode "$FN_ROOT/$RUN/${RUN}_E100.inp")" > "$INP"
|
|
FAKE_MODE=ok run_request POST "" "$INP"
|
|
if body_contains '"exitCode":0' && body_contains '"ok":true'; then
|
|
ok "engine completed OK"
|
|
else
|
|
bad "engine success not reported (body=$RUN_BODY)"
|
|
fi
|
|
|
|
# The .seq file the fake engine wrote must be readable.
|
|
run_request GET "action=read&fn=$(urlencode "$FN_ROOT/$RUN/${RUN}_E100.seq")" ""
|
|
if body_contains 'SEQ'; then
|
|
ok "engine output .seq readable"
|
|
else
|
|
bad "engine .seq not readable (status=$RUN_STATUS body=$RUN_BODY)"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
note "engine failure is reported, not silently ignored"
|
|
FAILINP="$TMP/failbody"
|
|
printf 'fn=%s&rngType=pcg32&line0=X\n' "$(urlencode "$FN_ROOT/$RUN/${RUN}_E200.inp")" > "$FAILINP"
|
|
FAKE_MODE=fail run_request POST "" "$FAILINP"
|
|
if body_contains '"exitCode":3' && body_contains '"ok":false'; then
|
|
ok "engine failure -> exitCode 3, ok=false"
|
|
else
|
|
bad "engine failure misreported (body=$RUN_BODY)"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
note "watchdog kills a hung engine (alarm + process-group kill)"
|
|
HANGINP="$TMP/hangbody"
|
|
printf 'fn=%s&rngType=pcg32&line0=X\n' "$(urlencode "$FN_ROOT/$RUN/${RUN}_E300.inp")" > "$HANGINP"
|
|
HANG_PIDF="$TMP/pid.txt"
|
|
FAKE_MODE=hang FAKE_PIDFILE="$HANG_PIDF" TRIMSP_CMD_TIMEOUT="2" run_request POST "" "$HANGINP"
|
|
if ! [ -e "$HANG_PIDF" ]; then
|
|
bad "hang engine never recorded a pid (test setup broken)"
|
|
else
|
|
DEAD="$(kill -0 "$(cat "$HANG_PIDF")" 2>/dev/null && echo no || echo yes)"
|
|
if body_contains '"exitCode":124' && [ "$DEAD" = "yes" ]; then
|
|
ok "hung engine timed out -> exitCode 124, process group reaped"
|
|
else
|
|
bad "watchdog did not fire correctly (exit=$RUN_BODY dead=$DEAD)"
|
|
fi
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
note "archive + download"
|
|
# The sequence file triggers the archive of the whole run directory.
|
|
SEQ="$TMP/seqbody"
|
|
printf 'fn=%s&line0=S\n' "$(urlencode "$FN_ROOT/$RUN/${RUN}_Seq_Results.dat")" > "$SEQ"
|
|
run_request POST "" "$SEQ"
|
|
if body_contains '"archiveUrl"' && body_contains '"phase":"archive"' && [ ! -d "$ROOT/$RUN" ]; then
|
|
ok "archive created and run directory removed"
|
|
else
|
|
bad "archive flow broken (body=$RUN_BODY dir_gone=$( [ ! -d "$ROOT/$RUN" ] && echo yes || echo no))"
|
|
fi
|
|
|
|
# Binary download: run through a file so NUL bytes survive.
|
|
REQ="$TMP/dlreq"
|
|
{
|
|
printf 'action=download&run=%s' "$RUN"
|
|
printf '\n'
|
|
} > "$REQ"
|
|
DL="$TMP/dl.out"
|
|
REQUEST_METHOD=GET CONTENT_TYPE="application/x-www-form-urlencoded" \
|
|
CONTENT_LENGTH=0 QUERY_STRING="action=download&run=$RUN" \
|
|
TRIMSP_TMPROOT="$ROOT" TRIMSP_BIN="$BIN" \
|
|
TRIMSP_CMD_TIMEOUT="4" TRIMSP_JOB_MAXAGE="86400" \
|
|
/usr/bin/perl -X "$CGI" < /dev/null > "$DL" 2>&1
|
|
if grep -a '^Status: 404' "$DL" >/dev/null; then
|
|
bad "download 404 for archived run"
|
|
elif grep -a '^Status:' "$DL" >/dev/null; then
|
|
bad "download unexpected error ($(grep -a '^Status:' "$DL" | head -1))"
|
|
else
|
|
DL_BODY="$TMP/dl.body"
|
|
perl -0777 -e '
|
|
local $/; my $raw = <>;
|
|
my ($body) = $raw =~ /^.*?\r?\n\r?\n(.*)\z/s;
|
|
print STDOUT $body' "$DL" > "$DL_BODY"
|
|
if [ "$(dd if="$DL_BODY" bs=1 count=2 2>/dev/null | od -An -tx1 | tr -d ' \n')" = "1f8b" ]; then
|
|
ok "download returns a gzip stream"
|
|
else
|
|
bad "download did not return gzip (hex=$(dd if="$DL_BODY" bs=1 count=8 2>/dev/null | od -An -tx1 | tr -d ' \n'))"
|
|
fi
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
note "pruner removes stale run dirs and keeps fresh ones"
|
|
STALE=stale0001
|
|
FRESH=fresh0001
|
|
mkdir -p "$ROOT/$STALE" "$ROOT/$FRESH"
|
|
printf 'cfg\n' > "$ROOT/$STALE/TrimSP.cfg"
|
|
printf 'x\n' > "$ROOT/$STALE/extra"
|
|
printf 'cfg\n' > "$ROOT/$FRESH/TrimSP.cfg"
|
|
# Backdate AFTER populating so the mtime that matters (dir mtime) is stale.
|
|
touch -d "2000-01-01 00:00:00" "$ROOT/$STALE"
|
|
run_request GET "action=health" ""
|
|
if [ ! -d "$ROOT/$STALE" ] && [ -d "$ROOT/$FRESH" ]; then
|
|
ok "pruner removed stale run dir, kept fresh one"
|
|
else
|
|
bad "pruner misbehaved (stale_gone=$( [ ! -d "$ROOT/$STALE" ] && echo yes || echo no) fresh_kept=$( [ -d "$ROOT/$FRESH" ] && echo yes || echo no))"
|
|
fi
|
|
|
|
# Orphaned archive pruned.
|
|
touch -d "2000-01-01 00:00:00" "$ROOT/orphan1234.tgz"
|
|
run_request GET "action=health" ""
|
|
if [ ! -e "$ROOT/orphan1234.tgz" ]; then
|
|
ok "pruner removed orphaned archive"
|
|
else
|
|
bad "orphaned archive not pruned"
|
|
fi
|
|
|
|
# Non-trimsp directory in the shared root is never touched.
|
|
OTHER="$ROOT/other-user-000"
|
|
mkdir -p "$OTHER"
|
|
touch -d "2000-01-01 00:00:00" "$OTHER"
|
|
run_request GET "action=health" ""
|
|
if [ -d "$OTHER" ]; then
|
|
ok "unrelated directory left untouched"
|
|
else
|
|
bad "pruner removed unrelated directory"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
note "symlinked run directory is refused"
|
|
mkdir -p "$TMP/real_dir"
|
|
ln -s "$TMP/real_dir" "$ROOT/symlnk001"
|
|
SYM="$TMP/symbody"
|
|
printf 'fn=%s&line0=x\n' "$(urlencode "$FN_ROOT/symlnk001/TrimSP.cfg")" > "$SYM"
|
|
run_request POST "" "$SYM"
|
|
if [ "$RUN_STATUS" = "500" ] && body_contains 'plain directory'; then
|
|
ok "symlinked run directory refused"
|
|
else
|
|
bad "symlinked run dir accepted (status=$RUN_STATUS body=$RUN_BODY)"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
note "request size cap"
|
|
# The real client joins submitted lines on one body line with '&' separators.
|
|
BIG="$TMP/bigbody"
|
|
{
|
|
printf 'fn=%s&rngType=pcg32&line0=z' "$(urlencode "$FN_ROOT/bigrun001/x.inp")"
|
|
for i in $(seq 1 20001); do printf '&line%d=z' "$i"; done
|
|
printf '\n'
|
|
} > "$BIG"
|
|
run_request POST "" "$BIG"
|
|
if [ "$RUN_STATUS" = "413" ]; then
|
|
ok "oversized request -> 413"
|
|
else
|
|
bad "oversized request not capped (status=$RUN_STATUS body=$RUN_BODY)"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
|
|
[ "$FAIL" -eq 0 ] |