Skip to content

Security: Unauthenticated withdrawal endpoint can spend the server-controlled seller wallet #29

Description

@mssystem1

Summary

The Gateway withdrawal endpoint performs withdrawals using the server-side SELLER_PRIVATE_KEY, but the route does not authenticate or authorize the requester.

Any party that can reach the deployed endpoint can submit an arbitrary withdrawal request. The server then signs and executes the withdrawal using the seller’s private key.

The requester can also provide a custom destinationAddress, allowing funds to be withdrawn to an attacker-controlled address.

Affected file

app/api/gateway/withdraw/route.ts

process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
export async function POST(req: NextRequest) {
const privateKey = process.env.SELLER_PRIVATE_KEY;
if (!privateKey) {
return NextResponse.json(
{ error: "SELLER_PRIVATE_KEY not configured" },
{ status: 500 },
);
}
const body = await req.json();
const { amount, destinationChain, destinationAddress } = body as {
amount: string;
destinationChain: string;
destinationAddress?: string;
};
if (!amount || !destinationChain) {
return NextResponse.json(
{ error: "amount and destinationChain are required" },
{ status: 400 },
);
}
if (!(destinationChain in GATEWAY_DOMAINS)) {
return NextResponse.json(
{ error: `Unsupported chain: ${destinationChain}` },
{ status: 400 },
);
}
const gateway = new GatewayClient({
chain: "arcTestnet",
privateKey: privateKey as `0x${string}`,
});
const isCrossChain = destinationChain !== "arcTestnet";

Withdrawal execution:

.from("withdrawals")
.insert({
amount_usdc: amount,
destination_chain: destinationChain,
destination_address: destinationAddress ?? gateway.address,
status: "submitted",
})
.select()
.single();
if (insertError) {
return NextResponse.json(
{ error: "Failed to record withdrawal: " + insertError.message },
{ status: 500 },
);
}
try {
const result = await gateway.withdraw(amount, {
chain: destinationChain as SupportedChainName,
recipient: destinationAddress
? (destinationAddress as `0x${string}`)
: undefined,
});
// Update the withdrawal record with the transaction hash

Severity

Critical for any publicly reachable deployment.

Steps to reproduce

  1. Deploy the application with a funded seller wallet and configure:

    SELLER_PRIVATE_KEY=0x...
  2. Without signing in or supplying any authentication token, send a request to the withdrawal endpoint:

    curl -X POST https://example.com/api/gateway/withdraw \
      -H "Content-Type: application/json" \
      -d '{
        "amount": "1",
        "destinationChain": "baseSepolia",
        "destinationAddress": "0xATTACKER_ADDRESS"
      }'
  3. Observe that the endpoint:

    • reads the server-controlled seller private key;
    • checks the seller’s balance;
    • creates a withdrawal record;
    • executes gateway.withdraw(...);
    • sends the withdrawal to the supplied destination address.

No authentication or authorization check is performed before the transaction is initiated.

Expected behavior

Only an authenticated and explicitly authorized seller or administrator should be able to initiate withdrawals from the server-controlled wallet.

The destination address should either:

  • be derived from the authenticated seller account; or
  • require an additional privileged authorization check.

Actual behavior

The route accepts requests from unauthenticated callers and signs withdrawals using SELLER_PRIVATE_KEY.

The caller controls:

  • the withdrawal amount;
  • the destination chain;
  • the destination address.

Impact

An attacker may be able to:

  • drain the seller’s available Gateway balance;
  • redirect funds to an attacker-controlled address;
  • create arbitrary withdrawal records;
  • repeatedly trigger paid on-chain transactions;
  • consume native gas balances on source and destination chains;
  • cause denial of service by exhausting the seller wallet.

Because the transaction is signed by the server wallet, blockchain-level authorization does not prevent this attack.

Root cause

The route directly exposes a privileged server-side wallet operation without verifying:

  • that the requester is authenticated;
  • that the requester owns or controls the seller account;
  • that the requester has withdrawal privileges;
  • that the supplied destination address is allowed.

Suggested fix

Require authentication and role-based authorization before reading the seller key or performing any balance checks.

Example structure:

export async function POST(req: NextRequest) {
  const session = await getAuthenticatedSession(req);

  if (!session) {
    return NextResponse.json(
      { error: "Unauthorized" },
      { status: 401 }
    );
  }

  if (session.user.role !== "seller_admin") {
    return NextResponse.json(
      { error: "Forbidden" },
      { status: 403 }
    );
  }

  // Continue with withdrawal only after authorization.
}

Additionally:

  1. Derive the seller wallet from the authenticated account.
  2. Do not allow arbitrary destination addresses by default.
  3. Add withdrawal limits.
  4. Add rate limiting.
  5. Add idempotency keys.
  6. Record the authenticated actor with each withdrawal.
  7. Require re-authentication or a second confirmation for withdrawals.
  8. Consider moving privileged withdrawals to an internal-only service.

Tests

Add tests confirming that:

  • unauthenticated requests return 401;
  • authenticated non-seller users return 403;
  • only authorized seller accounts can withdraw;
  • arbitrary destination addresses are rejected unless explicitly permitted;
  • the seller private key is never accessed before authorization;
  • repeated requests with the same idempotency key do not create multiple withdrawals;
  • withdrawal limits are enforced.

Acceptance criteria

  • Unauthenticated callers cannot initiate withdrawals.
  • Regular authenticated users cannot initiate seller withdrawals.
  • The withdrawal actor is authorized server-side.
  • Destination addresses are validated against an explicit policy.
  • Withdrawal requests are rate-limited.
  • Idempotency protection prevents duplicate withdrawals.
  • Security tests cover unauthorized access.
  • The secure withdrawal model is documented.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions