clawdforge/clients/bash/test/test_cf.sh
Kayos 7ba7058cd5 clients/bash: apply audit findings — security hardening + correctness fixes (347fdde → new)
Security:
- S1: bearer via tmpfile/--config, not cmdline arg (no /proc/<pid>/cmdline leak)
- S2/S3: JSON-escape user input in --files, --ip-cidrs, token name
- S4: URL-encode token name in revoke
- S5: refuse to source cf.env unless 0600/0400 + owner-matched
- S6: reject ; in upload paths to defeat curl @ filename injection

Correctness:
- B1: refuse cf run - on TTY stdin
- B2: replace fragile files splice with proper JSON-array composer (raw: passthrough in _json_obj_from_assoc)
- B3: disable glob on comma-split (set -f around loop)
- B4: only create stdin tmpfile when actually used
- B5: EXIT trap (was RETURN; missed _die exit)
- B6/B7: --max-time + stderr capture on uploads
- B8: drop bare Bearer header on healthz when no token
- B9: validate admin subcommand before token
- B10: wire _extract_error into HTTP-error path
- U3: dedicated '# --- end help ---' sentinel for cmd_help

New: clients/bash/test/test_cf.sh (curl wrapper mock + 23 assertions covering
all of the above; fully shellcheck-clean).

Audit: memory/clawdforge-audits/bash-347fdde.md
2026-04-28 23:09:06 -07:00

408 lines
14 KiB
Bash
Executable file

