Skip to content
Open
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
3 changes: 0 additions & 3 deletions apps/explorer/src/index.server.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import * as Sentry from '@sentry/cloudflare'
import handler, { createServerEntry } from '@tanstack/react-start/server-entry'
import { ExplorerExportRateLimit } from '#lib/server/export-rate-limit'

export { ExplorerExportRateLimit }

export const redirects: Array<{
from: RegExp
Expand Down
138 changes: 15 additions & 123 deletions apps/explorer/src/lib/server/export-rate-limit.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,13 @@
import { DurableObject, env } from 'cloudflare:workers'
import type { Address } from 'ox'
import { env } from 'cloudflare:workers'
import { getRequestHeader } from '@tanstack/react-start/server'

const RATE_LIMIT_WINDOW_MS = 10 * 60_000
const RATE_LIMITS = {
ip: 20,
wallet: 5,
} as const

type RateLimitBucket = {
count: number
resetAt: number
}
const RATE_LIMIT_PERIOD_SECONDS = 60

type RateLimitResult = {
allowed: boolean
remaining: number
retryAfterSeconds: number
}

type ExportRateLimitEnv = Cloudflare.Env & {
EXPLORER_EXPORT_RATE_LIMIT?: DurableObjectNamespace<ExplorerExportRateLimit>
}

export class RateLimitExceededError extends Error {
retryAfterSeconds: number

Expand All @@ -33,70 +18,6 @@ export class RateLimitExceededError extends Error {
}
}

export class ExplorerExportRateLimit extends DurableObject {
async fetch(request: Request): Promise<Response> {
const body = (await request.json()) as {
limit?: number
windowMs?: number
}

const limit =
typeof body.limit === 'number' && Number.isFinite(body.limit)
? Math.floor(body.limit)
: 0
const windowMs =
typeof body.windowMs === 'number' && Number.isFinite(body.windowMs)
? Math.floor(body.windowMs)
: 0

if (limit < 1 || windowMs < 1) {
return Response.json(
{ error: 'Invalid export rate limit request' },
{ status: 400 },
)
}

const now = Date.now()
const existing = await this.ctx.storage.get<RateLimitBucket>('bucket')
const bucket =
existing && existing.resetAt > now
? existing
: { count: 0, resetAt: now + windowMs }

if (bucket.count >= limit) {
return Response.json({
allowed: false,
remaining: 0,
retryAfterSeconds: Math.max(
1,
Math.ceil((bucket.resetAt - now) / 1000),
),
} satisfies RateLimitResult)
}

bucket.count += 1
await this.ctx.storage.put('bucket', bucket)
await this.ctx.storage.setAlarm(bucket.resetAt)

return Response.json({
allowed: true,
remaining: Math.max(0, limit - bucket.count),
retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
} satisfies RateLimitResult)
}

async alarm(): Promise<void> {
const bucket = await this.ctx.storage.get<RateLimitBucket>('bucket')
if (!bucket || bucket.resetAt > Date.now()) return

await this.ctx.storage.deleteAll()
}
}

function getRateLimitNamespace(): DurableObjectNamespace<ExplorerExportRateLimit> | null {
return (env as ExportRateLimitEnv).EXPLORER_EXPORT_RATE_LIMIT ?? null
}

function getClientIp(): string | null {
const cfConnectingIp = getRequestHeader('cf-connecting-ip')?.trim()
if (cfConnectingIp) return cfConnectingIp
Expand All @@ -114,65 +35,36 @@ function getClientIp(): string | null {
}

async function consumeRateLimit(params: {
limiter: RateLimit
key: string
limit: number
windowMs: number
}): Promise<RateLimitResult> {
const namespace = getRateLimitNamespace()
if (!namespace) {
if (!params.limiter)
return {
allowed: true,
remaining: params.limit,
retryAfterSeconds: 0,
}
}

const id = namespace.idFromName(params.key)
const stub = namespace.get(id)
const response = await stub.fetch('https://rate-limit/consume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ limit: params.limit, windowMs: params.windowMs }),
})
const { success } = await params.limiter.limit({ key: params.key })

if (!response.ok) {
throw new Error('Failed to check export rate limit')
return {
allowed: success,
retryAfterSeconds: success ? 0 : RATE_LIMIT_PERIOD_SECONDS,
}

return (await response.json()) as RateLimitResult
}

export async function enforceCsvExportRateLimit(
address: Address.Address,
): Promise<void> {
export async function enforceCsvExportRateLimit(): Promise<void> {
const ip = getClientIp()
if (!ip) return

const normalizedAddress = address.toLowerCase()
const [ipResult, walletResult] = await Promise.all([
consumeRateLimit({
key: `csv-export:ip:${ip}`,
limit: RATE_LIMITS.ip,
windowMs: RATE_LIMIT_WINDOW_MS,
}),
consumeRateLimit({
key: `csv-export:ip:${ip}:wallet:${normalizedAddress}`,
limit: RATE_LIMITS.wallet,
windowMs: RATE_LIMIT_WINDOW_MS,
}),
])
const result = await consumeRateLimit({
limiter: env.EXPLORER_EXPORT_RATE_LIMIT,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update binding types for EXPLORER_EXPORT_RATE_LIMIT

This call now passes env.EXPLORER_EXPORT_RATE_LIMIT into a RateLimit parameter, but the committed generated bindings still type that env var as a durable object namespace, so pnpm --filter explorer check:types fails with TS2741 (limit is missing). That blocks the required type-check/precommit path until worker types are regenerated (or the binding type is otherwise updated) in the same change.

Useful? React with 👍 / 👎.

key: `csv-export:ip:${ip}`,
})

if (!ipResult.allowed) {
if (!result.allowed) {
throw new RateLimitExceededError(
'Too many CSV exports from this IP. Please wait a few minutes before trying again.',
ipResult.retryAfterSeconds,
)
}

if (!walletResult.allowed) {
throw new RateLimitExceededError(
'Too many CSV exports for this wallet from your IP. Please wait a few minutes before trying again.',
walletResult.retryAfterSeconds,
result.retryAfterSeconds,
)
}
}
2 changes: 1 addition & 1 deletion apps/explorer/src/routes/api/address/balances/$address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const Route = createFileRoute('/api/address/balances/$address')({
const config = getWagmiConfig()
const chainId = getChainId(config)
if (isCsvExport) {
await enforceCsvExportRateLimit(address)
await enforceCsvExportRateLimit()
}
const response = await fetchAddressBalancesData({
address,
Expand Down
2 changes: 1 addition & 1 deletion apps/explorer/src/routes/api/address/history/$address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const Route = createFileRoute('/api/address/history/$address')({
const config = getWagmiConfig()
const chainId = getChainId(config)
if (isCsvExport) {
await enforceCsvExportRateLimit(address)
await enforceCsvExportRateLimit()
const transactions = await fetchAddressHistoryExportRows({
address,
chainId,
Expand Down
64 changes: 32 additions & 32 deletions apps/explorer/wrangler.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,21 @@
"preview_id": "0211859b856e40d095ab4e2a5329a22d"
}
],
"durable_objects": {
"bindings": [
{
"name": "EXPLORER_EXPORT_RATE_LIMIT",
"class_name": "ExplorerExportRateLimit"
}
]
},
"ratelimits": [
{
"name": "EXPLORER_EXPORT_RATE_LIMIT",
"namespace_id": "421701",
"simple": { "limit": 20, "period": 60 }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve prior CSV export rate window

Setting simple: { "limit": 20, "period": 60 } weakens the previous throttle from 20 exports per 10 minutes to 20 per minute (up to 200 in the same 10-minute span). For CSV export endpoints this is a substantial policy change that can increase expensive query load and abuse potential unless explicitly intended.

Useful? React with 👍 / 👎.

}
],
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["ExplorerExportRateLimit"]
},
{
"tag": "v2",
"deleted_classes": ["ExplorerExportRateLimit"]
}
],
"browser": { "binding": "BROWSER" },
Expand All @@ -41,14 +44,13 @@
"preview_id": "0211859b856e40d095ab4e2a5329a22d"
}
],
"durable_objects": {
"bindings": [
{
"name": "EXPLORER_EXPORT_RATE_LIMIT",
"class_name": "ExplorerExportRateLimit"
}
]
},
"ratelimits": [
{
"name": "EXPLORER_EXPORT_RATE_LIMIT",
"namespace_id": "421701",
"simple": { "limit": 20, "period": 60 }
}
],
"routes": [
{
"custom_domain": true,
Expand Down Expand Up @@ -97,14 +99,13 @@
"preview_id": "0211859b856e40d095ab4e2a5329a22d"
}
],
"durable_objects": {
"bindings": [
{
"name": "EXPLORER_EXPORT_RATE_LIMIT",
"class_name": "ExplorerExportRateLimit"
}
]
},
"ratelimits": [
{
"name": "EXPLORER_EXPORT_RATE_LIMIT",
"namespace_id": "421701",
"simple": { "limit": 20, "period": 60 }
}
],
"routes": [
{
"custom_domain": true,
Expand Down Expand Up @@ -148,14 +149,13 @@
"preview_id": "0211859b856e40d095ab4e2a5329a22d"
}
],
"durable_objects": {
"bindings": [
{
"name": "EXPLORER_EXPORT_RATE_LIMIT",
"class_name": "ExplorerExportRateLimit"
}
]
},
"ratelimits": [
{
"name": "EXPLORER_EXPORT_RATE_LIMIT",
"namespace_id": "421701",
"simple": { "limit": 20, "period": 60 }
}
],
"routes": [
{
"custom_domain": true,
Expand Down
Loading