Skip to content

Commit 12b2a36

Browse files
security+DRY: fix open redirect, add x402 replay protection, extract shared helpers
Security: - disconnect/route.ts: validate next param to prevent open redirect - x402Server.ts: add in-memory nonce dedup for settleOnChain:false routes - x402Server.ts: sanitize error detail fields to prevent info disclosure DRY (DRY): - api-utils.ts: add requireAuth, getEnv, ARC_USDC_ADDRESS, ARC_TREASURY_FALLBACK, ARC_CHAIN_ID - quiz/route.ts, quiz/claim/route.ts, thesis/[id]/route.ts, stats/route.ts: replace local NO_STORE_HEADERS with import - quiz/claim/route.ts, thesis/[id]/route.ts: replace local getEnv with import - keys/list/route.ts, keys/generate/route.ts, alerts/subscribe/route.ts: replace inline regex with isValidEthereumAddress
1 parent 9d6d5a0 commit 12b2a36

10 files changed

Lines changed: 111 additions & 42 deletions

File tree

frontend/src/app/api/alerts/subscribe/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import { NextResponse } from 'next/server'
1313
import { auth } from '../../../../../auth'
1414
import { prisma } from '@/lib/prisma'
15-
import { NO_STORE_HEADERS, handleServerError } from '@/lib/api-utils'
15+
import { NO_STORE_HEADERS, handleServerError, isValidEthereumAddress } from '@/lib/api-utils'
1616

