Files
s.esmaeiliandClaude Opus 5 024e3dd045 parity(api,apps): close SEC-02 - orders take the customer from the token
The user declined to defer this to Phase 2, correctly: a public POST /orders
with customerId in the body let anyone place an order on any customer's
account, and an order lands on that customer's gold debt and ledger.

POST /orders is now behind CustomerAuth. OrdersService.createOrder takes the
customer id as a separate argument that the controller fills from the token;
the body's customerId is ignored and a mismatch is logged. The field stays
optional in the DTO and in shared-types only so both apps keep validating
while they still send it -- they drop it in Phase 2. The admin builder is
unaffected: it legitimately places orders for a chosen customer and passes
that id explicitly, behind the admin guard.

GET /orders/:id is scoped to the owner. A foreign or nonexistent id both
return 404, never 403, so ids stay unenumerable. Operators read through the
new GET /orders/admin/:id behind rid order.list, declared before the :id
route because Nest matches in declaration order.

Both apps' CartView now gates on session.isLoggedIn rather than a remembered
customerId, which is no longer a credential.

OTP audit (requested): no master code, no bypass, no hardcoded code anywhere;
one code path issues a token and only after bcrypt.compare. No SMS provider is
wired up -- in development the code is logged once, guarded by
NODE_ENV !== 'production'.

Fixed while auditing (SEC-11): deliver() persisted the real message text, code
included, into SmsMessage.body, which the admin SMS grid lists and full-text
searches. Any operator with the sms rid could read live codes, defeating the
hashing of codeHash. The stored row is now a redacted audit line.

Also logged MON-08: wagePercent/discountPercent/flatRialCharge are still taken
from the client on order creation. The reference is worse -- it takes
rash_weight from the client too -- so this is already a partial improvement;
logged rather than changed, per D4.

Tests: auth-smoke.sh now 53 passed, 0 failed, including a spoofed body
customerId being ignored in favour of the token. Typecheck clean on api,
user-app, user-app-v2 and shared-types.

[ref: henza_server ApiMobileController.php:533,536-545,615-632]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:09:33 +03:30

253 lines
11 KiB
Bash

#!/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 ]