diff --git a/src/app/api/chains/[family]/addresses/[address]/balance/route.ts b/src/app/api/chains/[family]/addresses/[address]/balance/route.ts index c2a3933..33abf9d 100644 --- a/src/app/api/chains/[family]/addresses/[address]/balance/route.ts +++ b/src/app/api/chains/[family]/addresses/[address]/balance/route.ts @@ -8,6 +8,12 @@ import { BalanceSchema } from "../../../../../schemas/schemas" import { getChainName } from "../../../../../utils/tatumUtils" import { throwIfQueryParamIsNull } from "../../../../../utils/throwIfQueryParamIsNull" +// cache balance for 5 seconds +const headers = { + headers: { + "Cache-Control": `public, max-age=${5}`, + }, +} export async function GET( _request: NextRequest, context: { @@ -47,7 +53,8 @@ export async function GET( `Failed to parse balance for ${address} on ${family} : ${parsedResp.error}`, ) } - return Response.json(parsedResp.data) + + return Response.json(parsedResp.data, headers) } catch (e: unknown) { console.error(e) return Response.json({ message: "Failed to process request", error: e }) diff --git a/src/proxy.rateLimit.test.ts b/src/proxy.rateLimit.test.ts new file mode 100644 index 0000000..80285bc --- /dev/null +++ b/src/proxy.rateLimit.test.ts @@ -0,0 +1,133 @@ +import { NextRequest } from "next/server" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { proxy } from "./proxy" + +const ORIGIN = "http://localhost:3000" +const NOW = Date.UTC(2026, 9, 8, 12) +const WINDOW_MS = 60_000 + +let ipCounter = 0 +const freshIp = () => `proxy-rl-${++ipCounter}` + +type CallInit = { + method?: string + ip?: string | null + headers?: Record +} + +const call = (path: string, { method = "GET", ip, headers }: CallInit = {}) => + proxy( + new NextRequest(`${ORIGIN}${path}`, { + method, + headers: { + Origin: ORIGIN, + Host: "localhost:3000", + ...(ip === null ? {} : { "x-real-ip": ip ?? freshIp() }), + ...headers, + }, + }), + ) + +const passesThrough = (response: Response) => + response.headers.get("x-middleware-next") === "1" + +const exhaust = async (path: string, limit: number, ip = freshIp()) => { + for (let i = 0; i < limit; i += 1) { + expect((await call(path, { ip })).status).not.toBe(429) + } + return call(path, { ip }) +} + +beforeEach(() => { + vi.spyOn(Date, "now").mockReturnValue(NOW) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe("proxy cross-origin protection", () => { + it.each([ + ["Sec-Fetch-Site cross-site", { "sec-fetch-site": "cross-site" }], + ["mismatched Origin", { Origin: "https://evil.example" }], + ])("blocks %s", async (_label, headers) => { + const response = await call("/api/chains/BTC/fee", { headers }) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ message: "Forbidden" }) + }) + + it("allows same-origin", async () => { + expect( + passesThrough( + await call("/api/chains/BTC/fee", { + headers: { "sec-fetch-site": "same-origin" }, + }), + ), + ).toBe(true) + }) +}) + +describe("proxy rate limiting", () => { + it.each([ + ["balance", 30, "/api/chains/EVM/addresses/0xabc/balance"], + ["history", 12, "/api/chains/EVM/addresses/0xabc/history"], + ["utxos", 18, "/api/chains/BTC/addresses/bc1qtest/utxos"], + ["txLookup", 30, "/api/chains/BTC/transactions/abc123"], + ["fee", 60, "/api/chains/BTC/fee"], + ])("limits %s endpoint", async (_label, limit, path) => { + const blocked = await exhaust(path, limit) + expect(blocked.status).toBe(429) + expect(await blocked.json()).toEqual({ message: "Too many requests" }) + expect(blocked.headers.get("retry-after")).toBe("60") + }) + + it("tracks counters independently per IP", async () => { + const path = "/api/chains/EVM/addresses/0xabc/history" + expect((await exhaust(path, 12, "ip-a")).status).toBe(429) + expect((await call(path, { ip: "ip-b" })).status).toBe(200) + }) + + it("tracks counters independently per endpoint", async () => { + const ip = freshIp() + expect( + (await exhaust("/api/chains/EVM/addresses/0xabc/history", 12, ip)).status, + ).toBe(429) + expect((await call("/api/chains/BTC/fee", { ip })).status).toBe(200) + }) + + it("resets counters after the window", async () => { + const ip = freshIp() + const path = "/api/chains/EVM/addresses/0xabc/history" + expect((await exhaust(path, 12, ip)).status).toBe(429) + + vi.mocked(Date.now).mockReturnValue(NOW + WINDOW_MS - 1) + const stillBlocked = await call(path, { ip }) + expect(stillBlocked.status).toBe(429) + expect(stillBlocked.headers.get("retry-after")).toBe("1") + + vi.mocked(Date.now).mockReturnValue(NOW + WINDOW_MS) + expect((await call(path, { ip })).status).toBe(200) + }) + + it("does not rate-limit broadcast endpoint", async () => { + const ip = freshIp() + for (let i = 0; i < 100; i += 1) { + expect( + passesThrough( + await call("/api/chains/BTC/transactions/broadcast", { + method: "POST", + ip, + }), + ), + ).toBe(true) + } + }) + + it("does not rate-limit unrelated routes", async () => { + const ip = freshIp() + for (let i = 0; i < 100; i += 1) { + expect(passesThrough(await call("/api/tokens", { ip }))).toBe(true) + } + }) +}) diff --git a/src/proxy.ts b/src/proxy.ts index efb2b93..928616f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -18,6 +18,28 @@ export const config = { matcher: "/api/:path*", } +type RateLimitPolicy = { + limit: number + windowMs: number +} + +type RateLimitEntry = { + resetAt: number + hits: number +} + +const rateLimitStore = new Map() +const RATE_LIMIT_CLEANUP_INTERVAL_MS = 120 * 60 * 1000 // 2 hours +let nextRateLimitCleanupAt = 0 + +const RATE_LIMITS = { + balance: { limit: 60, windowMs: 3600 * 1000 }, + history: { limit: 60, windowMs: 3600 * 1000 }, + utxos: { limit: 60, windowMs: 3600 * 1000 }, + txLookup: { limit: 60, windowMs: 3600 * 1000 }, + fee: { limit: 60, windowMs: 3600 * 1000 }, +} satisfies Record + function isCrossOrigin(request: NextRequest): boolean { const site = request.headers.get("sec-fetch-site") if (site) return site === "cross-site" @@ -27,9 +49,117 @@ function isCrossOrigin(request: NextRequest): boolean { return new URL(origin).host !== request.headers.get("host") } +function isTxLookupPath(pathname: string) { + return ( + /\/api\/chains\/[^/]+\/transactions\/[^/]+$/.test(pathname) && + !pathname.endsWith("/broadcast") + ) +} + +function getClientIp(request: NextRequest) { + const forwardedFor = request.headers.get("x-forwarded-for") + if (forwardedFor) { + return forwardedFor.split(",")[0]?.trim() ?? "unknown" + } + + return ( + request.headers.get("cf-connecting-ip") ?? + request.headers.get("x-real-ip") ?? + "unknown" + ) +} + +function cleanupExpiredRateLimitEntries(now: number) { + if (now < nextRateLimitCleanupAt) { + return + } + + rateLimitStore.clear() + + nextRateLimitCleanupAt = now + RATE_LIMIT_CLEANUP_INTERVAL_MS +} + +function applyRateLimit( + request: NextRequest, + key: string, + policy: RateLimitPolicy, +) { + const now = Date.now() + cleanupExpiredRateLimitEntries(now) + const clientIp = getClientIp(request) + const compositeKey = `${clientIp}:${key}` + const current = rateLimitStore.get(compositeKey) + + if (!current || current.resetAt <= now) { + rateLimitStore.set(compositeKey, { + hits: 1, + resetAt: now + policy.windowMs, + }) + return null + } + + if (current.hits >= policy.limit) { + const retryAfter = Math.max(1, Math.ceil((current.resetAt - now) / 1000)) + return NextResponse.json( + { message: "Too many requests" }, + { + status: 429, + headers: { + "Retry-After": String(retryAfter), + }, + }, + ) + } + + current.hits += 1 + rateLimitStore.set(compositeKey, current) + return null +} + +function getPolicyForPath(request: NextRequest) { + const pathname = request.nextUrl.pathname + + if (!pathname.startsWith("/api")) return null + + if (pathname.endsWith("/balance")) { + return { key: "balance", policy: RATE_LIMITS.balance } + } + + if (pathname.endsWith("/history")) { + return { key: "history", policy: RATE_LIMITS.history } + } + + if (pathname.endsWith("/utxos")) { + return { key: "utxos", policy: RATE_LIMITS.utxos } + } + + if (pathname.endsWith("/fee")) { + return { key: "fee", policy: RATE_LIMITS.fee } + } + + if (request.method === "GET" && isTxLookupPath(pathname)) { + return { key: "txLookup", policy: RATE_LIMITS.txLookup } + } + + return null +} + export function proxy(request: NextRequest) { if (isCrossOrigin(request)) { return NextResponse.json({ message: "Forbidden" }, { status: 403 }) } + + const routePolicy = getPolicyForPath(request) + if (routePolicy) { + const rateLimitResponse = applyRateLimit( + request, + routePolicy.key, + routePolicy.policy, + ) + if (rateLimitResponse) { + return rateLimitResponse + } + } + return NextResponse.next() }