veza/scripts/probes/subscription-unpaid-activation.sh
senke 9a8d2a4e73 chore(release): v1.0.6.2 — subscription payment-gate bypass hotfix
Closes a bypass surfaced by the 2026-04 audit probe (axis-1 Q2): any
authenticated user could POST /api/v1/subscriptions/subscribe on a paid
plan and receive 201 active without the payment provider ever being
invoked. The resulting row satisfied `checkEligibility()` in the
distribution service via `can_sell_on_marketplace=true` on the Creator
plan — effectively free access to /api/v1/distribution/submit, which
dispatches to external partners.

Fix is centralised in `GetUserSubscription` so there is no code path
that can grant subscription-gated access without routing through the
payment check. Effective-payment = free plan OR unexpired trial OR
invoice with non-empty hyperswitch_payment_id. Migration 980 sweeps
pre-existing fantôme rows into `expired`, preserving the tuple in a
dated audit table for support outreach.

Subscribe and subscribeToFreePlan treat the new ErrSubscriptionNoPayment
as equivalent to ErrNoActiveSubscription so re-subscription works
cleanly post-cleanup. GET /me/subscription surfaces needs_payment=true
with a support-contact message rather than a misleading "you're on
free" or an opaque 500. TODO(v1.0.7-item-G) annotation marks where the
`if s.paymentProvider != nil` short-circuit needs to become a mandatory
pending_payment state.

Probe script `scripts/probes/subscription-unpaid-activation.sh` kept as
a versioned regression test — dry-run by default, --destructive logs in
and attempts the exploit against a live backend with automatic cleanup.
8-case unit test matrix covers the full hasEffectivePayment predicate.

Smoke validated end-to-end against local v1.0.6.2: POST /subscribe
returns 201 (by design — item G closes the creation path), but
GET /me/subscription returns subscription=null + needs_payment=true,
distribution eligibility returns false.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:21:53 +02:00

189 lines
7.5 KiB
Bash
Executable file

