From 3703320c3d6399ca1920ed1f014927d1798adbb2 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 24 Mar 2026 15:10:50 -0700 Subject: [PATCH 1/2] fix: verify-rls.sh matches deployed policy (inserts allowed, HTTP parsing) (#461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- supabase/verify-rls.sh | 168 +++++++++++++++++++++++++---------------- 1 file changed, 104 insertions(+), 64 deletions(-) diff --git a/supabase/verify-rls.sh b/supabase/verify-rls.sh index 68438687..4ed92bc6 100755 --- a/supabase/verify-rls.sh +++ b/supabase/verify-rls.sh @@ -1,10 +1,13 @@ #!/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: # bash supabase/verify-rls.sh -# -# All 9 checks should PASS (anon key denied for reads AND writes). set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -14,90 +17,127 @@ URL="$GSTACK_SUPABASE_URL" KEY="$GSTACK_SUPABASE_ANON_KEY" PASS=0 FAIL=0 +TOTAL=0 +# check [data] +# expected: "deny" (want 401/403) or "allow" (want 200/201) check() { local desc="$1" - local method="$2" - local path="$3" - local data="${4:-}" + local expected="$2" + local method="$3" + local path="$4" + local data="${5:-}" + TOTAL=$(( TOTAL + 1 )) - local args=(-sf -o /dev/null -w '%{http_code}' --max-time 10 - -H "apikey: ${KEY}" - -H "Authorization: Bearer ${KEY}" - -H "Content-Type: application/json") + local resp_file + resp_file="$(mktemp 2>/dev/null || echo "/tmp/verify-rls-$$-$TOTAL")" + local http_code 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 - 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 - 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 - # Only 401/403 prove RLS denial. 200 (even empty) means access is granted. - # 5xx means something errored but access wasn't denied by policy. - case "$HTTP" in - 401|403) - echo " PASS $desc (HTTP $HTTP, denied by RLS)" - PASS=$(( PASS + 1 )) - ;; - 200) - # 200 means the request was accepted — check if data was returned - 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 "")" - if [ "$BODY" = "[]" ] || [ -z "$BODY" ]; then - echo " WARN $desc (HTTP $HTTP, empty — may be RLS or empty table, verify manually)" - FAIL=$(( FAIL + 1 )) + # Trim to last 3 chars (the HTTP code) in case of concatenation + http_code="$(echo "$http_code" | grep -oE '[0-9]{3}$' || echo "000")" + + if [ "$expected" = "deny" ]; then + case "$http_code" in + 401|403) + echo " PASS $desc (HTTP $http_code, denied)" + PASS=$(( PASS + 1 )) ;; + 200|204) + # For GETs: 200+empty means RLS filtering (pass). 200+data means leak (fail). + # For PATCH: 204 means no rows matched — could be RLS or missing row. + if [ "$method" = "GET" ]; then + body="$(cat "$resp_file" 2>/dev/null || echo "")" + if [ "$body" = "[]" ] || [ -z "$body" ]; then + echo " PASS $desc (HTTP $http_code, empty — RLS filtering)" + PASS=$(( PASS + 1 )) + else + echo " FAIL $desc (HTTP $http_code, got data!)" + FAIL=$(( FAIL + 1 )) + fi else - echo " FAIL $desc (HTTP $HTTP, got data)" - FAIL=$(( FAIL + 1 )) - fi - else - echo " FAIL $desc (HTTP $HTTP, write accepted)" - FAIL=$(( FAIL + 1 )) - fi - ;; - 201) - echo " FAIL $desc (HTTP $HTTP, write succeeded!)" - FAIL=$(( FAIL + 1 )) - ;; - 000) - echo " WARN $desc (connection failed)" - FAIL=$(( FAIL + 1 )) - ;; - *) - # 404, 406, 500, etc. — access not definitively denied by RLS - echo " WARN $desc (HTTP $HTTP — not a clean RLS denial)" - FAIL=$(( FAIL + 1 )) - ;; - esac + # PATCH 204 = no rows affected. RLS blocked the update or row doesn't exist. + # Either way, the attacker can't modify data. + echo " PASS $desc (HTTP $http_code, no rows affected)" + PASS=$(( PASS + 1 )) + fi ;; + 000) + echo " WARN $desc (connection failed)" + FAIL=$(( FAIL + 1 )) ;; + *) + echo " WARN $desc (HTTP $http_code — unexpected)" + FAIL=$(( FAIL + 1 )) ;; + 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 "Read denial checks:" -check "SELECT telemetry_events" GET "telemetry_events?select=*&limit=1" -check "SELECT installations" GET "installations?select=*&limit=1" -check "SELECT update_checks" GET "update_checks?select=*&limit=1" -check "SELECT crash_clusters" GET "crash_clusters?select=*&limit=1" -check "SELECT skill_sequences" GET "skill_sequences?select=skill_a&limit=1" +echo "Read denial (should be blocked):" +check "SELECT telemetry_events" deny GET "telemetry_events?select=*&limit=1" +check "SELECT installations" deny GET "installations?select=*&limit=1" +check "SELECT update_checks" deny GET "update_checks?select=*&limit=1" +check "SELECT crash_clusters" deny GET "crash_clusters?select=*&limit=1" +check "SELECT skill_sequences" deny GET "skill_sequences?select=skill_a&limit=1" echo "" -echo "Write denial checks:" -check "INSERT telemetry_events" POST "telemetry_events" '{"gstack_version":"test","os":"test","event_timestamp":"2026-01-01T00:00:00Z","outcome":"test"}' -check "INSERT update_checks" POST "update_checks" '{"gstack_version":"test","os":"test"}' -check "INSERT installations" POST "installations" '{"installation_id":"test_verify_rls"}' -check "UPDATE installations" PATCH "installations?installation_id=eq.test_verify_rls" '{"gstack_version":"hacked"}' +echo "Update denial (should be blocked):" +check "UPDATE installations" deny PATCH "installations?installation_id=eq.test_verify_rls" '{"gstack_version":"hacked"}' + +echo "" +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 "Results: $PASS passed, $FAIL failed (of 9 checks)" +echo "Results: $PASS passed, $FAIL failed (of $TOTAL checks)" if [ "$FAIL" -gt 0 ]; then - echo "VERDICT: FAIL — anon key still has access" + echo "VERDICT: FAIL" exit 1 else - echo "VERDICT: PASS — anon key fully locked out" + echo "VERDICT: PASS — reads/updates blocked, inserts allowed" exit 0 fi From 2b85b1df46207a00df98bbedd40b303f754364c1 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 24 Mar 2026 15:16:03 -0700 Subject: [PATCH 2/2] fix: random UUID installation_id + verify-rls.sh edge cases (v0.11.16.1) (#462) * fix: random UUID installation_id + gitignore supabase/.temp Replace SHA-256(hostname+user) with random UUID v4 stored in ~/.gstack/installation-id. Not derivable from public inputs. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: random UUID installation_id + verify-rls.sh edge cases (v0.11.16.1) Replace SHA-256(hostname+user) with random UUID v4 stored in ~/.gstack/installation-id. Gitignore supabase/.temp/. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .gitignore | 1 + CHANGELOG.md | 7 +++++++ VERSION | 2 +- bin/gstack-telemetry-log | 29 ++++++++++++++++++++--------- test/telemetry.test.ts | 4 ++-- 5 files changed, 31 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 189276fb..770818be 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ bun.lock .env.local .env.* !.env.example +supabase/.temp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 654b1b83..fdd0f68f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.11.16.1] - 2026-03-24 — Installation ID Privacy Fix + +### Fixed + +- **Installation IDs are now random UUIDs instead of hostname hashes.** The old `SHA-256(hostname+username)` approach meant anyone who knew your machine identity could compute your installation ID. Now uses a random UUID stored in `~/.gstack/installation-id` — not derivable from any public input, rotatable by deleting the file. +- **RLS verification script handles edge cases.** `verify-rls.sh` now correctly treats INSERT success as expected (kept for old client compat), handles 409 conflicts and 204 no-ops. + ## [0.11.16.0] - 2026-03-24 — Telemetry Security Hardening ### Fixed diff --git a/VERSION b/VERSION index e36c939e..f71aefdf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.11.16.0 +0.11.16.1 diff --git a/bin/gstack-telemetry-log b/bin/gstack-telemetry-log index edcbdbab..885dfc2b 100755 --- a/bin/gstack-telemetry-log +++ b/bin/gstack-telemetry-log @@ -106,18 +106,29 @@ if [ -d "$STATE_DIR/sessions" ]; then fi # Generate installation_id for community tier +# Uses a random UUID stored locally — not derived from hostname/user so it +# can't be guessed or correlated by someone who knows your machine identity. INSTALL_ID="" if [ "$TIER" = "community" ]; then - HOST="$(hostname 2>/dev/null || echo "unknown")" - USER="$(whoami 2>/dev/null || echo "unknown")" - if command -v shasum >/dev/null 2>&1; then - INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | shasum -a 256 | awk '{print $1}')" - elif command -v sha256sum >/dev/null 2>&1; then - INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | sha256sum | awk '{print $1}')" - elif command -v openssl >/dev/null 2>&1; then - INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | openssl dgst -sha256 | awk '{print $NF}')" + ID_FILE="$HOME/.gstack/installation-id" + if [ -f "$ID_FILE" ]; then + INSTALL_ID="$(cat "$ID_FILE" 2>/dev/null)" + fi + if [ -z "$INSTALL_ID" ]; then + # Generate a random UUID v4 + if command -v uuidgen >/dev/null 2>&1; then + INSTALL_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" + elif [ -r /proc/sys/kernel/random/uuid ]; then + INSTALL_ID="$(cat /proc/sys/kernel/random/uuid)" + else + # Fallback: random hex from /dev/urandom + INSTALL_ID="$(od -An -tx1 -N16 /dev/urandom 2>/dev/null | tr -d ' \n')" + fi + if [ -n "$INSTALL_ID" ]; then + mkdir -p "$(dirname "$ID_FILE")" 2>/dev/null + printf '%s' "$INSTALL_ID" > "$ID_FILE" 2>/dev/null + fi fi - # If no SHA-256 command available, install_id stays empty fi # Local-only fields (never sent remotely) diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts index c9b42473..a3050631 100644 --- a/test/telemetry.test.ts +++ b/test/telemetry.test.ts @@ -78,8 +78,8 @@ describe('gstack-telemetry-log', () => { const events = parseJsonl(); expect(events).toHaveLength(1); - // installation_id should be a SHA-256 hash (64 hex chars) - expect(events[0].installation_id).toMatch(/^[a-f0-9]{64}$/); + // installation_id should be a UUID v4 (or hex fallback) + expect(events[0].installation_id).toMatch(/^[a-f0-9-]{32,36}$/); }); test('installation_id is null for anonymous tier', () => {