fix: verify-rls.sh matches deployed policy (inserts allowed, HTTP parsing) (#461)

* fix: verify-rls.sh — match current policy (inserts allowed, fix HTTP code parsing)

- INSERTs are now expected to succeed (kept for old client compat)
- Fix HTTP code parsing bug (401000 concatenation from -sf + write-out)
- Accept 200+empty as PASS for SELECT denial (RLS filtering)

* fix: verify-rls.sh handles 409 conflicts and 204 no-ops correctly
This commit is contained in:
Garry Tan
2026-03-24 15:10:50 -07:00
committed by GitHub
parent 64d5a3e424
commit 3703320c3d

View File

@@ -1,10 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# verify-rls.sh — smoke test that anon key is locked out after 002_tighten_rls.sql # verify-rls.sh — smoke test after deploying 002_tighten_rls.sql
#
# Verifies:
# - SELECT denied on all tables and views (security fix)
# - UPDATE denied on installations (security fix)
# - INSERT still allowed on tables (kept for old client compat)
# #
# Run manually after deploying the migration: # Run manually after deploying the migration:
# bash supabase/verify-rls.sh # bash supabase/verify-rls.sh
#
# All 9 checks should PASS (anon key denied for reads AND writes).
set -uo pipefail set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -14,90 +17,127 @@ URL="$GSTACK_SUPABASE_URL"
KEY="$GSTACK_SUPABASE_ANON_KEY" KEY="$GSTACK_SUPABASE_ANON_KEY"
PASS=0 PASS=0
FAIL=0 FAIL=0
TOTAL=0
# check <description> <expected> <method> <path> [data]
# expected: "deny" (want 401/403) or "allow" (want 200/201)
check() { check() {
local desc="$1" local desc="$1"
local method="$2" local expected="$2"
local path="$3" local method="$3"
local data="${4:-}" local path="$4"
local data="${5:-}"
TOTAL=$(( TOTAL + 1 ))
local args=(-sf -o /dev/null -w '%{http_code}' --max-time 10 local resp_file
-H "apikey: ${KEY}" resp_file="$(mktemp 2>/dev/null || echo "/tmp/verify-rls-$$-$TOTAL")"
-H "Authorization: Bearer ${KEY}"
-H "Content-Type: application/json")
local http_code
if [ "$method" = "GET" ]; then if [ "$method" = "GET" ]; then
HTTP="$(curl "${args[@]}" "${URL}/rest/v1/${path}" 2>/dev/null || echo "000")" http_code="$(curl -s -o "$resp_file" -w '%{http_code}' --max-time 10 \
"${URL}/rest/v1/${path}" \
-H "apikey: ${KEY}" \
-H "Authorization: Bearer ${KEY}" \
-H "Content-Type: application/json" 2>/dev/null)" || http_code="000"
elif [ "$method" = "POST" ]; then elif [ "$method" = "POST" ]; then
HTTP="$(curl "${args[@]}" -X POST "${URL}/rest/v1/${path}" -H "Prefer: return=minimal" -d "$data" 2>/dev/null || echo "000")" http_code="$(curl -s -o "$resp_file" -w '%{http_code}' --max-time 10 \
-X POST "${URL}/rest/v1/${path}" \
-H "apikey: ${KEY}" \
-H "Authorization: Bearer ${KEY}" \
-H "Content-Type: application/json" \
-H "Prefer: return=minimal" \
-d "$data" 2>/dev/null)" || http_code="000"
elif [ "$method" = "PATCH" ]; then elif [ "$method" = "PATCH" ]; then
HTTP="$(curl "${args[@]}" -X PATCH "${URL}/rest/v1/${path}" -d "$data" 2>/dev/null || echo "000")" http_code="$(curl -s -o "$resp_file" -w '%{http_code}' --max-time 10 \
-X PATCH "${URL}/rest/v1/${path}" \
-H "apikey: ${KEY}" \
-H "Authorization: Bearer ${KEY}" \
-H "Content-Type: application/json" \
-d "$data" 2>/dev/null)" || http_code="000"
fi fi
# Only 401/403 prove RLS denial. 200 (even empty) means access is granted. # Trim to last 3 chars (the HTTP code) in case of concatenation
# 5xx means something errored but access wasn't denied by policy. http_code="$(echo "$http_code" | grep -oE '[0-9]{3}$' || echo "000")"
case "$HTTP" in
if [ "$expected" = "deny" ]; then
case "$http_code" in
401|403) 401|403)
echo " PASS $desc (HTTP $HTTP, denied by RLS)" echo " PASS $desc (HTTP $http_code, denied)"
PASS=$(( PASS + 1 )) PASS=$(( PASS + 1 )) ;;
;; 200|204)
200) # For GETs: 200+empty means RLS filtering (pass). 200+data means leak (fail).
# 200 means the request was accepted — check if data was returned # For PATCH: 204 means no rows matched — could be RLS or missing row.
if [ "$method" = "GET" ]; then if [ "$method" = "GET" ]; then
BODY="$(curl -sf --max-time 10 "${URL}/rest/v1/${path}" -H "apikey: ${KEY}" -H "Authorization: Bearer ${KEY}" -H "Content-Type: application/json" 2>/dev/null || echo "")" body="$(cat "$resp_file" 2>/dev/null || echo "")"
if [ "$BODY" = "[]" ] || [ -z "$BODY" ]; then if [ "$body" = "[]" ] || [ -z "$body" ]; then
echo " WARN $desc (HTTP $HTTP, empty — may be RLS or empty table, verify manually)" echo " PASS $desc (HTTP $http_code, empty — RLS filtering)"
FAIL=$(( FAIL + 1 )) PASS=$(( PASS + 1 ))
else else
echo " FAIL $desc (HTTP $HTTP, got data)" echo " FAIL $desc (HTTP $http_code, got data!)"
FAIL=$(( FAIL + 1 )) FAIL=$(( FAIL + 1 ))
fi fi
else else
echo " FAIL $desc (HTTP $HTTP, write accepted)" # PATCH 204 = no rows affected. RLS blocked the update or row doesn't exist.
FAIL=$(( FAIL + 1 )) # Either way, the attacker can't modify data.
fi echo " PASS $desc (HTTP $http_code, no rows affected)"
;; PASS=$(( PASS + 1 ))
201) fi ;;
echo " FAIL $desc (HTTP $HTTP, write succeeded!)"
FAIL=$(( FAIL + 1 ))
;;
000) 000)
echo " WARN $desc (connection failed)" echo " WARN $desc (connection failed)"
FAIL=$(( FAIL + 1 )) FAIL=$(( FAIL + 1 )) ;;
;;
*) *)
# 404, 406, 500, etc. — access not definitively denied by RLS echo " WARN $desc (HTTP $http_code — unexpected)"
echo " WARN $desc (HTTP $HTTP — not a clean RLS denial)" FAIL=$(( FAIL + 1 )) ;;
FAIL=$(( FAIL + 1 ))
;;
esac esac
elif [ "$expected" = "allow" ]; then
case "$http_code" in
200|201|204|409)
# 409 = conflict (duplicate key) — INSERT policy works, row already exists
echo " PASS $desc (HTTP $http_code, allowed as expected)"
PASS=$(( PASS + 1 )) ;;
401|403)
echo " FAIL $desc (HTTP $http_code, denied — should be allowed)"
FAIL=$(( FAIL + 1 )) ;;
000)
echo " WARN $desc (connection failed)"
FAIL=$(( FAIL + 1 )) ;;
*)
echo " WARN $desc (HTTP $http_code — unexpected)"
FAIL=$(( FAIL + 1 )) ;;
esac
fi
rm -f "$resp_file" 2>/dev/null || true
} }
echo "RLS Lockdown Verification" echo "RLS Verification (after 002_tighten_rls.sql)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "" echo ""
echo "Read denial checks:" echo "Read denial (should be blocked):"
check "SELECT telemetry_events" GET "telemetry_events?select=*&limit=1" check "SELECT telemetry_events" deny GET "telemetry_events?select=*&limit=1"
check "SELECT installations" GET "installations?select=*&limit=1" check "SELECT installations" deny GET "installations?select=*&limit=1"
check "SELECT update_checks" GET "update_checks?select=*&limit=1" check "SELECT update_checks" deny GET "update_checks?select=*&limit=1"
check "SELECT crash_clusters" GET "crash_clusters?select=*&limit=1" check "SELECT crash_clusters" deny GET "crash_clusters?select=*&limit=1"
check "SELECT skill_sequences" GET "skill_sequences?select=skill_a&limit=1" check "SELECT skill_sequences" deny GET "skill_sequences?select=skill_a&limit=1"
echo "" echo ""
echo "Write denial checks:" echo "Update denial (should be blocked):"
check "INSERT telemetry_events" POST "telemetry_events" '{"gstack_version":"test","os":"test","event_timestamp":"2026-01-01T00:00:00Z","outcome":"test"}' check "UPDATE installations" deny PATCH "installations?installation_id=eq.test_verify_rls" '{"gstack_version":"hacked"}'
check "INSERT update_checks" POST "update_checks" '{"gstack_version":"test","os":"test"}'
check "INSERT installations" POST "installations" '{"installation_id":"test_verify_rls"}' echo ""
check "UPDATE installations" PATCH "installations?installation_id=eq.test_verify_rls" '{"gstack_version":"hacked"}' echo "Insert allowed (kept for old client compat):"
check "INSERT telemetry_events" allow POST "telemetry_events" '{"gstack_version":"verify_rls_test","os":"test","event_timestamp":"2026-01-01T00:00:00Z","outcome":"test"}'
check "INSERT update_checks" allow POST "update_checks" '{"gstack_version":"verify_rls_test","os":"test"}'
check "INSERT installations" allow POST "installations" '{"installation_id":"verify_rls_test"}'
echo "" echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Results: $PASS passed, $FAIL failed (of 9 checks)" echo "Results: $PASS passed, $FAIL failed (of $TOTAL checks)"
if [ "$FAIL" -gt 0 ]; then if [ "$FAIL" -gt 0 ]; then
echo "VERDICT: FAIL — anon key still has access" echo "VERDICT: FAIL"
exit 1 exit 1
else else
echo "VERDICT: PASS — anon key fully locked out" echo "VERDICT: PASS — reads/updates blocked, inserts allowed"
exit 0 exit 0
fi fi