1717
export const runtime = 'nodejs'
1818
export const dynamic = 'force-dynamic'
@@ -38,7 +38,7 @@ export async function POST(req: Request) {
3838
const body = (await req.json().catch(() => null)) as AlertPayload | null
3939
const wallet = typeof body?.wallet === 'string' ? body.wallet.trim() : ''
4040

41-
if (!wallet || !/^0x[a-fA-F0-9]{40}$/.test(wallet)) {
41+
if (!wallet || !isValidEthereumAddress(wallet)) {
4242
return NextResponse.json(
4343
{ success: false, error: 'Invalid wallet address' },
4444
{ status: 400, headers: NO_STORE_HEADERS }

frontend/src/app/api/disconnect/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ export async function GET(req: Request) {
1717
}
1818

1919
const { searchParams } = new URL(req.url)
20-
const redirectTo = searchParams.get('next') ?? '/'
20+
const rawNext = searchParams.get('next') ?? '/'
21+
// Prevent open redirect: only allow relative paths starting with /
22+
const redirectTo = rawNext.startsWith('/') && !rawNext.startsWith('//') ? rawNext : '/'
2123

2224
const mode = searchParams.get('mode')
2325
const response = mode === 'json'

frontend/src/app/api/keys/generate/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { NextResponse } from 'next/server'
1212
import { createHash, randomBytes } from 'crypto'
1313
import { auth } from '../../../../../auth'
1414
import { prisma } from '@/lib/prisma'
15-
import { NO_STORE_HEADERS, handleServerError } from '@/lib/api-utils'
15+
import { NO_STORE_HEADERS, handleServerError, isValidEthereumAddress } from '@/lib/api-utils'
1616
import { Tier } from '@/lib/subscription'
1717

1818
export const runtime = 'nodejs'
@@ -44,7 +44,7 @@ export async function POST(req: Request) {
4444
const body = (await req.json().catch(() => null)) as KeyPayload | null
4545
const wallet = typeof body?.wallet === 'string' ? body.wallet.trim() : ''
4646

47-
if (!wallet || !/^0x[a-fA-F0-9]{40}$/.test(wallet)) {
47+
if (!wallet || !isValidEthereumAddress(wallet)) {
4848
return NextResponse.json(
4949
{ success: false, error: 'Invalid wallet address' },
5050
{ status: 400, headers: NO_STORE_HEADERS }

frontend/src/app/api/keys/list/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import { NextResponse } from 'next/server'
99
import { auth } from '../../../../../auth'
1010
import { prisma } from '@/lib/prisma'
11-
import { NO_STORE_HEADERS, handleServerError } from '@/lib/api-utils'
11+
import { NO_STORE_HEADERS, handleServerError, isValidEthereumAddress } from '@/lib/api-utils'
1212

1313
export const runtime = 'nodejs'
1414
export const dynamic = 'force-dynamic'
@@ -26,7 +26,7 @@ export async function GET(req: Request) {
2626
const { searchParams } = new URL(req.url)
2727
const wallet = searchParams.get('wallet')?.trim() ?? ''
2828

29-
if (!wallet || !/^0x[a-fA-F0-9]{40}$/.test(wallet)) {
29+
if (!wallet || !isValidEthereumAddress(wallet)) {
3030
return NextResponse.json(
3131
{ success: false, error: 'Invalid wallet address' },
3232
{ status: 400, headers: NO_STORE_HEADERS }

frontend/src/app/api/quiz/claim/route.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,12 @@ import { NextResponse } from 'next/server'
1818
import { auth } from '../../../../../auth'
1919
import { prisma } from '@/lib/prisma'
2020
import { withX402 } from '@/lib/x402Server'
21-
22-
23-
function getEnv(val: string | undefined, fallback: string): string {
24-
if (!val || val === 'undefined' || val === 'null' || val === '') return fallback;
25-
return val;
26-
}
21+
import { NO_STORE_HEADERS, getEnv, ARC_TREASURY_FALLBACK, ARC_USDC_ADDRESS } from '@/lib/api-utils'
2722

2823
export const runtime = 'nodejs'
2924
export const dynamic = 'force-dynamic'
3025
export const revalidate = 0
3126

32-
const NO_STORE_HEADERS = {
33-
'Cache-Control': 'no-store, max-age=0',
34-
}
35-
3627
type QuizAttemptPayload = {
3728
desk?: unknown
3829
score?: unknown
@@ -50,9 +41,9 @@ export const POST = withX402(
5041
// 0.001 USDC processing fee — user earns 0.5 USDC reward on correct answers
5142
priceUsdc: 0.001,
5243
description: 'Quiz claim processing fee — earn 0.5 USDC on correct answer',
53-
treasuryAddress: getEnv(process.env.ROSETTA_TREASURY_ADDRESS, '0x000000000000000000000000000000000000dEaD'),
44+
treasuryAddress: getEnv(process.env.ROSETTA_TREASURY_ADDRESS, ARC_TREASURY_FALLBACK),
5445
arcRpcUrl: process.env.NEXT_PUBLIC_ARC_RPC_URL!,
55-
usdcAddress: getEnv(process.env.NEXT_PUBLIC_USDC_ARC_ADDRESS, '0x3600000000000000000000000000000000000000'),
46+
usdcAddress: getEnv(process.env.NEXT_PUBLIC_USDC_ARC_ADDRESS, ARC_USDC_ADDRESS),
5647
settlerPrivateKey: process.env.ARC_SETTLER_PRIVATE_KEY!,
5748
},
5849
async (req: Request) => {

frontend/src/app/api/quiz/route.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
import { NextResponse } from 'next/server'
22
import { auth } from '../../../../auth'
33
import { prisma } from '@/lib/prisma'
4+
import { NO_STORE_HEADERS, ARC_TREASURY_FALLBACK, ARC_USDC_ADDRESS } from '@/lib/api-utils'
45

56
export const runtime = 'nodejs'
67
export const dynamic = 'force-dynamic'
78
export const revalidate = 0
89

9-
const NO_STORE_HEADERS = {
10-
'Cache-Control': 'no-store, max-age=0',
11-
}
12-
1310
type QuizAttemptPayload = {
1411
desk?: unknown
1512
score?: unknown

frontend/src/app/api/stats/route.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
import { NextResponse } from 'next/server'
22
import { prisma } from '@/lib/prisma'
3+
import { NO_STORE_HEADERS } from '@/lib/api-utils'
34

45
export const runtime = 'nodejs'
56
export const dynamic = 'force-dynamic'
67
export const revalidate = 0
78

8-
const NO_STORE_HEADERS = {
9-
'Cache-Control': 'no-store, max-age=0',
10-
}
11-
129
/**
1310
* GET /api/stats
1411
*

frontend/src/app/api/thesis/[id]/route.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,29 +26,20 @@
2626
import { NextResponse } from 'next/server'
2727
import { prisma } from '@/lib/prisma'
2828
import { withX402 } from '@/lib/x402Server'
29-
30-
31-
function getEnv(val: string | undefined, fallback: string): string {
32-
if (!val || val === 'undefined' || val === 'null' || val === '') return fallback;
33-
return val;
34-
}
29+
import { NO_STORE_HEADERS, getEnv, ARC_TREASURY_FALLBACK, ARC_USDC_ADDRESS } from '@/lib/api-utils'
3530

3631
export const runtime = 'nodejs'
3732
export const dynamic = 'force-dynamic'
3833
export const revalidate = 0
3934

40-
const NO_STORE_HEADERS = {
41-
'Cache-Control': 'no-store, max-age=0',
42-
}
43-
4435
export const GET = withX402(
4536
{
4637
resource: '/api/thesis/[id]',
4738
priceUsdc: 0.001,
4839
description: 'Full thesis reasoning chain — 0.001 USDC',
49-
treasuryAddress: getEnv(process.env.ROSETTA_TREASURY_ADDRESS, '0x000000000000000000000000000000000000dEaD'),
40+
treasuryAddress: getEnv(process.env.ROSETTA_TREASURY_ADDRESS, ARC_TREASURY_FALLBACK),
5041
arcRpcUrl: process.env.NEXT_PUBLIC_ARC_RPC_URL!,
51-
usdcAddress: getEnv(process.env.NEXT_PUBLIC_USDC_ARC_ADDRESS, '0x3600000000000000000000000000000000000000'),
42+
usdcAddress: getEnv(process.env.NEXT_PUBLIC_USDC_ARC_ADDRESS, ARC_USDC_ADDRESS),
5243
settlerPrivateKey: process.env.ARC_SETTLER_PRIVATE_KEY!,
5344
// Thesis unlock is a demo read-gate: verify x402 proof, then unlock.
5445
// Quiz claims still perform on-chain settlement.

frontend/src/lib/api-utils.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { NextResponse } from 'next/server'
9+
import { auth } from '../../auth'
910

1011
/** Standard no-cache headers for all API responses. */
1112
export const NO_STORE_HEADERS = { 'Cache-Control': 'no-store, max-age=0' } as const
@@ -16,7 +17,46 @@ export function isValidEthereumAddress(addr: string): boolean {
1617
}
1718

1819
/**
19-
* Handle an unexpected server error — log + return structured 500 response.
20+
* Resolve an env var with a fallback.
21+
* Handles common misconfigurations: 'undefined', 'null', empty string.
22+
*/
23+
export function getEnv(val: string | undefined, fallback: string): string {
24+
if (!val || val === 'undefined' || val === 'null' || val === '') return fallback
25+
return val
26+
}
27+
28+
/**
29+
* Require authenticated session — returns 401 response if not logged in.
30+
* DRY: replaces copy-pasted auth guard blocks across all API routes.
31+
*
32+
* Usage:
33+
* const res = await requireAuth()
34+
* if (res) return res // 401 — stop here
35+
* const session = await auth() // guaranteed non-null after this
36+
*/
37+
export async function requireAuth(): Promise<NextResponse | null> {
38+
const session = await auth()
39+
if (!session?.user) {
40+
return NextResponse.json(
41+
{ success: false, error: 'Authentication required' },
42+
{ status: 401, headers: NO_STORE_HEADERS }
43+
)
44+
}
45+
return null
46+
}
47+
48+
// ─── Arc Testnet Constants ─────────────────────────────────────────────────
49+
50+
/** USDC contract address on Arc Testnet. */
51+
export const ARC_USDC_ADDRESS = '0x3600000000000000000000000000000000000000'
52+
53+
/** Treasury/dead address fallback. */
54+
export const ARC_TREASURY_FALLBACK = '0x000000000000000000000000000000000000dEaD'
55+
56+
/** Arc Testnet chain ID. */
57+
export const ARC_CHAIN_ID = 5042002
58+
59+
/** Handle an unexpected server error — log + return structured 500 response.
2060
* DRY: every route's catch block becomes `return handleServerError(error, 'Label')`.
2161
*/
2262
export function handleServerError(error: unknown, context: string): NextResponse {

frontend/src/lib/x402Server.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,39 @@ import { privateKeyToAccount } from 'viem/accounts'
5757
import { decodePaymentHeader, buildTransferAuthorizationMessage } from './eip3009'
5858
import type { SignedAuthorization } from './eip3009'
5959

60+
// ─── Replay Protection (settleOnChain: false only) ─────────────────────────
61+
62+
/**
63+
* In-memory nonce dedup for off-chain-only payments.
64+
* When settleOnChain: false, the USDC contract doesn't track the nonce,
65+
* so we must prevent replay attacks ourselves.
66+
*
67+
* Map key: nonce (hex string)
68+
* Map value: expiration timestamp (ms)
69+
* Cleanup runs every 60s to prevent memory growth.
70+
*/
71+
const usedNonces = new Map<string, number>()
72+
73+
function cleanupExpiredNonces(): void {
74+
const now = Date.now()
75+
for (const [nonce, expiresAt] of usedNonces) {
76+
if (expiresAt < now) usedNonces.delete(nonce)
77+
}
78+
}
79+
setInterval(cleanupExpiredNonces, 60_000)
80+
81+
function isNonceUsed(nonce: string): boolean {
82+
return usedNonces.has(nonce)
83+
}
84+
85+
function markNonceUsed(nonce: string, validBeforeMs: number): void {
86+
// Cap TTL to 5 minutes to prevent memory bloat from misconfigured validBefore
87+
const ttl = Math.min(validBeforeMs - Date.now(), 5 * 60 * 1000)
88+
if (ttl > 0) {
89+
usedNonces.set(nonce, Date.now() + ttl)
90+
}
91+
}
92+
6093
// ─── Arc Testnet Chain Definition (server-safe, no process.env at module level) ──
6194

6295
/**
@@ -203,10 +236,11 @@ export function withX402(
203236
// ── Step 4: Verify the payment ──
204237
const verification = await verifyPayment(signedAuth, config)
205238
if (!verification.valid) {
239+
console.warn(`[x402] Verification failed: ${verification.reason}`)
206240
return new Response(
207241
JSON.stringify({
208242
error: 'Payment verification failed',
209-
detail: verification.reason,
243+
detail: 'Payment signature verification failed',
210244
code: 'PAYMENT_VERIFICATION_FAILED',
211245
}),
212246
{
@@ -226,7 +260,7 @@ export function withX402(
226260
return new Response(
227261
JSON.stringify({
228262
error: 'Payment settlement failed',
229-
detail: message,
263+
detail: 'Settlement transaction failed',
230264
code: 'SETTLEMENT_FAILED',
231265
}),
232266
{
@@ -235,6 +269,23 @@ export function withX402(
235269
}
236270
)
237271
}
272+
} else {
273+
// Off-chain only: check nonce dedup to prevent replay
274+
const nonceHex = signedAuth.nonce.toLowerCase()
275+
if (isNonceUsed(nonceHex)) {
276+
return new Response(
277+
JSON.stringify({
278+
error: 'Payment already used',
279+
detail: 'This payment has already been redeemed',
280+
code: 'PAYMENT_REPLAY_DETECTED',
281+
}),
282+
{
283+
status: 402,
284+
headers: { 'Content-Type': 'application/json' },
285+
}
286+
)
287+
}
288+
markNonceUsed(nonceHex, Number(signedAuth.validBefore) * 1000)
238289
}
239290

240291
// ── Step 6: Payment verified/settled! Call the actual handler ──

0 commit comments

Comments
 (0)