From 853a7dd6802776d4811099687be19b3fbb0eb59c Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 17:50:48 +0700 Subject: [PATCH 1/2] fix: implement SSE ticket exchange to prevent JWT exposure in URLs (closes #101) --- app/src/api/sse.ts | 39 +++++++++++++-- app/src/hooks/useSSE.ts | 23 ++++++--- packages/agent/src/routes/auth.ts | 80 +++++++++++++++++++++++++++++-- 3 files changed, 129 insertions(+), 13 deletions(-) diff --git a/app/src/api/sse.ts b/app/src/api/sse.ts index f4b94ab..53aa308 100644 --- a/app/src/api/sse.ts +++ b/app/src/api/sse.ts @@ -1,11 +1,44 @@ export type SSEHandler = (event: MessageEvent) => void -export function connectSSE( +const API_URL = import.meta.env.VITE_API_URL ?? '' + +/** + * Exchange a JWT for a short-lived, one-time SSE ticket. + * Falls back to null if the endpoint is unavailable (legacy server). + */ +async function fetchSseTicket(jwt: string): Promise { + try { + const res = await fetch(`${API_URL}/api/auth/sse-ticket`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + }) + if (!res.ok) return null + const data = (await res.json()) as { ticket?: string } + return data.ticket ?? null + } catch { + return null + } +} + +/** + * Create an SSE EventSource using a short-lived ticket (preferred) + * or falling back to raw JWT query param (legacy). + */ +export async function connectSSE( token: string, onEvent: SSEHandler, onError?: (err: Event) => void -): EventSource { - const url = `${import.meta.env.VITE_API_URL ?? ''}/api/stream?token=${encodeURIComponent(token)}` +): Promise { + // Try ticket exchange first — keeps JWT out of URLs + const ticket = await fetchSseTicket(token) + + const url = ticket + ? `${API_URL}/api/stream?ticket=${encodeURIComponent(ticket)}` + : `${API_URL}/api/stream?token=${encodeURIComponent(token)}` + const source = new EventSource(url) source.addEventListener('activity', onEvent) source.addEventListener('confirm', onEvent) diff --git a/app/src/hooks/useSSE.ts b/app/src/hooks/useSSE.ts index 07f892b..2a2cce5 100644 --- a/app/src/hooks/useSSE.ts +++ b/app/src/hooks/useSSE.ts @@ -17,16 +17,27 @@ export function useSSE(token: string | null) { useEffect(() => { if (!token) { setConnected(false); return } - const source = connectSSE(token, (e) => { + + let cancelled = false + + connectSSE(token, (e) => { const data = JSON.parse(e.data) as ActivityEvent setEvents(prev => [data, ...prev].slice(0, 200)) + }).then((source) => { + if (cancelled) { source.close(); return } + sourceRef.current = source + setConnected(true) + source.onerror = () => setConnected(false) + }).catch(() => { + setConnected(false) }) - sourceRef.current = source - setConnected(true) - - source.onerror = () => setConnected(false) - return () => { source.close(); sourceRef.current = null; setConnected(false) } + return () => { + cancelled = true + sourceRef.current?.close() + sourceRef.current = null + setConnected(false) + } }, [token]) return { events, connected } diff --git a/packages/agent/src/routes/auth.ts b/packages/agent/src/routes/auth.ts index c2a6374..99d7678 100644 --- a/packages/agent/src/routes/auth.ts +++ b/packages/agent/src/routes/auth.ts @@ -197,19 +197,84 @@ authRouter.post('/verify', (req: Request, res: Response) => { res.json({ token, expiresIn: JWT_EXPIRY }) }) +// ─── SSE Ticket Exchange ───────────────────────────────────────────────────── +// Short-lived one-time tickets for SSE connections (avoids JWT in URL) + +const SSE_TICKET_TTL = 30_000 // 30 seconds +const sseTickets = new Map() + +/** + * POST /auth/sse-ticket + * Exchanges a valid JWT for a short-lived, one-time SSE connection ticket. + * The ticket is a random string (not a JWT), safe to appear in URLs. + */ +authRouter.post('/sse-ticket', (req: Request, res: Response) => { + const authHeader = req.headers.authorization + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined + + if (!token) { + res.status(401).json({ error: 'Bearer token required' }) + return + } + + try { + const decoded = jwt.verify(token, getSecret(), { algorithms: ['HS256'] }) as { wallet: string } + const ticket = crypto.randomBytes(32).toString('hex') + sseTickets.set(ticket, { wallet: decoded.wallet, expires: Date.now() + SSE_TICKET_TTL }) + + // Cap ticket store — evict oldest entry on overflow + if (sseTickets.size > 10_000) { + const oldest = sseTickets.keys().next().value + if (oldest) sseTickets.delete(oldest) + } + + res.json({ ticket, expiresIn: SSE_TICKET_TTL / 1000 }) + } catch { + res.status(401).json({ error: 'invalid or expired token' }) + } +}) + +/** + * Validate and consume an SSE ticket. Returns the wallet if valid, null otherwise. + * Tickets are one-time use — deleted after first validation. + */ +export function consumeSseTicket(ticket: string): string | null { + const entry = sseTickets.get(ticket) + if (!entry || entry.expires < Date.now()) { + sseTickets.delete(ticket) + return null + } + sseTickets.delete(ticket) // one-time use + return entry.wallet +} + // ───────────────────────────────────────────────────────────────────────────── // Middleware // ───────────────────────────────────────────────────────────────────────────── /** - * Express middleware that validates a JWT from: - * - ?token= query param (preferred for SSE — EventSource cannot set headers) - * - Authorization: Bearer header + * Express middleware that validates auth from (in priority order): + * 1. ?ticket= query param (preferred for SSE — short-lived, one-time, no JWT in URL) + * 2. ?token= query param (legacy SSE fallback — discouraged, exposes JWT in URL) + * 3. Authorization: Bearer header * * On success, attaches `wallet` to the request object and calls next(). */ export function verifyJwt(req: Request, res: Response, next: NextFunction): void { - // Query param takes precedence (needed for SSE via EventSource) + // SSE ticket (preferred — no JWT in URL) + const ticket = req.query.ticket as string | undefined + if (ticket) { + const wallet = consumeSseTicket(ticket) + if (!wallet) { + res.status(401).json({ error: 'invalid or expired SSE ticket' }) + return + } + ;(req as unknown as Record).wallet = wallet + next() + return + } + + // JWT from query param (legacy SSE) or Authorization header const authHeader = req.headers.authorization const token = (req.query.token as string | undefined) ?? @@ -268,3 +333,10 @@ setInterval(() => { if (entry.resetAt < now) verifyAttempts.delete(ip) } }, 5 * 60 * 1000).unref() + +setInterval(() => { + const now = Date.now() + for (const [ticket, data] of sseTickets) { + if (data.expires < now) sseTickets.delete(ticket) + } +}, 30_000).unref() From 08513aca269ee2e8d5b62f5b6ec335fb30e57343 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 9 Apr 2026 17:56:43 +0700 Subject: [PATCH 2/2] fix: add on-chain transaction verification for payment link confirmation (closes #120) --- packages/agent/src/routes/pay.ts | 78 ++++++++- packages/agent/tests/pay-route.test.ts | 212 ++++++++++++++++++++++++- 2 files changed, 286 insertions(+), 4 deletions(-) diff --git a/packages/agent/src/routes/pay.ts b/packages/agent/src/routes/pay.ts index 95a8e82..59a1a15 100644 --- a/packages/agent/src/routes/pay.ts +++ b/packages/agent/src/routes/pay.ts @@ -1,4 +1,5 @@ import { Router } from 'express' +import { Connection, LAMPORTS_PER_SOL } from '@solana/web3.js' import { getPaymentLink, markPaymentLinkPaid } from '../db.js' import { renderPaymentPage, @@ -9,6 +10,69 @@ import { export const payRouter = Router() +// ───────────────────────────────────────────────────────────────────────────── +// On-chain transaction verification +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Verify a Solana transaction on-chain before accepting payment confirmation. + * Checks: tx exists, tx succeeded, recipient matches, amount matches. + * Fail-open on RPC errors — we don't want RPC downtime to block legitimate payments. + */ +export async function verifyTransaction( + txSignature: string, + expectedAddress: string, + expectedAmount: number | null, +): Promise<{ valid: boolean; error?: string }> { + const rpcUrl = process.env.SOLANA_RPC_URL ?? 'https://api.mainnet-beta.solana.com' + const connection = new Connection(rpcUrl, 'confirmed') + + try { + const tx = await connection.getTransaction(txSignature, { + maxSupportedTransactionVersion: 0, + }) + + if (!tx) { + return { valid: false, error: 'transaction not found on-chain' } + } + + if (tx.meta?.err) { + return { valid: false, error: 'transaction failed on-chain' } + } + + // Check the stealth address received funds + const accountKeys = tx.transaction.message.getAccountKeys() + const targetIndex = accountKeys.staticAccountKeys.findIndex( + key => key.toBase58() === expectedAddress, + ) + + if (targetIndex === -1) { + return { valid: false, error: 'transaction does not involve the expected address' } + } + + // Verify amount if specified (1% tolerance for rounding) + if (expectedAmount !== null && tx.meta) { + const preBalance = tx.meta.preBalances[targetIndex] ?? 0 + const postBalance = tx.meta.postBalances[targetIndex] ?? 0 + const receivedLamports = postBalance - preBalance + const receivedSol = receivedLamports / LAMPORTS_PER_SOL + + if (receivedSol < expectedAmount * 0.99) { + return { + valid: false, + error: `insufficient amount: received ${receivedSol.toFixed(4)} SOL, expected ${expectedAmount} SOL`, + } + } + } + + return { valid: true } + } catch (err) { + // RPC failures should not block payment — log and allow with warning + console.warn('[pay] on-chain verification failed, accepting signature:', err instanceof Error ? err.message : err) + return { valid: true } + } +} + payRouter.get('/:id', (req, res) => { const link = getPaymentLink(req.params.id) @@ -30,7 +94,7 @@ payRouter.get('/:id', (req, res) => { res.type('html').send(renderPaymentPage(link)) }) -payRouter.post('/:id/confirm', (req, res) => { +payRouter.post('/:id/confirm', async (req, res) => { const { txSignature } = req.body if (!txSignature || typeof txSignature !== 'string' || txSignature.length > 200) { @@ -55,6 +119,18 @@ payRouter.post('/:id/confirm', (req, res) => { return } + // On-chain verification: tx exists, succeeded, correct recipient & amount + const verification = await verifyTransaction( + txSignature, + link.stealth_address, + link.amount, + ) + + if (!verification.valid) { + res.status(400).json({ error: verification.error ?? 'transaction verification failed' }) + return + } + markPaymentLinkPaid(req.params.id, txSignature) res.json({ success: true, message: 'Payment confirmed' }) }) diff --git a/packages/agent/tests/pay-route.test.ts b/packages/agent/tests/pay-route.test.ts index 1a4edfd..6ca4a74 100644 --- a/packages/agent/tests/pay-route.test.ts +++ b/packages/agent/tests/pay-route.test.ts @@ -1,10 +1,25 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import express from 'express' import supertest from 'supertest' +import { PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js' import { closeDb, createPaymentLink, getPaymentLink, markPaymentLinkPaid } from '../src/db.js' +// Mock @solana/web3.js Connection to prevent real RPC calls +const mockGetTransaction = vi.fn() +vi.mock('@solana/web3.js', async (importOriginal) => { + const mod = await importOriginal() + return { + ...mod, + Connection: vi.fn().mockImplementation(() => ({ + getTransaction: mockGetTransaction, + })), + } +}) + beforeEach(() => { process.env.DB_PATH = ':memory:' + // Default: tx found, succeeded, correct address — tests override as needed + mockGetTransaction.mockReset() }) afterEach(() => { @@ -12,7 +27,7 @@ afterEach(() => { delete process.env.DB_PATH }) -const { payRouter } = await import('../src/routes/pay.js') +const { payRouter, verifyTransaction } = await import('../src/routes/pay.js') function createApp() { const app = express() @@ -21,6 +36,32 @@ function createApp() { return app } +/** Build a mock Solana transaction response for verifyTransaction */ +function mockSolanaTx( + recipientAddress: string, + preBalance: number, + postBalance: number, + opts?: { failed?: boolean }, +) { + return { + meta: { + err: opts?.failed ? { InstructionError: [0, 'GenericError'] } : null, + preBalances: [1_000_000_000, preBalance], + postBalances: [900_000_000, postBalance], + }, + transaction: { + message: { + getAccountKeys: () => ({ + staticAccountKeys: [ + { toBase58: () => 'SenderAddress111111111111111111111111111111' }, + { toBase58: () => recipientAddress }, + ], + }), + }, + }, + } +} + describe('GET /pay/:id', () => { it('returns 200 with HTML for a valid pending link', async () => { const link = createPaymentLink({ @@ -89,13 +130,15 @@ describe('GET /pay/:id', () => { }) describe('POST /pay/:id/confirm', () => { - it('marks a pending link as paid', async () => { + it('marks a pending link as paid after on-chain verification', async () => { createPaymentLink({ id: 'confirm-test', stealth_address: 'StEaLtH1111', ephemeral_pubkey: '0xeph', expires_at: Date.now() + 3600_000, }) + // Mock: valid tx to the correct address + mockGetTransaction.mockResolvedValueOnce(mockSolanaTx('StEaLtH1111', 0, 1_000_000)) const app = createApp() const res = await supertest(app) .post('/pay/confirm-test/confirm') @@ -159,4 +202,167 @@ describe('POST /pay/:id/confirm', () => { .send({ txSignature: 'tx' }) expect(res.status).toBe(404) }) + + it('rejects tx not found on-chain', async () => { + createPaymentLink({ + id: 'no-tx', + stealth_address: 'StEaLtH1111', + ephemeral_pubkey: '0xeph', + expires_at: Date.now() + 3600_000, + }) + mockGetTransaction.mockResolvedValueOnce(null) + const app = createApp() + const res = await supertest(app) + .post('/pay/no-tx/confirm') + .send({ txSignature: 'fake-sig-123' }) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/not found on-chain/i) + // Must NOT be marked paid + expect(getPaymentLink('no-tx')!.status).toBe('pending') + }) + + it('rejects tx that failed on-chain', async () => { + createPaymentLink({ + id: 'failed-tx', + stealth_address: 'StEaLtH1111', + ephemeral_pubkey: '0xeph', + expires_at: Date.now() + 3600_000, + }) + mockGetTransaction.mockResolvedValueOnce(mockSolanaTx('StEaLtH1111', 0, 0, { failed: true })) + const app = createApp() + const res = await supertest(app) + .post('/pay/failed-tx/confirm') + .send({ txSignature: 'failed-sig' }) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/failed on-chain/i) + expect(getPaymentLink('failed-tx')!.status).toBe('pending') + }) + + it('rejects tx sent to wrong address', async () => { + createPaymentLink({ + id: 'wrong-addr', + stealth_address: 'CorrectStealthAddr1111111111111111111111111', + ephemeral_pubkey: '0xeph', + expires_at: Date.now() + 3600_000, + }) + // Tx goes to a different address + mockGetTransaction.mockResolvedValueOnce(mockSolanaTx('WrongAddr1111111111111111111111111111111111', 0, 5_000_000_000)) + const app = createApp() + const res = await supertest(app) + .post('/pay/wrong-addr/confirm') + .send({ txSignature: 'wrong-dest-sig' }) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/expected address/i) + expect(getPaymentLink('wrong-addr')!.status).toBe('pending') + }) + + it('rejects tx with insufficient amount', async () => { + createPaymentLink({ + id: 'low-amount', + stealth_address: 'StEaLtH1111', + ephemeral_pubkey: '0xeph', + amount: 5.0, + expires_at: Date.now() + 3600_000, + }) + // Only 1 SOL received when 5 expected + mockGetTransaction.mockResolvedValueOnce(mockSolanaTx('StEaLtH1111', 0, 1 * LAMPORTS_PER_SOL)) + const app = createApp() + const res = await supertest(app) + .post('/pay/low-amount/confirm') + .send({ txSignature: 'low-amount-sig' }) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/insufficient amount/i) + expect(getPaymentLink('low-amount')!.status).toBe('pending') + }) + + it('accepts tx when amount is null (any-amount link)', async () => { + createPaymentLink({ + id: 'any-amount', + stealth_address: 'StEaLtH1111', + ephemeral_pubkey: '0xeph', + expires_at: Date.now() + 3600_000, + }) + // Any amount — just needs correct address + mockGetTransaction.mockResolvedValueOnce(mockSolanaTx('StEaLtH1111', 0, 100_000)) + const app = createApp() + const res = await supertest(app) + .post('/pay/any-amount/confirm') + .send({ txSignature: 'any-amount-sig' }) + expect(res.status).toBe(200) + expect(res.body.success).toBe(true) + expect(getPaymentLink('any-amount')!.status).toBe('paid') + }) + + it('fails open when RPC is unreachable', async () => { + createPaymentLink({ + id: 'rpc-down', + stealth_address: 'StEaLtH1111', + ephemeral_pubkey: '0xeph', + expires_at: Date.now() + 3600_000, + }) + // Simulate RPC failure + mockGetTransaction.mockRejectedValueOnce(new Error('fetch failed')) + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const app = createApp() + const res = await supertest(app) + .post('/pay/rpc-down/confirm') + .send({ txSignature: 'rpc-down-sig' }) + expect(res.status).toBe(200) + expect(res.body.success).toBe(true) + expect(getPaymentLink('rpc-down')!.status).toBe('paid') + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[pay] on-chain verification failed'), + expect.stringContaining('fetch failed'), + ) + consoleSpy.mockRestore() + }) +}) + +describe('verifyTransaction (unit)', () => { + it('returns valid for correct tx', async () => { + mockGetTransaction.mockResolvedValueOnce(mockSolanaTx('TestAddr', 0, 5 * LAMPORTS_PER_SOL)) + const result = await verifyTransaction('sig', 'TestAddr', 5.0) + expect(result).toEqual({ valid: true }) + }) + + it('returns invalid when tx is null', async () => { + mockGetTransaction.mockResolvedValueOnce(null) + const result = await verifyTransaction('sig', 'TestAddr', null) + expect(result).toEqual({ valid: false, error: 'transaction not found on-chain' }) + }) + + it('returns invalid when tx has error', async () => { + mockGetTransaction.mockResolvedValueOnce(mockSolanaTx('TestAddr', 0, 0, { failed: true })) + const result = await verifyTransaction('sig', 'TestAddr', null) + expect(result).toEqual({ valid: false, error: 'transaction failed on-chain' }) + }) + + it('returns invalid when address not in tx', async () => { + mockGetTransaction.mockResolvedValueOnce(mockSolanaTx('OtherAddr', 0, 5 * LAMPORTS_PER_SOL)) + const result = await verifyTransaction('sig', 'ExpectedAddr', null) + expect(result).toEqual({ valid: false, error: 'transaction does not involve the expected address' }) + }) + + it('allows 1% tolerance on amount', async () => { + // 4.96 SOL received, 5.0 expected — within 1% tolerance (4.95 is threshold) + mockGetTransaction.mockResolvedValueOnce(mockSolanaTx('Addr', 0, 4.96 * LAMPORTS_PER_SOL)) + const result = await verifyTransaction('sig', 'Addr', 5.0) + expect(result).toEqual({ valid: true }) + }) + + it('rejects below 1% tolerance', async () => { + // 4.9 SOL received, 5.0 expected — below 99% threshold + mockGetTransaction.mockResolvedValueOnce(mockSolanaTx('Addr', 0, 4.9 * LAMPORTS_PER_SOL)) + const result = await verifyTransaction('sig', 'Addr', 5.0) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/insufficient amount/) + }) + + it('fails open on RPC error', async () => { + mockGetTransaction.mockRejectedValueOnce(new Error('network timeout')) + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const result = await verifyTransaction('sig', 'Addr', 1.0) + expect(result).toEqual({ valid: true }) + consoleSpy.mockRestore() + }) })