Skip to content

fix: verify wallet ownership in bridge estimate and rebalance - #26

Open
memosr wants to merge 1 commit into
circlefin:masterfrom
memosr:fix/idor-bridge-rebalance-wallet-ownership
Open

fix: verify wallet ownership in bridge estimate and rebalance#26
memosr wants to merge 1 commit into
circlefin:masterfrom
memosr:fix/idor-bridge-rebalance-wallet-ownership

Conversation

@memosr

@memosr memosr commented Jun 3, 2026

Copy link
Copy Markdown

Problem

app/api/bridge/rebalance/route.ts and app/api/bridge/estimate/route.ts authenticate the caller but accept sourceWalletId and destinationWalletId from the POST body without verifying those wallets belong to the authenticated user.

Why this matters

The Circle Developer SDK operates with an app-level API key that has signing authority over every wallet in the developer account — there is no per-user scope at the SDK level. The ownership boundary is enforced only by the wallets table in Supabase, which these two handlers never query for authorization.

Exploit

User A (authenticated) sends POST /api/bridge/rebalance with:

{
  "sourceWalletId":      "<User B's circle_wallet_id>",
  "destinationWalletId": "<User A's circle_wallet_id>",
  ...
}

The handler validates User A's session, fetches both wallets from Circle (succeeds — both are in the same developer account), and executes the bridge transfer. User B's USDC is burned on the source chain and minted into User A's wallet. The on-chain burn is irreversible once the CCTP attestation is submitted. The inserted transaction record carries user_id: user.id (User A), so it never appears in User B's history.

/api/bridge/estimate has the same missing ownership check, allowing User A to silently probe User B's wallet balances before the attack.

By contrast, app/api/wallet/transfer/route.ts:52-57 and app/api/gateway/deposit/route.ts:76-82 correctly scope their Supabase query with .eq("user_id", user.id) before touching Circle.

Fix

Added ownership checks for both sourceWalletId and destinationWalletId before any Circle SDK call. Pattern matches transfer/route.ts:

+ const { data: srcRecord } = await supabase
+   .from("wallets")
+   .select("circle_wallet_id")
+   .eq("user_id", user.id)
+   .eq("circle_wallet_id", sourceWalletId)
+   .single();
+ if (!srcRecord) {
+   return NextResponse.json(
+     { error: "Source wallet not found or access denied" },
+     { status: 404 }
+   );
+ }
+
+ const { data: dstRecord } = await supabase
+   .from("wallets")
+   .select("circle_wallet_id")
+   .eq("user_id", user.id)
+   .eq("circle_wallet_id", destinationWalletId)
+   .single();
+ if (!dstRecord) {
+   return NextResponse.json(
+     { error: "Destination wallet not found or access denied" },
+     { status: 404 }
+   );
+ }

  // existing Circle SDK calls follow

Same change applied to both bridge/estimate/route.ts and bridge/rebalance/route.ts.

Impact

  • Security: Closes IDOR vulnerability that allowed authenticated users to drain other users' wallets via cross-chain bridge.
  • Privacy: Closes the silent reconnaissance vector in estimate (no more probing other users' balances).
  • Consistency: Both routes now match the auth + ownership pattern used by wallet/transfer/route.ts.
  • Risk: Low — legitimate users with valid wallet IDs are unaffected. Unauthorized wallet IDs now return 404 instead of silently executing the transfer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant