#!/usr/bin/env bash # # Auth smoke test for the routes closed in the security hotfix. # # For every route that was made non-public, asserts: # - no token -> 401 # - low-privilege token -> 403 # - super-admin token -> anything but 401/403 (the guard let it through) # # And asserts the genuinely-public routes still answer 200, plus that # GET /customers no longer leaks anything beyond the four login-lookup fields. # # Usage: API=http://localhost:3000/api bash scripts/auth-smoke.sh # Needs the API running and the dev seed applied (npx prisma db seed). set -uo pipefail API="${API:-http://localhost:3000/api}" SUPER_PHONE=09128081392; SUPER_PASS=09128081392 # ut 85, all rids LOW_PHONE=09123334444; LOW_PASS=admin1234 # ut 88, 5 unrelated rids pass=0; fail=0 ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; pass=$((pass+1)); } bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail+1)); } login() { curl -s -X POST "$API/admin/auth/login" -H 'Content-Type: application/json' \ -d "{\"phone\":\"$1\",\"password\":\"$2\"}" | sed -n 's/.*"api_token":"\([^"]*\)".*/\1/p' } code() { # method path [token] local m=$1 p=$2 t=${3:-} if [ -n "$t" ]; then curl -s -o /dev/null -w '%{http_code}' -X "$m" "$API/$p" \ -H "Authorization: Bearer $t" -H 'Content-Type: application/json' -d '{}' else curl -s -o /dev/null -w '%{http_code}' -X "$m" "$API/$p" \ -H 'Content-Type: application/json' -d '{}' fi } SUPER=$(login "$SUPER_PHONE" "$SUPER_PASS") LOW=$(login "$LOW_PHONE" "$LOW_PASS") [ -z "$SUPER" ] && { echo "super-admin login failed — is the seed applied?"; exit 1; } [ -z "$LOW" ] && { echo "low-priv login failed — is the seed applied?"; exit 1; } # route | required rid CLOSED=( "GET|accounting/customers/x/balance|wallet.list" "POST|accounting/customers/x/reconcile|wallet.charge" "POST|accounting/repayments/gold|document.create" "POST|accounting/repayments/rial|document.create" "POST|accounting/entries/x/confirm|document.list.confirm" "PATCH|orders/x/status|order.list.sts.submit" ) echo "Closed routes — no token must be 401:" for row in "${CLOSED[@]}"; do IFS='|' read -r m p _ <<<"$row" c=$(code "$m" "$p") [ "$c" = 401 ] && ok "$m $p -> 401" || bad "$m $p -> $c (want 401)" done echo "Closed routes — low-privilege token must be 403:" for row in "${CLOSED[@]}"; do IFS='|' read -r m p rid <<<"$row" c=$(code "$m" "$p" "$LOW") [ "$c" = 403 ] && ok "$m $p [$rid] -> 403" || bad "$m $p [$rid] -> $c (want 403)" done echo "Closed routes — super admin must get past the guard:" for row in "${CLOSED[@]}"; do IFS='|' read -r m p _ <<<"$row" c=$(code "$m" "$p" "$SUPER") case "$c" in 401|403) bad "$m $p -> $c (guard rejected a super admin)" ;; *) ok "$m $p -> $c (through the guard)" ;; esac done echo "Public routes must stay reachable:" for p in "catalog/products" "pricing/latest?karatPerMille=750" "home/slider"; do c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p") [ "$c" = 200 ] && ok "GET $p -> 200" || bad "GET $p -> $c (want 200)" done # ---------------------------------------------------------------- stage 2 --- # Customer session: real OTP, customer-scoped JWT, no id-bearing routes left. echo "Old id-bearing customer routes must be gone entirely:" for p in "customers" "customers/customer-dev-test" "customers/customer-dev-test/orders" "customers/customer-dev-test/ledger-entries"; do c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p") [ "$c" = 404 ] && ok "GET $p -> 404" || bad "GET $p -> $c (want 404)" done echo "Customer routes without a token must be 401:" for p in "customer/me" "customer/me/orders" "customer/me/ledger-entries"; do c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p") [ "$c" = 401 ] && ok "GET $p -> 401" || bad "GET $p -> $c (want 401)" done # The dev seed's customer. Needs the API log to read the code back, since no SMS # gateway is wired up — see CustomerAuthService.deliver. CUST_PHONE=09128081392 otp_request() { curl -s -o /dev/null -w '%{http_code}' -X POST "$API/customer/auth/request-otp" \ -H 'Content-Type: application/json' -d "{\"username\":\"$1\"}" } # Writes the JSON body to $BODY_FILE and prints the status code. The body goes # to a file because this is called inside $(...), where a plain assignment would # be lost along with the subshell. BODY_FILE=$(mktemp) trap 'rm -f "$BODY_FILE"' EXIT otp_verify() { # mobile code -> prints http code; body lands in $BODY_FILE curl -s -o "$BODY_FILE" -w '%{http_code}' -X POST "$API/customer/auth/verify-otp" \ -H 'Content-Type: application/json' -d "{\"username\":\"$1\",\"password\":\"$2\"}" } echo "OTP request:" c=$(otp_request "$CUST_PHONE") [ "$c" = 200 ] && ok "request-otp -> 200" || bad "request-otp -> $c (want 200)" c=$(otp_request 09999999999) [ "$c" = 200 ] && ok "unknown number answers 200 too (no enumeration)" \ || bad "unknown number -> $c (want 200, identical to a known one)" echo "OTP send throttle:" c=$(otp_request "$CUST_PHONE") [ "$c" = 429 ] && ok "immediate resend -> 429" || bad "immediate resend -> $c (want 429)" echo "OTP verify:" c=$(otp_verify "$CUST_PHONE" 0000) [ "$c" = 401 ] && ok "wrong code -> 401" || bad "wrong code -> $c (want 401)" # The real code only reachable from the server log; skip the rest when the # caller did not point CODE_LOG at it. if [ -n "${CODE_LOG:-}" ] && [ -f "$CODE_LOG" ]; then CODE=$(grep -o "OTP for $CUST_PHONE: [0-9]*" "$CODE_LOG" | tail -1 | grep -o '[0-9]*$') if [ -n "$CODE" ]; then c=$(otp_verify "$CUST_PHONE" "$CODE") if [ "$c" = 200 ]; then ok "correct code -> 200" CTOK=$(sed -n 's/.*"token":"\([^"]*\)".*/\1/p' "$BODY_FILE") c=$(otp_verify "$CUST_PHONE" "$CODE") [ "$c" = 401 ] && ok "same code reused -> 401 (single use)" \ || bad "same code reused -> $c (want 401)" echo "Customer token works on customer routes:" for p in "customer/me" "customer/me/orders" "customer/me/ledger-entries"; do c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p" -H "Authorization: Bearer $CTOK") [ "$c" = 200 ] && ok "GET $p -> 200" || bad "GET $p -> $c (want 200)" done echo "Customer token must NOT work on admin routes:" for p in "admin/auth/me" "admin/customers-omde/list" "admin/dashboard/overview"; do c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p" -H "Authorization: Bearer $CTOK") [ "$c" = 401 ] && ok "GET $p -> 401" || bad "GET $p -> $c (want 401)" done echo "Admin token must NOT work on customer routes:" for p in "customer/me" "customer/me/orders"; do c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p" -H "Authorization: Bearer $SUPER") [ "$c" = 401 ] && ok "GET $p -> 401" || bad "GET $p -> $c (want 401)" done echo "No IDOR surface: /me* takes no id, and guessing one 404s:" for p in "customer/me/customer-6" "customer/customer-6" "customers/customer-6"; do c=$(curl -s -o /dev/null -w '%{http_code}' "$API/$p" -H "Authorization: Bearer $CTOK") [ "$c" = 404 ] && ok "GET $p -> 404" || bad "GET $p -> $c (want 404)" done echo "/customer/me returns the token's own customer:" who=$(curl -s "$API/customer/me" -H "Authorization: Bearer $CTOK" | sed -n 's/.*"mobile":"\([^"]*\)".*/\1/p') [ "$who" = "$CUST_PHONE" ] && ok "mobile = $who" \ || bad "mobile = $who (want $CUST_PHONE)" # ------------------------------------------------ SEC-02: orders --- # POST /orders was public with customerId in the body, so anyone could # place an order on anyone's account — and an order lands on that # customer's gold debt and ledger. echo "Orders require a customer token:" c=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$API/orders" \ -H 'Content-Type: application/json' \ -d '{"customerId":"x","items":[{"productVariantId":"x","quantity":1,"wagePercent":7}]}') [ "$c" = 401 ] && ok "POST orders without a token -> 401" \ || bad "POST orders without a token -> $c (want 401)" c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/any-id") [ "$c" = 401 ] && ok "GET orders/:id without a token -> 401" \ || bad "GET orders/:id without a token -> $c (want 401)" # A variant to order, and a customer that is NOT the token's owner. VARIANT=$(curl -s "$API/catalog/products" | sed -n 's/.*"variants":\[{"id":"\([^"]*\)".*/\1/p' | head -1) if [ -n "$VARIANT" ]; then echo "The body's customerId must be ignored in favour of the token:" ORD=$(curl -s -X POST "$API/orders" -H "Authorization: Bearer $CTOK" \ -H 'Content-Type: application/json' \ -d "{\"customerId\":\"some-other-customer\",\"items\":[{\"productVariantId\":\"$VARIANT\",\"quantity\":1,\"wagePercent\":7}]}") OWNER=$(printf '%s' "$ORD" | sed -n 's/.*"customerId":"\([^"]*\)".*/\1/p') MY_ID=$(curl -s "$API/customer/me" -H "Authorization: Bearer $CTOK" | sed -n 's/^{"id":"\([^"]*\)".*/\1/p') [ -n "$OWNER" ] && [ "$OWNER" = "$MY_ID" ] \ && ok "order landed on the token's customer ($OWNER), not the body's" \ || bad "order landed on '$OWNER', expected '$MY_ID'" # orderNumber is unique per order, so it identifies the row we just made # without having to parse nested item ids out of the response. ORDNUM=$(printf '%s' "$ORD" | sed -n 's/.*"orderNumber":"\([^"]*\)".*/\1/p') MYORD=$(curl -s "$API/customer/me/orders" -H "Authorization: Bearer $CTOK" | tr '{' '\n' | grep -F "\"orderNumber\":\"$ORDNUM\"" | sed -n 's/^"id":"\([^"]*\)".*/\1/p' | head -1) if [ -n "$MYORD" ]; then c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/$MYORD" -H "Authorization: Bearer $CTOK") [ "$c" = 200 ] && ok "own order readable -> 200" || bad "own order -> $c (want 200)" fi # Someone else's order and a nonexistent one must be indistinguishable, # or the status code itself confirms which ids exist. c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/definitely-not-an-order" \ -H "Authorization: Bearer $CTOK") [ "$c" = 404 ] && ok "unknown order id -> 404" || bad "unknown order id -> $c (want 404)" echo "Admin order read is admin-only:" c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/admin/any" -H "Authorization: Bearer $CTOK") [ "$c" = 401 ] && ok "customer token on orders/admin/:id -> 401" \ || bad "customer token on orders/admin/:id -> $c (want 401)" c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/admin/any" -H "Authorization: Bearer $SUPER") case "$c" in 401|403) bad "admin token on orders/admin/:id -> $c (guard rejected an admin)" ;; *) ok "admin token on orders/admin/:id -> $c (through the guard)" ;; esac else printf ' \033[33mSKIP\033[0m no product variant available to order\n' fi else bad "correct code -> $c (want 200)" fi else printf ' \033[33mSKIP\033[0m no OTP line in %s yet\n' "$CODE_LOG" fi else printf ' \033[33mSKIP\033[0m set CODE_LOG=/path/to/api.log to test the full OTP round trip\n' fi echo echo "passed: $pass failed: $fail" [ "$fail" -eq 0 ]