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>
189 lines
7.5 KiB
Bash
Executable file
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
|