diff --git a/src/app/api/stellar/send/route.ts b/src/app/api/stellar/send/route.ts index 3ab4dab..8cf4d24 100644 --- a/src/app/api/stellar/send/route.ts +++ b/src/app/api/stellar/send/route.ts @@ -27,7 +27,9 @@ export async function POST(request: NextRequest) { const rl = rateLimit(`send:${user.id}`, 20, 60_000); if (!rl.allowed) { logSecurityEvent("rate_limited", { userId: user.id, endpoint: "stellar/send" }); - return errorResponse("Too many send requests. Please try again later.", 429); + return errorResponse("Too many send requests. Please try again later.", 429, { + "Retry-After": String(Math.ceil(rl.retryAfterMs / 1000)), + }); } const body = (await readBodyWithLimit(request)) as Record; diff --git a/src/app/api/stellar/sign-and-submit/__tests__/route.test.ts b/src/app/api/stellar/sign-and-submit/__tests__/route.test.ts new file mode 100644 index 0000000..7286501 --- /dev/null +++ b/src/app/api/stellar/sign-and-submit/__tests__/route.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; +import { Keypair } from "@stellar/stellar-sdk"; + +// --------------------------------------------------------------------------- +// Rate-limit coverage for POST /api/stellar/sign-and-submit. This endpoint +// takes a raw Stellar secret key and signs server-side, so it gets a tighter +// per-user budget (10/min) than /api/stellar/submit (20/min). +// --------------------------------------------------------------------------- + +const { + mockGetCurrentUser, + mockSubmitTransaction, + mockFrom, + mockMaybeSingle, + mockSingle, + mockFromXDR, +} = vi.hoisted(() => { + const mockMaybeSingle = vi.fn(); + const mockSingle = vi.fn(); + const chain: Record = {}; + chain.select = vi.fn(() => chain); + chain.eq = vi.fn(() => chain); + chain.update = vi.fn(() => chain); + chain.maybeSingle = mockMaybeSingle; + chain.single = mockSingle; + return { + mockGetCurrentUser: vi.fn(), + mockSubmitTransaction: vi.fn(), + mockFrom: vi.fn(() => chain), + mockMaybeSingle, + mockSingle, + mockFromXDR: vi.fn(), + }; +}); + +vi.mock("@/lib/auth", () => ({ getCurrentUser: mockGetCurrentUser })); +vi.mock("@/lib/supabase", () => ({ supabase: { from: mockFrom } })); +vi.mock("@/lib/stellar", () => ({ + submitTransaction: mockSubmitTransaction, + NETWORK_PASSPHRASE: "Test SDF Network ; September 2015", +})); +vi.mock("@stellar/stellar-sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + TransactionBuilder: { ...actual.TransactionBuilder, fromXDR: mockFromXDR }, + }; +}); + +const KEYPAIR = Keypair.random(); +const USER = { id: "user-1", stellarPublicKey: KEYPAIR.publicKey() }; +const TX_ID = "22222222-2222-4222-8222-222222222222"; + +function postRequest(overrides: Record = {}) { + return new NextRequest("http://localhost/api/stellar/sign-and-submit", { + method: "POST", + body: JSON.stringify({ + transactionId: TX_ID, + xdr: "AAAAAgAAAAB1bnNpZ25lZA==", + secretKey: KEYPAIR.secret(), + ...overrides, + }), + headers: { "content-type": "application/json" }, + }); +} + +function stubHappyPathDb(userId = USER.id) { + mockMaybeSingle.mockResolvedValue({ + data: { id: TX_ID, userId, status: "pending" }, + error: null, + }); + mockSingle.mockResolvedValue({ + data: { id: TX_ID, userId, status: "confirmed", stellarTxHash: "hash" }, + error: null, + }); + mockFromXDR.mockReturnValue({ sign: vi.fn(), toXDR: () => "signed-xdr" }); + mockSubmitTransaction.mockResolvedValue({ hash: "hash", status: "confirmed" }); +} + +async function importRoute() { + vi.resetModules(); + return await import("../route"); +} + +beforeEach(() => { + mockGetCurrentUser.mockReset(); + mockSubmitTransaction.mockReset(); + mockFrom.mockClear(); + mockMaybeSingle.mockReset(); + mockSingle.mockReset(); + mockFromXDR.mockReset(); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("POST /api/stellar/sign-and-submit rate limiting", () => { + it("allows the first 10 signing requests in a window", async () => { + mockGetCurrentUser.mockResolvedValue(USER); + stubHappyPathDb(); + const { POST } = await importRoute(); + + for (let i = 0; i < 10; i++) { + expect((await POST(postRequest())).status, `request #${i + 1}`).toBe(200); + } + }); + + it("returns 429 with Retry-After on the 11th request", async () => { + mockGetCurrentUser.mockResolvedValue(USER); + stubHappyPathDb(); + const { POST } = await importRoute(); + + for (let i = 0; i < 10; i++) await POST(postRequest()); + const response = await POST(postRequest()); + const body = await response.json(); + + expect(response.status).toBe(429); + expect(body).toEqual({ + success: false, + error: "Too many signing requests. Please try again later.", + }); + const retryAfter = Number(response.headers.get("Retry-After")); + expect(retryAfter).toBeGreaterThan(0); + expect(retryAfter).toBeLessThanOrEqual(60); + }); + + it("is stricter than /api/stellar/submit because it handles secret keys", async () => { + mockGetCurrentUser.mockResolvedValue(USER); + stubHappyPathDb(); + const { POST } = await importRoute(); + + for (let i = 0; i < 10; i++) await POST(postRequest()); + // 20 would still be allowed on /api/stellar/submit; here it must not be. + expect((await POST(postRequest())).status).toBe(429); + }); + + it("never reaches the signing/submit path once rate limited", async () => { + mockGetCurrentUser.mockResolvedValue(USER); + stubHappyPathDb(); + const { POST } = await importRoute(); + + for (let i = 0; i < 10; i++) await POST(postRequest()); + mockFromXDR.mockClear(); + mockSubmitTransaction.mockClear(); + mockFrom.mockClear(); + + await POST(postRequest()); + + expect(mockFromXDR).not.toHaveBeenCalled(); + expect(mockSubmitTransaction).not.toHaveBeenCalled(); + expect(mockFrom).not.toHaveBeenCalled(); + }); + + it("burns budget on rejected secret keys too, so key-guessing is throttled", async () => { + mockGetCurrentUser.mockResolvedValue(USER); + stubHappyPathDb(); + const { POST } = await importRoute(); + + for (let i = 0; i < 10; i++) { + const response = await POST(postRequest({ secretKey: "SNOTAREALSECRETKEY" })); + expect(response.status).toBe(400); + } + expect((await POST(postRequest())).status).toBe(429); + }); + + it("keeps separate budgets per user", async () => { + mockGetCurrentUser.mockResolvedValue(USER); + stubHappyPathDb(); + const { POST } = await importRoute(); + + for (let i = 0; i < 11; i++) await POST(postRequest()); + expect((await POST(postRequest())).status).toBe(429); + + mockGetCurrentUser.mockResolvedValue({ ...USER, id: "user-2" }); + stubHappyPathDb("user-2"); + expect((await POST(postRequest())).status).toBe(200); + }); + + it("still answers 401 for an anonymous caller", async () => { + mockGetCurrentUser.mockResolvedValue(null); + const { POST } = await importRoute(); + + expect((await POST(postRequest())).status).toBe(401); + expect(mockFrom).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/stellar/sign-and-submit/route.ts b/src/app/api/stellar/sign-and-submit/route.ts index a6360ff..9808895 100644 --- a/src/app/api/stellar/sign-and-submit/route.ts +++ b/src/app/api/stellar/sign-and-submit/route.ts @@ -4,8 +4,15 @@ import { supabase } from "@/lib/supabase"; import { getCurrentUser } from "@/lib/auth"; import { submitTransaction, NETWORK_PASSPHRASE } from "@/lib/stellar"; import { successResponse, errorResponse, unauthorizedResponse } from "@/lib/api-response"; +import { rateLimit, logSecurityEvent } from "@/lib/security"; import type { Transaction } from "@/lib/types"; +// Tighter than /api/stellar/submit: this endpoint accepts a raw secret key +// and signs server-side, so an attacker who got hold of a session cookie +// should not be able to spray signing attempts at it. +const SIGN_SUBMIT_LIMIT = 10; +const SIGN_SUBMIT_WINDOW_MS = 60_000; + // --------------------------------------------------------------------------- // Testnet-only convenience endpoint: signs server-side with a secret key // supplied in the request body, so the demo works without a browser wallet @@ -23,6 +30,14 @@ export async function POST(request: NextRequest) { return unauthorizedResponse(); } + const rl = rateLimit(`sign-and-submit:${user.id}`, SIGN_SUBMIT_LIMIT, SIGN_SUBMIT_WINDOW_MS); + if (!rl.allowed) { + logSecurityEvent("rate_limited", { userId: user.id, endpoint: "stellar/sign-and-submit" }); + return errorResponse("Too many signing requests. Please try again later.", 429, { + "Retry-After": String(Math.ceil(rl.retryAfterMs / 1000)), + }); + } + const body = await request.json(); const { transactionId, xdr, secretKey } = body as { transactionId?: string; diff --git a/src/app/api/stellar/submit/__tests__/route.test.ts b/src/app/api/stellar/submit/__tests__/route.test.ts new file mode 100644 index 0000000..9c65145 --- /dev/null +++ b/src/app/api/stellar/submit/__tests__/route.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; +import { z } from "zod"; + +// --------------------------------------------------------------------------- +// Rate-limit coverage for POST /api/stellar/submit. The limiter buckets live +// in module scope inside @/lib/security, so every test re-imports the route +// through vi.resetModules() to start from a clean bucket. +// --------------------------------------------------------------------------- + +const { mockGetCurrentUser, mockSubmitTransaction, mockFrom, mockMaybeSingle, mockSingle } = + vi.hoisted(() => { + const mockMaybeSingle = vi.fn(); + const mockSingle = vi.fn(); + const chain: Record = {}; + chain.select = vi.fn(() => chain); + chain.eq = vi.fn(() => chain); + chain.update = vi.fn(() => chain); + chain.maybeSingle = mockMaybeSingle; + chain.single = mockSingle; + return { + mockGetCurrentUser: vi.fn(), + mockSubmitTransaction: vi.fn(), + mockFrom: vi.fn(() => chain), + mockMaybeSingle, + mockSingle, + }; + }); + +vi.mock("@/lib/auth", () => ({ getCurrentUser: mockGetCurrentUser })); +vi.mock("@/lib/supabase", () => ({ supabase: { from: mockFrom } })); +vi.mock("@/lib/stellar", () => ({ submitTransaction: mockSubmitTransaction })); + +// @/lib/validations currently throws at import time on main +// (`ReferenceError: isValidStellarPublicKey is not defined`, introduced by +// #527, fix pending in #529), which would make this route un-importable in a +// test. Mock it with a copy of the real stellarSubmitSchema - identical +// definition, and unaffected by that bug. Once #529 lands this mock can go. +vi.mock("@/lib/validations", () => ({ + stellarSubmitSchema: z.object({ + signedXdr: z.string().min(1, "Signed XDR is required"), + transactionId: z.string().min(1, "Transaction ID is required"), + }), +})); + +const USER = { id: "user-1", stellarPublicKey: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" }; +const SIGNED_XDR = "AAAAAgAAAABzaWduZWQ="; +const TX_ID = "11111111-1111-4111-8111-111111111111"; + +function postRequest() { + return new NextRequest("http://localhost/api/stellar/submit", { + method: "POST", + body: JSON.stringify({ signedXdr: SIGNED_XDR, transactionId: TX_ID }), + headers: { "content-type": "application/json" }, + }); +} + +/** Happy-path DB responses: the transaction exists, belongs to the user and + * is still pending, and the post-submit update succeeds. */ +function stubHappyPathDb() { + mockMaybeSingle.mockResolvedValue({ + data: { id: TX_ID, userId: USER.id, status: "pending" }, + error: null, + }); + mockSingle.mockResolvedValue({ + data: { + id: TX_ID, + userId: USER.id, + status: "confirmed", + stellarTxHash: "hash", + fromAsset: "XLM", + toAsset: "XLM", + fromAmount: "1", + toAmount: "1", + }, + error: null, + }); + mockSubmitTransaction.mockResolvedValue({ hash: "hash", status: "confirmed" }); +} + +async function importRoute() { + vi.resetModules(); + return await import("../route"); +} + +beforeEach(() => { + mockGetCurrentUser.mockReset(); + mockSubmitTransaction.mockReset(); + mockFrom.mockClear(); + mockMaybeSingle.mockReset(); + mockSingle.mockReset(); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("POST /api/stellar/submit rate limiting", () => { + it("allows the first 20 submissions in a window", async () => { + mockGetCurrentUser.mockResolvedValue(USER); + stubHappyPathDb(); + const { POST } = await importRoute(); + + for (let i = 0; i < 20; i++) { + const response = await POST(postRequest()); + expect(response.status, `request #${i + 1}`).toBe(200); + } + }); + + it("returns 429 with the shared error shape once the limit is exceeded", async () => { + mockGetCurrentUser.mockResolvedValue(USER); + stubHappyPathDb(); + const { POST } = await importRoute(); + + for (let i = 0; i < 20; i++) await POST(postRequest()); + const response = await POST(postRequest()); + const body = await response.json(); + + expect(response.status).toBe(429); + expect(body).toEqual({ + success: false, + error: "Too many submit requests. Please try again later.", + }); + }); + + it("sends a Retry-After header in seconds on the 429", async () => { + mockGetCurrentUser.mockResolvedValue(USER); + stubHappyPathDb(); + const { POST } = await importRoute(); + + for (let i = 0; i < 20; i++) await POST(postRequest()); + const response = await POST(postRequest()); + + const retryAfter = Number(response.headers.get("Retry-After")); + expect(Number.isNaN(retryAfter)).toBe(false); + expect(retryAfter).toBeGreaterThan(0); + expect(retryAfter).toBeLessThanOrEqual(60); + }); + + it("does not hit Horizon or the database once rate limited", async () => { + mockGetCurrentUser.mockResolvedValue(USER); + stubHappyPathDb(); + const { POST } = await importRoute(); + + for (let i = 0; i < 20; i++) await POST(postRequest()); + mockFrom.mockClear(); + mockSubmitTransaction.mockClear(); + + await POST(postRequest()); + + expect(mockSubmitTransaction).not.toHaveBeenCalled(); + expect(mockFrom).not.toHaveBeenCalled(); + }); + + it("buckets per user, so one noisy account cannot lock out another", async () => { + mockGetCurrentUser.mockResolvedValue(USER); + stubHappyPathDb(); + const { POST } = await importRoute(); + + for (let i = 0; i < 21; i++) await POST(postRequest()); + expect((await POST(postRequest())).status).toBe(429); + + mockGetCurrentUser.mockResolvedValue({ ...USER, id: "user-2" }); + // The stored transaction has to belong to user-2 as well, otherwise the + // ownership check (not the limiter) would be what answers. + mockMaybeSingle.mockResolvedValue({ + data: { id: TX_ID, userId: "user-2", status: "pending" }, + error: null, + }); + expect((await POST(postRequest())).status).toBe(200); + }); + + it("rate limits only authenticated callers - anonymous requests still get 401", async () => { + mockGetCurrentUser.mockResolvedValue(null); + const { POST } = await importRoute(); + + const response = await POST(postRequest()); + expect(response.status).toBe(401); + expect(mockFrom).not.toHaveBeenCalled(); + }); + + it("counts a request against the budget even when it fails validation", async () => { + mockGetCurrentUser.mockResolvedValue(USER); + const { POST } = await importRoute(); + + const bad = () => + new NextRequest("http://localhost/api/stellar/submit", { + method: "POST", + body: JSON.stringify({}), + headers: { "content-type": "application/json" }, + }); + + for (let i = 0; i < 20; i++) { + expect((await POST(bad())).status).toBe(400); + } + // Otherwise malformed bodies would be a free unlimited channel. + expect((await POST(bad())).status).toBe(429); + }); +}); diff --git a/src/app/api/stellar/submit/route.ts b/src/app/api/stellar/submit/route.ts index 3308940..d1a51e7 100644 --- a/src/app/api/stellar/submit/route.ts +++ b/src/app/api/stellar/submit/route.ts @@ -4,8 +4,15 @@ import { getCurrentUser } from "@/lib/auth"; import { submitTransaction } from "@/lib/stellar"; import { stellarSubmitSchema } from "@/lib/validations"; import { successResponse, errorResponse, unauthorizedResponse } from "@/lib/api-response"; +import { rateLimit, logSecurityEvent } from "@/lib/security"; import type { Transaction } from "@/lib/types"; +// Submitting is cheap for the caller but expensive for us (a Horizon +// round-trip plus two DB writes each time), so it gets the same per-user +// budget as /api/stellar/send. +const SUBMIT_LIMIT = 20; +const SUBMIT_WINDOW_MS = 60_000; + export async function POST(request: NextRequest) { try { const user = await getCurrentUser(); @@ -13,6 +20,14 @@ export async function POST(request: NextRequest) { return unauthorizedResponse(); } + const rl = rateLimit(`submit:${user.id}`, SUBMIT_LIMIT, SUBMIT_WINDOW_MS); + if (!rl.allowed) { + logSecurityEvent("rate_limited", { userId: user.id, endpoint: "stellar/submit" }); + return errorResponse("Too many submit requests. Please try again later.", 429, { + "Retry-After": String(Math.ceil(rl.retryAfterMs / 1000)), + }); + } + const body = await request.json(); const parsed = stellarSubmitSchema.safeParse(body); diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index 2eb7dc6..fa72901 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -4,10 +4,14 @@ export function successResponse(data: T, status = 200) { return NextResponse.json({ success: true, data }, { status }); } -export function errorResponse(message: string, status = 400) { +export function errorResponse( + message: string, + status = 400, + headers?: HeadersInit +) { return NextResponse.json( { success: false, error: message }, - { status } + headers ? { status, headers } : { status } ); }