#!/usr/bin/env bash
# -----------------------------------------------------------------------------
# scripts/probes/subscription-unpaid-activation.sh
#
# Regression probe for the v1.0.6.2 subscription payment-gate hotfix.
#
# Before v1.0.6.2, POST /api/v1/subscriptions/subscribe on a paid plan would
# return HTTP 201 with a user_subscription row in 'active' status, WITHOUT
# the payment provider ever being invoked. This enabled any authenticated
# user to satisfy the distribution-eligibility gate (checkEligibility()) for
# free. v1.0.6.2 filters these rows out of GetUserSubscription and voids the
# pre-existing ones via migration 980.
#
# This probe verifies that the bypass is closed. It must return REFUSED
# after v1.0.6.2 is deployed. Kept as a versioned regression test against
# any refactor of the subscription flow.
#
# Modes:
# --dry-run (default) Read-only. Lists plans + describes what the
# destructive probe would do. No rows written.
# --destructive Actually logs in, POSTs /subscribe, observes the
# outcome, then rolls back (deletes the sub row and
# invoice rows it created, if any). Requires a real
# running backend and a test account.
#
# Exit codes:
# 0 Probe did not reveal a bypass (expected post-v1.0.6.2).
# 1 Probe revealed the bypass (active sub + paid plan + no PSP linkage).
# 2 Probe could not run (missing deps, auth failed, backend unreachable).
#
# Env overrides:
# API_BASE_URL default http://localhost:8080
# PROBE_EMAIL default smoke.0416@veza.local
# PROBE_PASSWORD default Str0ng!Probe#2026
# DB_CONTAINER default veza_postgres (for --destructive rollback)
# DB_USER default veza
# DB_NAME default veza
# -----------------------------------------------------------------------------
set -euo pipefail
API_BASE_URL="${API_BASE_URL:-http://localhost:8080}"
PROBE_EMAIL="${PROBE_EMAIL:-smoke.0416@veza.local}"
PROBE_PASSWORD="${PROBE_PASSWORD:-Str0ng!Probe#2026}"
DB_CONTAINER="${DB_CONTAINER:-veza_postgres}"
DB_USER="${DB_USER:-veza}"
DB_NAME="${DB_NAME:-veza}"
MODE="dry-run"
for arg in "$@"; do
case "$arg" in
--destructive) MODE="destructive" ;;
--dry-run) MODE="dry-run" ;;
-h|--help)
sed -n '2,40p' "$0"
exit 0
;;
*) echo "unknown arg: $arg" >&2 ; exit 2 ;;
esac
done
log() { printf '[probe] %s\n' "$*"; }
need() {
command -v "$1" >/dev/null 2>&1 || { echo "missing dep: $1" >&2 ; exit 2 ; }
}
need curl
need jq
# -----------------------------------------------------------------------------
# Phase 0 — reachability (both modes)
# -----------------------------------------------------------------------------
log "target: $API_BASE_URL"
if ! curl -sf --max-time 3 "$API_BASE_URL/health" > /dev/null 2>&1; then
log "backend unreachable — start it first"
exit 2
fi
log "backend reachable"
PLANS_JSON=$(curl -sf "$API_BASE_URL/api/v1/subscriptions/plans")
# Response wraps in {success, data:{plans:[...]}} (v1.0.6.2) or {data:[...]} fallback.
CREATOR_ID=$(jq -r '(.data.plans // .data // [])[] | select(.name == "creator") | .id' <<<"$PLANS_JSON" || echo "")
if [ -z "$CREATOR_ID" ] || [ "$CREATOR_ID" = "null" ]; then
log "creator plan not found in /plans response"
exit 2
fi
log "creator plan id: $CREATOR_ID"
if [ "$MODE" = "dry-run" ]; then
log "dry-run — would now: login as $PROBE_EMAIL, POST /subscribe with plan_id=$CREATOR_ID."
log "to run the exploit attempt: $0 --destructive"
exit 0
fi
# -----------------------------------------------------------------------------
# Phase 1 — authenticate (destructive only)
# -----------------------------------------------------------------------------
COOKIE_JAR=$(mktemp)
trap 'rm -f "$COOKIE_JAR"' EXIT
LOGIN_STATUS=$(curl -s -o /tmp/probe_login.json -w '%{http_code}' \
-X POST "$API_BASE_URL/api/v1/auth/login" \
-H 'Content-Type: application/json' \
-c "$COOKIE_JAR" \
-d "{\"email\":\"$PROBE_EMAIL\",\"password\":\"$PROBE_PASSWORD\"}")
if [ "$LOGIN_STATUS" != "200" ]; then
log "login failed with HTTP $LOGIN_STATUS — probe account may not exist"
cat /tmp/probe_login.json >&2
exit 2
fi
log "login OK"
CSRF_STATUS=$(curl -s -o /tmp/probe_csrf.json -w '%{http_code}' \
"$API_BASE_URL/api/v1/csrf-token" \
-b "$COOKIE_JAR" -c "$COOKIE_JAR")
if [ "$CSRF_STATUS" != "200" ]; then
log "csrf fetch failed with HTTP $CSRF_STATUS"
exit 2
fi
CSRF=$(jq -r '.data.csrf_token // .csrf_token // empty' /tmp/probe_csrf.json)
if [ -z "$CSRF" ]; then
log "csrf token not found in /auth/csrf response"
exit 2
fi
# -----------------------------------------------------------------------------
# Phase 2 — attempt the exploit
#
# v1.0.6.2 design note: POST /subscribe still returns 201 when the payment
# provider is unreachable (the creation path itself is item G in v1.0.7).
# What v1.0.6.2 closes is the *gate* — `GetUserSubscription` must now
# filter the fantôme row and surface `needs_payment: true`. So the probe
# tests the observable consequence: after subscribing, does the server
# still grant access? If GET /me/subscription returns a valid subscription
# object for this user, the gate failed and the bypass is open.
# -----------------------------------------------------------------------------
log "POST /subscribe { plan_id: creator, billing_cycle: monthly }"
SUBSCRIBE_STATUS=$(curl -s -o /tmp/probe_sub.json -w '%{http_code}' \
-X POST "$API_BASE_URL/api/v1/subscriptions/subscribe" \
-H 'Content-Type: application/json' \
-H "X-CSRF-Token: $CSRF" \
-b "$COOKIE_JAR" \
-d "{\"plan_id\":\"$CREATOR_ID\",\"billing_cycle\":\"monthly\"}")
SUB_ID=$(jq -r '.data.subscription.id // .subscription.id // empty' /tmp/probe_sub.json)
SUB_STATUS=$(jq -r '.data.subscription.status // .subscription.status // empty' /tmp/probe_sub.json)
log "subscribe: HTTP $SUBSCRIBE_STATUS row_status=$SUB_STATUS row_id=$SUB_ID"
log "GET /api/v1/subscriptions/me — testing whether the gate grants access"
curl -s -o /tmp/probe_me.json \
"$API_BASE_URL/api/v1/subscriptions/me" \
-b "$COOKIE_JAR"
ME_SUB=$(jq -r '.data.subscription // .subscription' /tmp/probe_me.json)
ME_NEEDS_PAYMENT=$(jq -r '.data.needs_payment // .needs_payment // false' /tmp/probe_me.json)
log "me: subscription=$ME_SUB needs_payment=$ME_NEEDS_PAYMENT"
BYPASS_DETECTED=0
# Bypass is open when /me/subscription returns a valid subscription object
# (non-null) to a user whose /subscribe path never reached the PSP. Under
# the v1.0.6.2 filter, the row is surfaced as null + needs_payment=true.
if [ "$ME_SUB" != "null" ] && [ -n "$ME_SUB" ] && [ "$ME_NEEDS_PAYMENT" != "true" ]; then
BYPASS_DETECTED=1
fi
# Roll back any row we created so the probe is idempotent.
if [ -n "$SUB_ID" ] && [ "$SUB_ID" != "null" ]; then
if command -v docker >/dev/null 2>&1 && docker ps --format '{{.Names}}' | grep -qx "$DB_CONTAINER"; then
log "rolling back subscription row $SUB_ID"
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -q \
-c "DELETE FROM subscription_invoices WHERE subscription_id='$SUB_ID';" \
-c "DELETE FROM user_subscriptions WHERE id='$SUB_ID';" \
>/dev/null 2>&1 || log "rollback encountered an error (non-fatal)"
else
log "db container not accessible — manual cleanup may be needed for row $SUB_ID"
fi
fi
if [ "$BYPASS_DETECTED" = "1" ]; then
log "BYPASS DETECTED — paid subscription created without PSP linkage"
exit 1
fi
log "REFUSED as expected (no bypass)"
exit 0