diff --git a/clients/bash/cf b/clients/bash/cf index eba6d9e..3b5e95f 100755 --- a/clients/bash/cf +++ b/clients/bash/cf @@ -15,6 +15,9 @@ # CLAWDFORGE_TOKEN — required for /run + /files # CLAWDFORGE_ADMIN_TOKEN — required for admin/* subcommands # +# The cf.env file MUST have permissions 0600 or 0400 and be owned by you, +# or cf will refuse to source it. +# # Output: # - successful command → response JSON to stdout (pipe to jq freely) # - errors → message to stderr, non-zero exit code @@ -26,15 +29,48 @@ # 3 missing token # 4 HTTP 4xx (auth / not-found / bad-request) # 5 HTTP 5xx (server / claude failure) +# --- end help --- set -euo pipefail # ---------- config loading ------------------------------------------------- _CFG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/clawdforge/cf.env" + +# S5: Refuse to source cf.env unless perms are 0600/0400 and owner-matched. +_check_cfg_perms() { + local f="$1" mode="" owner="" + if stat -c '%a %U' "$f" >/dev/null 2>&1; then + # GNU stat (Linux) + read -r mode owner < <(stat -c '%a %U' "$f") + elif stat -f '%Lp %Su' "$f" >/dev/null 2>&1; then + # BSD stat (macOS) + read -r mode owner < <(stat -f '%Lp %Su' "$f") + else + echo "cf: cannot stat $f to verify perms — refusing to source" >&2 + return 1 + fi + if [[ "$mode" != "600" && "$mode" != "400" ]]; then + echo "cf: refusing to source $f — perms are $mode (must be 0600 or 0400)" >&2 + echo "cf: fix with: chmod 600 $f" >&2 + return 1 + fi + if [[ "$owner" != "$(id -un)" ]]; then + echo "cf: refusing to source $f — owned by $owner, not $(id -un)" >&2 + return 1 + fi + return 0 +} + if [[ -f "$_CFG_FILE" ]]; then - # shellcheck disable=SC1090 - set -a; . "$_CFG_FILE"; set +a + if _check_cfg_perms "$_CFG_FILE"; then + set -a + # shellcheck disable=SC1090 + . "$_CFG_FILE" + set +a + else + exit 1 + fi fi CLAWDFORGE_URL="${CLAWDFORGE_URL:-http://192.168.0.5:8800}" @@ -52,34 +88,155 @@ _need_admin_token() { [[ -n "${CLAWDFORGE_ADMIN_TOKEN:-}" ]] || _die "CLAWDFORGE_ADMIN_TOKEN not set" 3 } +# JSON-escape a single string value (no surrounding quotes). +_json_escape() { + local v="$1" + v="${v//\\/\\\\}" + v="${v//\"/\\\"}" + v="${v//$'\n'/\\n}" + v="${v//$'\r'/\\r}" + v="${v//$'\t'/\\t}" + printf '%s' "$v" +} + +# Build a JSON array of strings from a comma-separated list. Values are +# JSON-escaped. Globbing is disabled around the comma-split loop. +# Args: comma-separated string +_json_string_array() { + local raw="$1" + if [[ -z "$raw" ]]; then printf '[]'; return 0; fi + local out='[' + local first=1 item esc + # B3: disable globbing on the split loop + set -f + local -a parts=() + local IFS=',' + # shellcheck disable=SC2206 + parts=( $raw ) + set +f + for item in "${parts[@]}"; do + [[ "$first" == 0 ]] && out+=',' + first=0 + esc="$(_json_escape "$item")" + out+="\"$esc\"" + done + out+=']' + printf '%s' "$out" +} + +# URL-encode a string. Prefer jq; fall back to pure-bash. +_url_encode() { + local v="$1" + if command -v jq >/dev/null 2>&1; then + printf '%s' "$v" | jq -sRr @uri + return 0 + fi + local i c out="" + for (( i=0; i<${#v}; i++ )); do + c="${v:i:1}" + case "$c" in + [a-zA-Z0-9._~-]) out+="$c" ;; + *) out+="$(printf '%%%02X' "'$c")" ;; + esac + done + printf '%s' "$out" +} + +# Build a JSON object from --key value flags (each value is JSON-quoted as string). +# Numbers passed as $key=number are kept numeric. Booleans true/false kept bool. +# Special key syntax: +# key=raw:... — value is inserted verbatim (already-JSON, e.g. an array literal) +_json_obj_from_assoc() { + local first=1 out='{' + local kv k v + for kv in "$@"; do + k="${kv%%=*}" + v="${kv#*=}" + if [[ "$first" == 0 ]]; then out+=','; fi + first=0 + local k_esc + k_esc="$(_json_escape "$k")" + out+="\"$k_esc\":" + if [[ "$v" == raw:* ]]; then + out+="${v#raw:}" + elif [[ "$v" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then + out+="$v" + elif [[ "$v" == "true" || "$v" == "false" ]]; then + out+="$v" + else + local esc + esc="$(_json_escape "$v")" + out+="\"$esc\"" + fi + done + out+='}' + printf '%s\n' "$out" +} + +# Print the error body's "error" or "detail" field if it's JSON, else the whole body. +_extract_error() { + local body="$1" + if command -v jq >/dev/null 2>&1; then + local parsed + parsed="$(jq -r '.error // .detail // empty' <<<"$body" 2>/dev/null || true)" + if [[ -n "$parsed" ]]; then + printf '%s' "$parsed" + return 0 + fi + fi + printf '%s' "$body" +} + +# Holds the path of any tmp file created within the current shell process so +# the EXIT trap can clean it up even on _die-driven exits. (B5) +_CF_TMP="" +_cleanup_tmp() { + if [[ -n "${_CF_TMP:-}" && -e "$_CF_TMP" ]]; then + rm -f "$_CF_TMP" + fi +} +trap _cleanup_tmp EXIT + # Fire a curl request, capture body + status, exit on HTTP error. # Args: METHOD URL TOKEN [DATA_OR_- [CONTENT_TYPE]] _request() { local method="$1" url="$2" token="$3" data="${4:-}" ctype="${5:-application/json}" local body status - local tmp="" - tmp="$(mktemp)" - trap '[[ -n "${tmp:-}" ]] && rm -f "$tmp"' RETURN + # S1: pass bearer via curl --config <(...) so the token never appears in argv. + # B4: only mktemp the stdin tmp file when actually needed. local -a curl_args=( -sS -X "$method" "$url" - -H "Authorization: Bearer $token" -H "Accept: application/json" -w '\n__cf_status__=%{http_code}' --max-time 600 ) + # Auth header file (chmod 600). Always created when token is non-empty so + # the bearer never lands in argv via -H. + local auth_file="" + if [[ -n "$token" ]]; then + auth_file="$(mktemp)" + chmod 600 "$auth_file" + # curl --config syntax: 'header = "Authorization: Bearer "' + printf 'header = "Authorization: Bearer %s"\n' "$token" > "$auth_file" + curl_args+=(--config "$auth_file") + fi + if [[ -n "$data" ]]; then if [[ "$data" == "-" ]]; then - cat > "$tmp" - curl_args+=(-H "Content-Type: $ctype" --data-binary "@$tmp") + _CF_TMP="$(mktemp)" + cat > "$_CF_TMP" + curl_args+=(-H "Content-Type: $ctype" --data-binary "@$_CF_TMP") else curl_args+=(-H "Content-Type: $ctype" --data-binary "$data") fi fi - local resp - resp="$(curl "${curl_args[@]}" 2>&1)" || _die "curl failed: $resp" 1 + local resp rc=0 + resp="$(curl "${curl_args[@]}" 2>&1)" || rc=$? + if [[ -n "$auth_file" ]]; then rm -f "$auth_file"; fi + if (( rc != 0 )); then _die "curl failed: $resp" 1; fi status="${resp##*__cf_status__=}" body="${resp%__cf_status__=*}" @@ -89,54 +246,25 @@ _request() { printf '%s\n' "$body" return 0 fi + # B10: surface parsed .error/.detail messages instead of raw body. echo "cf: HTTP $status" >&2 - printf '%s\n' "$body" >&2 + local msg + msg="$(_extract_error "$body")" + if [[ -n "$msg" ]]; then + printf '%s\n' "$msg" >&2 + else + printf '%s\n' "$body" >&2 + fi if [[ "$status" -ge 400 && "$status" -lt 500 ]]; then exit 4; fi exit 5 } -# Build a JSON object from --key value flags (each value is JSON-quoted as string). -# Numbers passed as $key=number are kept numeric. Booleans true/false kept bool. -# Lists are NOT supported — cmd_run handles its files-array specially. -_json_obj_from_assoc() { - local first=1 out='{' - for kv in "$@"; do - local k="${kv%%=*}" v="${kv#*=}" - if [[ "$first" == 0 ]]; then out+=','; fi - first=0 - out+="\"$k\":" - if [[ "$v" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then - out+="$v" - elif [[ "$v" == "true" || "$v" == "false" ]]; then - out+="$v" - else - # JSON-escape: backslash, quote, newline, tab, CR - local esc="${v//\\/\\\\}" - esc="${esc//\"/\\\"}" - esc="${esc//$'\n'/\\n}" - esc="${esc//$'\r'/\\r}" - esc="${esc//$'\t'/\\t}" - out+="\"$esc\"" - fi - done - out+='}' - printf '%s\n' "$out" -} - -# Print the error body's "error" field if it's JSON, else the whole body. -_extract_error() { - local body="$1" - if command -v jq >/dev/null 2>&1; then - jq -r '.error // .detail // .' <<<"$body" 2>/dev/null || printf '%s' "$body" - else - printf '%s' "$body" - fi -} - # ---------- subcommands ---------------------------------------------------- cmd_healthz() { - _request GET "$CLAWDFORGE_URL/healthz" "${CLAWDFORGE_TOKEN:-${CLAWDFORGE_ADMIN_TOKEN:-}}" + # B8: drop Authorization entirely when no token is set. + local tok="${CLAWDFORGE_TOKEN:-${CLAWDFORGE_ADMIN_TOKEN:-}}" + _request GET "$CLAWDFORGE_URL/healthz" "$tok" } cmd_run() { @@ -144,7 +272,13 @@ cmd_run() { local prompt="" model="" system="" timeout_secs="" files="" if [[ $# -lt 1 ]]; then _die "usage: cf run [--model] [--system] [--timeout] [--files]" 2; fi prompt="$1"; shift - if [[ "$prompt" == "-" ]]; then prompt="$(cat)"; fi + if [[ "$prompt" == "-" ]]; then + # B1: refuse to read from a TTY — would hang forever. + if [[ -t 0 ]]; then + _die "cf run -: stdin is a TTY, expected a pipe or redirect (e.g. 'echo hi | cf run -')" 2 + fi + prompt="$(cat)" + fi while [[ $# -gt 0 ]]; do case "$1" in @@ -156,36 +290,22 @@ cmd_run() { esac done - # Build JSON safely without depending on jq for input + # Build JSON safely. S2/B2: use the shared array composer + raw: pass-through + # in _json_obj_from_assoc to avoid hand-splicing the body. local args=("prompt=$prompt") [[ -n "$model" ]] && args+=("model=$model") [[ -n "$system" ]] && args+=("system=$system") [[ -n "$timeout_secs" ]] && args+=("timeout_secs=$timeout_secs") if [[ -n "$files" ]]; then - # Build files array — keep this branch pure-bash to avoid jq dep - local farr='[' - local first=1 IFS=, - for ft in $files; do - [[ "$first" == 0 ]] && farr+=',' - farr+="\"$ft\"" - first=0 - done - farr+=']' - # Compose final body manually since args helper doesn't do arrays - local body - body="$(_json_obj_from_assoc "${args[@]}")" - body="${body%\}},\"files\":$farr}" - body="${body//\}\}/\}}" # tidy any double-close edge case - # Guard: if substitution didn't apply correctly, fall back to manual splice - if [[ "$body" != *'"files":'* ]]; then - body="${body%\}},\"files\":$farr}" - fi - _request POST "$CLAWDFORGE_URL/run" "$CLAWDFORGE_TOKEN" "$body" - else - local body; body="$(_json_obj_from_assoc "${args[@]}")" - _request POST "$CLAWDFORGE_URL/run" "$CLAWDFORGE_TOKEN" "$body" + local farr + farr="$(_json_string_array "$files")" + args+=("files=raw:$farr") fi + + local body + body="$(_json_obj_from_assoc "${args[@]}")" + _request POST "$CLAWDFORGE_URL/run" "$CLAWDFORGE_TOKEN" "$body" } cmd_upload() { @@ -199,27 +319,62 @@ cmd_upload() { *) _die "unknown flag: $1" 2;; esac done + + # S6: reject ';' in upload paths — curl's -F @ syntax interprets + # ;type=...;filename=... suffixes, allowing a self-attacker to override + # the uploaded filename or content-type. + if [[ "$path" == *';'* ]]; then + _die "upload path contains ';' which curl @ syntax interprets specially — rename the file" 2 + fi + + # S1: bearer via --config tmpfile, not -H argv. + local auth_file + auth_file="$(mktemp)" + chmod 600 "$auth_file" + printf 'header = "Authorization: Bearer %s"\n' "$CLAWDFORGE_TOKEN" > "$auth_file" + local -a curl_args=( -sS -X POST "$CLAWDFORGE_URL/files" - -H "Authorization: Bearer $CLAWDFORGE_TOKEN" + --config "$auth_file" -H "Accept: application/json" -w '\n__cf_status__=%{http_code}' + --max-time 600 -F "file=@$path" ) [[ -n "$ttl" ]] && curl_args+=(-F "ttl_secs=$ttl") - local resp; resp="$(curl "${curl_args[@]}")" || _die "curl failed" 1 + + # B7: capture stderr like _request does so failures are useful. + local resp rc=0 + resp="$(curl "${curl_args[@]}" 2>&1)" || rc=$? + rm -f "$auth_file" + if (( rc != 0 )); then _die "curl failed: $resp" 1; fi + local status="${resp##*__cf_status__=}" local body="${resp%__cf_status__=*}"; body="${body%$'\n'}" if [[ "$status" -ge 200 && "$status" -lt 300 ]]; then printf '%s\n' "$body" else - echo "cf: HTTP $status" >&2; printf '%s\n' "$body" >&2 + echo "cf: HTTP $status" >&2 + local msg + msg="$(_extract_error "$body")" + if [[ -n "$msg" ]]; then + printf '%s\n' "$msg" >&2 + else + printf '%s\n' "$body" >&2 + fi [[ "$status" -lt 500 ]] && exit 4 || exit 5 fi } cmd_admin() { local sub="${1:-}"; shift || true + # B9: validate the subcommand BEFORE requiring the admin token so typos + # don't get swallowed by a "missing token" error. + case "$sub" in + token-mint|token-list|token-revoke) ;; + "") _die "usage: cf admin " 2;; + *) _die "unknown admin subcommand: $sub (token-mint|token-list|token-revoke)" 2;; + esac _need_admin_token case "$sub" in token-mint) @@ -232,17 +387,13 @@ cmd_admin() { *) _die "unknown flag: $1" 2;; esac done + # S3: route via the shared escape pipeline. + local -a args=("name=$name") + local arr + arr="$(_json_string_array "$cidrs")" + args+=("ip_cidrs=raw:$arr") local body - if [[ -n "$cidrs" ]]; then - local arr='[' first=1 IFS=, - for c in $cidrs; do - [[ "$first" == 0 ]] && arr+=','; first=0 - arr+="\"$c\"" - done; arr+=']' - body="{\"name\":\"$name\",\"ip_cidrs\":$arr}" - else - body="{\"name\":\"$name\",\"ip_cidrs\":[]}" - fi + body="$(_json_obj_from_assoc "${args[@]}")" _request POST "$CLAWDFORGE_URL/admin/tokens" "$CLAWDFORGE_ADMIN_TOKEN" "$body" ;; token-list) @@ -251,14 +402,17 @@ cmd_admin() { token-revoke) local name="${1:-}" [[ -n "$name" ]] || _die "usage: cf admin token-revoke " 2 - _request DELETE "$CLAWDFORGE_URL/admin/tokens/$name" "$CLAWDFORGE_ADMIN_TOKEN" + # S4: URL-encode the name so '/', '?', '#', etc. can't smuggle path/query. + local enc + enc="$(_url_encode "$name")" + _request DELETE "$CLAWDFORGE_URL/admin/tokens/$enc" "$CLAWDFORGE_ADMIN_TOKEN" ;; - *) _die "unknown admin subcommand: $sub (token-mint|token-list|token-revoke)" 2;; esac } cmd_help() { - sed -n '2,/^set -euo/{/^set -euo/q;p}' "${BASH_SOURCE[0]}" | sed 's/^# \?//' + # U3: dedicated sentinel comment so help-text extraction survives reorgs. + sed -n '2,/^# --- end help ---/{/^# --- end help ---/q;p}' "${BASH_SOURCE[0]}" | sed 's/^# \?//' } # ---------- dispatch ------------------------------------------------------- diff --git a/clients/bash/test/test_cf.sh b/clients/bash/test/test_cf.sh new file mode 100755 index 0000000..1d6a647 --- /dev/null +++ b/clients/bash/test/test_cf.sh @@ -0,0 +1,408 @@ +#!/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 + any --data-binary @) +# 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 if present (separator __CONFIG__) +# - contents of --data-binary @ 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 and --data-binary @ + 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" <&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