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
-
Deploy the application with a funded seller wallet and configure:
-
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"
}'
-
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:
- Derive the seller wallet from the authenticated account.
- Do not allow arbitrary destination addresses by default.
- Add withdrawal limits.
- Add rate limiting.
- Add idempotency keys.
- Record the authenticated actor with each withdrawal.
- Require re-authentication or a second confirmation for withdrawals.
- 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
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.tsarc-nanopayments/app/api/gateway/withdraw/route.ts
Lines 39 to 77 in a29f920
Withdrawal execution:
arc-nanopayments/app/api/gateway/withdraw/route.ts
Lines 138 to 163 in a29f920
Severity
Critical for any publicly reachable deployment.
Steps to reproduce
Deploy the application with a funded seller wallet and configure:
Without signing in or supplying any authentication token, send a request to the withdrawal endpoint:
Observe that the endpoint:
gateway.withdraw(...);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:
Actual behavior
The route accepts requests from unauthenticated callers and signs withdrawals using
SELLER_PRIVATE_KEY.The caller controls:
Impact
An attacker may be able to:
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:
Suggested fix
Require authentication and role-based authorization before reading the seller key or performing any balance checks.
Example structure:
Additionally:
Tests
Add tests confirming that:
401;403;Acceptance criteria