#!/usr/bin/env bash
# test_cf.sh — smoke tests for clients/bash/cf
#
# Strategy: drop a fake `curl` into PATH that records its invocation
# (argv + the contents of any --config <file> + any --data-binary @<file>)
# to a request-shape file, then exercise cf against it. We assert on the
# captured request, not on the real network.
#
# No frameworks, just bash + assertions. Run:
# bash clients/bash/test/test_cf.sh
#
# Exit 0 on all-pass, 1 on first failure.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CF="$HERE/../cf"
[[ -x "$CF" ]] || { echo "fatal: cf not found or not exec at $CF" >&2; exit 1; }
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/bin" "$WORK/cfgdir"
REQ_LOG="$WORK/req.log"
# Fake curl. Captures everything the SUT passes:
# - argv (one per line, separator __ARGV__)
# - contents of --config <file> if present (separator __CONFIG__)
# - contents of --data-binary @<file> if present (separator __DATAFILE__)
# Then prints a stub successful HTTP response on stdout and exits 0.
cat > "$WORK/bin/curl" <<'FAKE_CURL'
#!/usr/bin/env bash
log="${CF_TEST_REQ_LOG:?must export CF_TEST_REQ_LOG}"
{
echo "__ARGV_COUNT__=$#"
for a in "$@"; do
printf '__ARGV__=%s\n' "$a"
done
# Walk the args looking for --config <file> and --data-binary @<file>
prev=""
for a in "$@"; do
if [[ "$prev" == "--config" ]]; then
echo "__CONFIG_FILE__=$a"
if [[ -f "$a" ]]; then
echo "__CONFIG_CONTENTS__"
cat "$a"
echo "__END_CONFIG__"
fi
fi
if [[ "$prev" == "--data-binary" && "$a" == @* ]]; then
df="${a#@}"
echo "__DATAFILE__=$df"
if [[ -f "$df" ]]; then
echo "__DATAFILE_CONTENTS__"
cat "$df"
echo "__END_DATAFILE__"
fi
fi
if [[ "$prev" == "--data-binary" && "$a" != @* ]]; then
echo "__DATA_INLINE__"
printf '%s' "$a"
echo
echo "__END_DATA_INLINE__"
fi
prev="$a"
done
} > "$log"
# Stub a response body and the status marker that cf parses.
# Use a body that's valid JSON so _extract_error doesn't trip.
printf '%s\n__cf_status__=200' '{"ok":true}'
exit 0
FAKE_CURL
chmod +x "$WORK/bin/curl"
# Make a writable cf.env baseline (chmod 600). Tests that need a different
# perms set will rewrite this.
CF_ENV="$WORK/cfgdir/clawdforge/cf.env"
mkdir -p "$(dirname "$CF_ENV")"
cat > "$CF_ENV" <<EOF
CLAWDFORGE_URL=http://test.invalid:8800
CLAWDFORGE_TOKEN=tok-secret-xyz
CLAWDFORGE_ADMIN_TOKEN=admin-tok-abc
EOF
chmod 600 "$CF_ENV"
# Common env for invocations that should source cf.env from our scratch dir.
run_cf() {
XDG_CONFIG_HOME="$WORK/cfgdir" \
CF_TEST_REQ_LOG="$REQ_LOG" \
PATH="$WORK/bin:$PATH" \
"$CF" "$@"
}
# Like run_cf but skips loading our scratch cf.env (env vars passed inline).
run_cf_no_env() {
HOME="$WORK/no-such-home" \
XDG_CONFIG_HOME="$WORK/no-such-cfg" \
CF_TEST_REQ_LOG="$REQ_LOG" \
PATH="$WORK/bin:$PATH" \
"$CF" "$@"
}
PASS=0
FAIL=0
fail() {
echo "FAIL: $*" >&2
FAIL=$((FAIL+1))
}
pass() {
echo "PASS: $*"
PASS=$((PASS+1))
}
assert_contains() {
local needle="$1" hay="$2" name="$3"
if [[ "$hay" == *"$needle"* ]]; then
pass "$name"
else
fail "$name — expected to find: $needle"
echo "--- haystack ---" >&2
printf '%s\n' "$hay" >&2
echo "--- end ---" >&2
fi
}
assert_not_contains() {
local needle="$1" hay="$2" name="$3"
if [[ "$hay" != *"$needle"* ]]; then
pass "$name"
else
fail "$name — found unexpectedly: $needle"
echo "--- haystack ---" >&2
printf '%s\n' "$hay" >&2
echo "--- end ---" >&2
fi
}
# ---- Test 1: token NOT in argv (S1) --------------------------------------
echo "# Test 1: bearer token not in argv"
: > "$REQ_LOG"
run_cf healthz >/dev/null
log="$(cat "$REQ_LOG")"
# Extract argv lines only
argv_only="$(grep '^__ARGV__=' "$REQ_LOG" || true)"
assert_not_contains "tok-secret-xyz" "$argv_only" "T1.argv-no-token"
# But the bearer should appear inside the captured --config file contents.
assert_contains "Bearer tok-secret-xyz" "$log" "T1.config-has-bearer"
# ---- Test 2: JSON-injection in --files (S2) ------------------------------
echo "# Test 2: JSON injection via --files"
# Comma is the documented separator for --files, so per-token injection
# attempts must use other characters (quote, backslash, etc.). Each token
# MUST be JSON-escaped, and the resulting body MUST remain a valid object
# with no smuggled top-level keys.
: > "$REQ_LOG"
# Single-token injection — quote-escape attempt without a comma.
run_cf run hi --files 'tok_a"]:"extra":"injected' >/dev/null
body=""
in_block=0
while IFS= read -r line; do
if [[ "$line" == "__DATA_INLINE__" ]]; then in_block=1; continue; fi
if [[ "$line" == "__END_DATA_INLINE__" ]]; then in_block=0; continue; fi
if (( in_block )); then body+="$line"$'\n'; fi
done < "$REQ_LOG"
body="${body%$'\n'}"
if command -v jq >/dev/null 2>&1; then
if jq -e '.files | type == "array" and length == 1 and .[0] == "tok_a\"]:\"extra\":\"injected"' >/dev/null <<<"$body" 2>/dev/null; then
pass "T2.files-jq-roundtrip"
else
fail "T2.files-jq-roundtrip — body=$body"
fi
has_extra="$(jq -r 'has("extra")' <<<"$body" 2>/dev/null || echo error)"
if [[ "$has_extra" == "false" ]]; then
pass "T2.no-injected-top-key"
else
fail "T2.no-injected-top-key — body=$body"
fi
else
assert_contains 'tok_a\"]:\"extra\":\"injected' "$body" "T2.files-escaped-string"
fi
# Sub-test: even when the input contains commas (intentionally splitting),
# each split piece must still be properly JSON-escaped and the body must
# remain a valid JSON object with no smuggled top-level keys. This is the
# original audit repro 'tok_a","extra":"injected' which DOES contain a
# comma and therefore splits into two tokens.
: > "$REQ_LOG"
run_cf run hi --files 'tok_a","extra":"injected' >/dev/null
body=""
in_block=0
while IFS= read -r line; do
if [[ "$line" == "__DATA_INLINE__" ]]; then in_block=1; continue; fi
if [[ "$line" == "__END_DATA_INLINE__" ]]; then in_block=0; continue; fi
if (( in_block )); then body+="$line"$'\n'; fi
done < "$REQ_LOG"
body="${body%$'\n'}"
if command -v jq >/dev/null 2>&1; then
has_extra="$(jq -r 'has("extra")' <<<"$body" 2>/dev/null || echo error)"
if [[ "$has_extra" == "false" ]]; then
pass "T2.audit-repro-no-injection"
else
fail "T2.audit-repro-no-injection — body=$body"
fi
count="$(jq -r '.files | length' <<<"$body" 2>/dev/null || echo err)"
if [[ "$count" == "2" ]]; then
pass "T2.audit-repro-split-2"
else
fail "T2.audit-repro-split-2 — count=$count body=$body"
fi
fi
# Sub-test: comma-separated tokens still split into N array elements.
: > "$REQ_LOG"
run_cf run hi --files 'one,two,three' >/dev/null
body=""
in_block=0
while IFS= read -r line; do
if [[ "$line" == "__DATA_INLINE__" ]]; then in_block=1; continue; fi
if [[ "$line" == "__END_DATA_INLINE__" ]]; then in_block=0; continue; fi
if (( in_block )); then body+="$line"$'\n'; fi
done < "$REQ_LOG"
body="${body%$'\n'}"
if command -v jq >/dev/null 2>&1; then
count="$(jq -r '.files | length' <<<"$body" 2>/dev/null || echo err)"
if [[ "$count" == "3" ]]; then
pass "T2.files-comma-split-contract"
else
fail "T2.files-comma-split-contract — count=$count body=$body"
fi
fi
# ---- Test 3: JSON-injection in admin token-mint name (S3) ----------------
echo "# Test 3: JSON injection via token-mint name"
: > "$REQ_LOG"
run_cf admin token-mint 'evil","admin":true,"x":"x' --ip-cidrs '10.0.0.0/8,192.168.0.0/16' >/dev/null
body=""
in_block=0
while IFS= read -r line; do
if [[ "$line" == "__DATA_INLINE__" ]]; then in_block=1; continue; fi
if [[ "$line" == "__END_DATA_INLINE__" ]]; then in_block=0; continue; fi
if (( in_block )); then body+="$line"$'\n'; fi
done < "$REQ_LOG"
body="${body%$'\n'}"
if command -v jq >/dev/null 2>&1; then
# The smuggled "admin":true MUST NOT appear at top level.
has_admin="$(jq -r 'has("admin")' <<<"$body" 2>/dev/null || echo error)"
if [[ "$has_admin" == "false" ]]; then
pass "T3.no-admin-key-injected"
else
fail "T3.no-admin-key-injected — body=$body"
fi
name_val="$(jq -r '.name' <<<"$body" 2>/dev/null || echo error)"
if [[ "$name_val" == 'evil","admin":true,"x":"x' ]]; then
pass "T3.name-roundtrips"
else
fail "T3.name-roundtrips — got: $name_val"
fi
cidrs_count="$(jq -r '.ip_cidrs | length' <<<"$body" 2>/dev/null || echo error)"
if [[ "$cidrs_count" == "2" ]]; then
pass "T3.cidrs-2-items"
else
fail "T3.cidrs-2-items — body=$body"
fi
else
assert_contains 'evil\",\"admin\":true' "$body" "T3.name-escaped"
fi
# ---- Test 4: URL-encoding in token-revoke (S4) ---------------------------
echo "# Test 4: URL-encoding in token-revoke"
: > "$REQ_LOG"
run_cf admin token-revoke 'foo/../bar' >/dev/null
log="$(cat "$REQ_LOG")"
# Find the URL argv (looks like http://...)
url_line="$(grep '^__ARGV__=http' "$REQ_LOG" || true)"
assert_not_contains "foo/../bar" "$url_line" "T4.no-raw-traversal"
# Either %2F or %2f acceptable.
if [[ "$url_line" == *"foo%2F..%2Fbar"* || "$url_line" == *"foo%2f..%2fbar"* ]]; then
pass "T4.has-url-encoded-slashes"
else
fail "T4.has-url-encoded-slashes — url_line=$url_line"
fi
# ---- Test 5: cf.env perms guard (S5) --------------------------------------
echo "# Test 5: cf.env refused when world-writable"
chmod 644 "$CF_ENV"
set +e
out="$(run_cf healthz 2>&1)"; rc=$?
set -e
chmod 600 "$CF_ENV"
if (( rc != 0 )) && [[ "$out" == *"refusing to source"* ]]; then
pass "T5.refuses-bad-perms"
else
fail "T5.refuses-bad-perms — rc=$rc out=$out"
fi
# And with correct perms, still works.
: > "$REQ_LOG"
if run_cf healthz >/dev/null; then pass "T5.accepts-good-perms"; else fail "T5.accepts-good-perms"; fi
# ---- Test 6: cf run - with TTY stdin errors (B1) -------------------------
echo "# Test 6: cf run - on TTY stdin"
# We can't easily allocate a real TTY in CI; simulate by giving cf a stdin
# that *is* a tty by using script(1) if available; otherwise, fall back to
# invoking cf with /dev/tty (which on most CI is unavailable and would error
# differently). Easiest robust approach: directly test the [[ -t 0 ]] guard
# by feeding stdin from a TTY-like fd. We use `script` from bsdutils.
if command -v script >/dev/null 2>&1; then
set +e
# 'script -qec CMD /dev/null' runs CMD with a pty as stdin/stdout.
out="$(script -qec "XDG_CONFIG_HOME='$WORK/cfgdir' CF_TEST_REQ_LOG='$REQ_LOG' PATH='$WORK/bin:$PATH' '$CF' run -" /dev/null 2>&1)"
rc=$?
set -e
if (( rc != 0 )) && [[ "$out" == *"stdin is a TTY"* ]]; then
pass "T6.tty-refused"
else
fail "T6.tty-refused — rc=$rc out=$out"
fi
else
echo "SKIP: T6.tty-refused — 'script' utility not available"
fi
# Also confirm that piping in works (non-TTY path).
: > "$REQ_LOG"
if echo "hello-piped" | run_cf run - >/dev/null; then pass "T6.pipe-works"; else fail "T6.pipe-works"; fi
# ---- Test 7: glob expansion on --files (B3) ------------------------------
echo "# Test 7: glob expansion on --files"
GLOBDIR="$WORK/globdir"
mkdir -p "$GLOBDIR"
: > "$GLOBDIR/foo1.txt"
: > "$GLOBDIR/foo2.txt"
: > "$REQ_LOG"
( cd "$GLOBDIR" && run_cf run hi --files 'foo*.txt,bar' >/dev/null )
body=""
in_block=0
while IFS= read -r line; do
if [[ "$line" == "__DATA_INLINE__" ]]; then in_block=1; continue; fi
if [[ "$line" == "__END_DATA_INLINE__" ]]; then in_block=0; continue; fi
if (( in_block )); then body+="$line"$'\n'; fi
done < "$REQ_LOG"
body="${body%$'\n'}"
if command -v jq >/dev/null 2>&1; then
count="$(jq -r '.files | length' <<<"$body" 2>/dev/null || echo err)"
first="$(jq -r '.files[0]' <<<"$body" 2>/dev/null || echo err)"
second="$(jq -r '.files[1]' <<<"$body" 2>/dev/null || echo err)"
if [[ "$count" == "2" && "$first" == "foo*.txt" && "$second" == "bar" ]]; then
pass "T7.no-glob-expansion"
else
fail "T7.no-glob-expansion — count=$count first=$first second=$second body=$body"
fi
else
assert_contains '"foo*.txt"' "$body" "T7.no-glob-expansion-asterisk-preserved"
fi
# ---- Test 8: cmd_help works with new sentinel (U3) -----------------------
echo "# Test 8: cmd_help"
out="$(run_cf_no_env help 2>&1 || true)"
assert_contains "cf — bash CLI for clawdforge" "$out" "T8.help-header"
assert_contains "Exit codes" "$out" "T8.help-body"
# Sentinel line itself should NOT appear in the rendered help.
assert_not_contains "end help" "$out" "T8.no-sentinel-leak"
# ---- Test 9: upload path with ';' is rejected (S6) -----------------------
echo "# Test 9: upload semicolon-in-path rejected"
SEMI="$WORK/has;semi.txt"
: > "$SEMI"
set +e
out="$(run_cf upload "$SEMI" 2>&1)"; rc=$?
set -e
if (( rc != 0 )) && [[ "$out" == *"';'"* ]]; then
pass "T9.semi-rejected"
else
fail "T9.semi-rejected — rc=$rc out=$out"
fi
# ---- Test 10: healthz with no token doesn't send empty Bearer (B8) -------
echo "# Test 10: healthz with no token"
: > "$REQ_LOG"
unset_env_run() {
HOME="$WORK/no-such-home" \
XDG_CONFIG_HOME="$WORK/no-such-cfg" \
CF_TEST_REQ_LOG="$REQ_LOG" \
PATH="$WORK/bin:$PATH" \
env -u CLAWDFORGE_TOKEN -u CLAWDFORGE_ADMIN_TOKEN "$CF" "$@"
}
unset_env_run healthz >/dev/null
log="$(cat "$REQ_LOG")"
assert_not_contains "Authorization" "$log" "T10.no-auth-header-without-token"
# ---- Test 11: admin subcommand validated before token (B9) ---------------
echo "# Test 11: admin typo before token"
set +e
out="$(env -u CLAWDFORGE_ADMIN_TOKEN \
HOME="$WORK/no-such-home" \
XDG_CONFIG_HOME="$WORK/no-such-cfg" \
PATH="$WORK/bin:$PATH" \
"$CF" admin token-mintzz 2>&1)"
rc=$?
set -e
if (( rc == 2 )) && [[ "$out" == *"unknown admin subcommand"* ]]; then
pass "T11.subcmd-validated-first"
else
fail "T11.subcmd-validated-first — rc=$rc out=$out"
fi
echo ""
echo "============================="
echo "PASS: $PASS FAIL: $FAIL"
echo "============================="
if (( FAIL > 0 )); then exit 1; fi
exit 0