Files
pwap/web/supabase/migrations/20260725000000_withdraw_authz_hardening.sql
T
pezkuwichain a8f41cd47f security(p2p): fix withdrawal BOLA, financial RLS exposure, admin dispute escrow
Critical/high audit remediation on the custodial P2P ledger. Auth model is
wallet-based; identity (citizen/visa) is cryptographically bound to a wallet
(People Chain tiki.citizenNft / active p2p_visa), which enables a correct fix.

- Withdrawal BOLA (CRITICAL): process-withdraw now requires a wallet-SIGNED
  challenge; server verifies signature (raw + <Bytes> forms), asserts the signer
  OWNS the identity, consumes a single-use nonce (replay), and derives user_id
  server-side — the client-supplied user_id is ignored. process-withdrawal (batch)
  now requires the service-role key (was: any bearer, incl. public anon key).
  request_withdraw is REVOKEd from anon/authenticated + service-role guarded.
- Financial RLS (HIGH): drop the blanket USING(true) SELECT on user_internal_balances,
  p2p_balance_transactions, p2p_deposit_withdraw_requests; lock p2p_user_payment_methods
  (IBAN PII) + p2p_fiat_disputes UPDATE to service_role; legitimate reads move behind
  scoped SECURITY DEFINER RPCs.
- Deposit integrity: verify-deposit now binds the on-chain sender to identity ownership
  before crediting.
- Admin dispute (CRITICAL fund-logic): DisputeResolutionPanel relabeled trades without
  moving escrow. New admin-signed resolve-dispute function + admin_resolve_dispute RPC
  moves escrow (release/refund/split) atomically with correct accounting (avoids the
  double-count in the legacy resolve_p2p_dispute). Client isAdmin documented as cosmetic.

DEPLOY RUNBOOK (gated; owner runs): 1) apply migrations 20260225/20260725* in order;
2) deploy edge functions process-withdraw, process-withdrawal, verify-deposit, resolve-dispute;
3) set edge secrets PEOPLE_RPC_ENDPOINT (+ optional ADMIN_WALLETS); 4) ship frontend.
Migrations + functions + frontend must go together or the app breaks.

KNOWN RESIDUAL (Round 2 — as severe as the withdrawal BOLA): release_/lock_/refund_
escrow_internal still have PUBLIC EXECUTE and the client calls release_escrow_internal
directly with the anon key from confirmPaymentReceived -> an anon caller can drain any
victim's LOCKED balance. Fix = a wallet-signed confirm-payment edge function (same
pattern as withdrawals) before revoking PUBLIC execute. Not yet fixed.
2026-07-25 00:09:03 -07:00

119 lines
4.5 KiB
PL/PgSQL

-- =====================================================
-- Migration: Withdrawal authorization hardening
-- Date: 2026-07-25
-- =====================================================
--
-- Part of the fix for the CRITICAL broken-object-level-auth finding on the
-- withdrawal path. Two defenses:
--
-- 1. Replay protection for the signed withdrawal challenge (nonce store).
-- 2. Defense-in-depth: request_withdraw() may ONLY be executed by the backend
-- service role. Previously it was executable by anon/authenticated, so an
-- attacker with the public anon key could call it directly with a victim's
-- user_id to create a pending withdrawal to an attacker-controlled wallet,
-- which the batch processor would then pay out.
--
-- =====================================================
-- 1. CHALLENGE NONCE STORE (single-use signed challenges)
-- =====================================================
CREATE TABLE IF NOT EXISTS public.p2p_challenge_nonces (
nonce TEXT PRIMARY KEY,
purpose TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_p2p_challenge_nonces_created
ON public.p2p_challenge_nonces(created_at);
ALTER TABLE public.p2p_challenge_nonces ENABLE ROW LEVEL SECURITY;
-- Only the service role (edge functions) may read/write nonces.
DROP POLICY IF EXISTS "challenge_nonces_service_only" ON public.p2p_challenge_nonces;
CREATE POLICY "challenge_nonces_service_only" ON public.p2p_challenge_nonces
FOR ALL USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
REVOKE ALL ON public.p2p_challenge_nonces FROM anon, authenticated, PUBLIC;
-- =====================================================
-- 2. LOCK request_withdraw() TO SERVICE ROLE
-- =====================================================
-- Recreate with an explicit service-role guard (mirrors process_deposit()).
CREATE OR REPLACE FUNCTION request_withdraw(
p_user_id UUID,
p_token TEXT,
p_amount DECIMAL(20, 12),
p_wallet_address TEXT
) RETURNS JSON AS $$
DECLARE
v_balance RECORD;
v_request_id UUID;
BEGIN
-- SECURITY: backend service role only. Object-level authorization (proving the
-- caller owns p_user_id) is enforced by the process-withdraw edge function via
-- a verified wallet signature before this is ever invoked.
IF current_setting('role', true) <> 'service_role'
AND current_setting('request.jwt.claim.role', true) <> 'service_role' THEN
RETURN json_build_object(
'success', false,
'error', 'UNAUTHORIZED: withdrawals must go through the backend service'
);
END IF;
-- Lock user's balance
SELECT * INTO v_balance
FROM user_internal_balances
WHERE user_id = p_user_id AND token = p_token
FOR UPDATE;
-- Check sufficient available balance
IF v_balance IS NULL OR v_balance.available_balance < p_amount THEN
RETURN json_build_object(
'success', false,
'error', 'Insufficient available balance. Available: ' || COALESCE(v_balance.available_balance, 0)
);
END IF;
-- Lock the amount (move to locked_balance)
UPDATE user_internal_balances
SET
available_balance = available_balance - p_amount,
locked_balance = locked_balance + p_amount,
updated_at = NOW()
WHERE user_id = p_user_id AND token = p_token;
-- Create withdrawal request
INSERT INTO p2p_deposit_withdraw_requests (
user_id, request_type, token, amount, wallet_address, status
) VALUES (
p_user_id, 'withdraw', p_token, p_amount, p_wallet_address, 'pending'
) RETURNING id INTO v_request_id;
-- Log the transaction
INSERT INTO p2p_balance_transactions (
user_id, token, transaction_type, amount,
balance_before, balance_after, reference_type, reference_id,
description
) VALUES (
p_user_id, p_token, 'withdraw', -p_amount,
v_balance.available_balance, v_balance.available_balance - p_amount, 'withdraw_request', v_request_id,
'Withdrawal request to ' || p_wallet_address
);
RETURN json_build_object(
'success', true,
'request_id', v_request_id,
'amount', p_amount,
'wallet_address', p_wallet_address,
'status', 'pending'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
REVOKE EXECUTE ON FUNCTION request_withdraw(UUID, TEXT, DECIMAL, TEXT) FROM PUBLIC, anon, authenticated;
COMMENT ON FUNCTION request_withdraw IS
'Create withdrawal request and lock balance. SERVICE ROLE ONLY — called by the
process-withdraw edge function after it verifies the caller owns the identity via
a wallet signature. Never call directly from the client.';