#!/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 ]