Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 })
Expand Down
133 changes: 133 additions & 0 deletions src/proxy.rateLimit.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
}

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)

Check failure on line 80 in src/proxy.rateLimit.test.ts

View workflow job for this annotation

GitHub Actions / Build workspace

src/proxy.rateLimit.test.ts > proxy rate limiting > limits txLookup endpoint

AssertionError: expected 200 to be 429 // Object.is equality - Expected + Received - 429 + 200 ❯ src/proxy.rateLimit.test.ts:80:28

Check failure on line 80 in src/proxy.rateLimit.test.ts

View workflow job for this annotation

GitHub Actions / Build workspace

src/proxy.rateLimit.test.ts > proxy rate limiting > limits utxos endpoint

AssertionError: expected 200 to be 429 // Object.is equality - Expected + Received - 429 + 200 ❯ src/proxy.rateLimit.test.ts:80:28

Check failure on line 80 in src/proxy.rateLimit.test.ts

View workflow job for this annotation

GitHub Actions / Build workspace

src/proxy.rateLimit.test.ts > proxy rate limiting > limits history endpoint

AssertionError: expected 200 to be 429 // Object.is equality - Expected + Received - 429 + 200 ❯ src/proxy.rateLimit.test.ts:80:28

Check failure on line 80 in src/proxy.rateLimit.test.ts

View workflow job for this annotation

GitHub Actions / Build workspace

src/proxy.rateLimit.test.ts > proxy rate limiting > limits balance endpoint

AssertionError: expected 200 to be 429 // Object.is equality - Expected + Received - 429 + 200 ❯ src/proxy.rateLimit.test.ts:80:28
expect(await blocked.json()).toEqual({ message: "Too many requests" })
expect(blocked.headers.get("retry-after")).toBe("60")

Check failure on line 82 in src/proxy.rateLimit.test.ts

View workflow job for this annotation

GitHub Actions / Build workspace

src/proxy.rateLimit.test.ts > proxy rate limiting > limits fee endpoint

AssertionError: expected '3600' to be '60' // Object.is equality Expected: "60" Received: "3600" ❯ src/proxy.rateLimit.test.ts:82:48
})

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)

Check failure on line 87 in src/proxy.rateLimit.test.ts

View workflow job for this annotation

GitHub Actions / Build workspace

src/proxy.rateLimit.test.ts > proxy rate limiting > tracks counters independently per IP

AssertionError: expected 200 to be 429 // Object.is equality - Expected + Received - 429 + 200 ❯ src/proxy.rateLimit.test.ts:87:54
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)

Check failure on line 95 in src/proxy.rateLimit.test.ts

View workflow job for this annotation

GitHub Actions / Build workspace

src/proxy.rateLimit.test.ts > proxy rate limiting > tracks counters independently per endpoint

AssertionError: expected 200 to be 429 // Object.is equality - Expected + Received - 429 + 200 ❯ src/proxy.rateLimit.test.ts:95:7
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)

Check failure on line 102 in src/proxy.rateLimit.test.ts

View workflow job for this annotation

GitHub Actions / Build workspace

src/proxy.rateLimit.test.ts > proxy rate limiting > resets counters after the window

AssertionError: expected 200 to be 429 // Object.is equality - Expected + Received - 429 + 200 ❯ src/proxy.rateLimit.test.ts:102:50

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)
}
})
})
130 changes: 130 additions & 0 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RateLimitEntry>()
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<string, RateLimitPolicy>

function isCrossOrigin(request: NextRequest): boolean {
const site = request.headers.get("sec-fetch-site")
if (site) return site === "cross-site"
Expand All @@ -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()
}
Loading