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
This commit is contained in:
parent
237e2f7c34
commit
7ba7058cd5
2 changed files with 652 additions and 90 deletions
334
clients/bash/cf
334
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 <tok>"'
|
||||
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 <prompt|-> [--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 <token-mint|token-list|token-revoke>" 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 <name>" 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 -------------------------------------------------------
|
||||
|
|
|
|||
408
clients/bash/test/test_cf.sh
Executable file
408
clients/bash/test/test_cf.sh
Executable file
|
|
@ -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 <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
|
||||
Loading…
Add table
Add a link
Reference in a new issue