From fee24a7e294f16537bd06c3f5edf1dafe4c3138c Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Tue, 20 Oct 2026 16:10:13 +0530 Subject: [PATCH 1/6] fix(api): harden client identity, request caps and limiter pruning Use the proxy-appended client IP everywhere instead of the first x-forwarded-for entry; cap JSON, RPC and multipart bodies before buffering; prune rate-limit maps instead of clearing every bucket; cap RPC responses; and redact viem URLs from the unauthenticated sync response. --- app/src/app/api/bridge/quote/route.ts | 3 +- app/src/app/api/bridge/status/route.ts | 3 +- .../app/api/launch/chart-provider/route.ts | 3 +- app/src/app/api/launch/edit/nonce/route.ts | 11 +- app/src/app/api/launch/edit/route.ts | 11 +- app/src/app/api/launch/image/route.ts | 18 ++- app/src/app/api/launch/meta/route.ts | 11 +- app/src/app/api/launch/sync/route.ts | 18 ++- app/src/app/api/launch/watchlist/route.ts | 5 +- app/src/app/api/posts/mod/route.ts | 11 +- app/src/app/api/posts/report/route.ts | 11 +- app/src/app/api/posts/route.ts | 11 +- app/src/app/api/presence/route.ts | 3 +- app/src/app/api/rpc/route.ts | 52 ++++--- app/src/app/api/write-limits.test.ts | 62 ++++++++- app/src/lib/http.test.ts | 80 +++++++++++ app/src/lib/http.ts | 130 ++++++++++++++++++ app/src/lib/launchpad/editServer.ts | 16 +-- app/src/lib/launchpad/watchlistData.test.ts | 2 + app/src/lib/profiles/http.ts | 44 +----- app/src/lib/rateLimit.test.ts | 64 +++++++++ app/src/lib/rateLimit.ts | 83 +++++++++++ 22 files changed, 507 insertions(+), 145 deletions(-) create mode 100644 app/src/lib/http.test.ts create mode 100644 app/src/lib/http.ts create mode 100644 app/src/lib/rateLimit.test.ts create mode 100644 app/src/lib/rateLimit.ts diff --git a/app/src/app/api/bridge/quote/route.ts b/app/src/app/api/bridge/quote/route.ts index a004575a..42e7be63 100644 --- a/app/src/app/api/bridge/quote/route.ts +++ b/app/src/app/api/bridge/quote/route.ts @@ -1,3 +1,4 @@ +import { clientIp } from "@/lib/http"; import { rateLimited } from "@/lib/launchpad/editServer"; import { BRIDGE_PRIVATE_HEADERS, bridgeErrorResponse, getBridgeQuote, readBridgeJson } from "@/lib/bridge/relay"; import { BridgeApiError, parseBridgeRequest } from "@/lib/bridge/validation"; @@ -6,7 +7,7 @@ export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export async function POST(req: Request) { - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`bridge:quote:ip:${ip}`, 20)) return bridgeErrorResponse(new BridgeApiError("Too many quotes. Please wait a moment.", 429)); try { if (req.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase() !== "application/json") throw new BridgeApiError("Send a JSON request.", 415); diff --git a/app/src/app/api/bridge/status/route.ts b/app/src/app/api/bridge/status/route.ts index 118caaae..5d7b0746 100644 --- a/app/src/app/api/bridge/status/route.ts +++ b/app/src/app/api/bridge/status/route.ts @@ -1,3 +1,4 @@ +import { clientIp } from "@/lib/http"; import { rateLimited } from "@/lib/launchpad/editServer"; import { BRIDGE_PRIVATE_HEADERS, bridgeErrorResponse, getBridgeStatus } from "@/lib/bridge/relay"; import { BridgeApiError } from "@/lib/bridge/validation"; @@ -6,7 +7,7 @@ export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export async function GET(req: Request) { - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`bridge:status:ip:${ip}`, 100)) return bridgeErrorResponse(new BridgeApiError("Too many status requests. Please wait a moment.", 429)); try { const params = new URL(req.url).searchParams; diff --git a/app/src/app/api/launch/chart-provider/route.ts b/app/src/app/api/launch/chart-provider/route.ts index 9265b69c..d4a096c5 100644 --- a/app/src/app/api/launch/chart-provider/route.ts +++ b/app/src/app/api/launch/chart-provider/route.ts @@ -1,6 +1,7 @@ import { isChartPool } from "@/lib/launchpad/chart-pool"; import { lookupGeckoPool } from "@/lib/launchpad/geckoterminal"; import { createGeckoCache } from "@/lib/launchpad/gecko-cache"; +import { clientIp } from "@/lib/http"; import { rateLimited } from "@/lib/launchpad/editServer"; export const dynamic = "force-dynamic"; @@ -8,7 +9,7 @@ const lookup = createGeckoCache(lookupGeckoPool, () => Date.now()); export async function GET(request: Request) { const headers = { "cache-control": "no-store" }; - const ip = (request.headers.get("fly-client-ip") || request.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(request); if (rateLimited(`chart:ip:${ip}`, 60)) return Response.json({ error: "Too many chart requests. Try again shortly." }, { status: 429, headers: { ...headers, "retry-after": "60" } }); const query = new URL(request.url).searchParams; const pool = { chain: query.get("chain"), token: query.get("token"), poolId: query.get("pool"), quote: query.get("quote") }; diff --git a/app/src/app/api/launch/edit/nonce/route.ts b/app/src/app/api/launch/edit/nonce/route.ts index 32d7a287..e96eb999 100644 --- a/app/src/app/api/launch/edit/nonce/route.ts +++ b/app/src/app/api/launch/edit/nonce/route.ts @@ -1,20 +1,17 @@ import { NextResponse } from "next/server"; import { isAddress } from "viem"; import { isChainKey } from "@/lib/chainPublic"; +import { clientIp, readJson } from "@/lib/http"; import { issueNonce, rateLimited } from "@/lib/launchpad/editServer"; export const dynamic = "force-dynamic"; /** POST {chain, token, wallet} → {nonce, expiresAt}. Only the token's creator gets one. */ export async function POST(req: Request) { - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`nonce:ip:${ip}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 }); - let b: { chain?: unknown; token?: unknown; wallet?: unknown }; - try { - b = (await req.json()) as typeof b; - } catch { - return NextResponse.json({ error: "bad json" }, { status: 400 }); - } + const b = await readJson(req, 2_048); + if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 }); if (!isChainKey(b.chain) || typeof b.token !== "string" || !isAddress(b.token) || typeof b.wallet !== "string" || !isAddress(b.wallet)) return NextResponse.json({ error: "bad params" }, { status: 400 }); const n = await issueNonce(b.chain, b.token, b.wallet); if (!n) return NextResponse.json({ error: "not the creator" }, { status: 403 }); diff --git a/app/src/app/api/launch/edit/route.ts b/app/src/app/api/launch/edit/route.ts index 830141b3..856ea280 100644 --- a/app/src/app/api/launch/edit/route.ts +++ b/app/src/app/api/launch/edit/route.ts @@ -1,19 +1,16 @@ import { NextResponse } from "next/server"; import { isChainKey } from "@/lib/chainPublic"; +import { clientIp, readJson } from "@/lib/http"; import { applySignedEdit, rateLimited } from "@/lib/launchpad/editServer"; export const dynamic = "force-dynamic"; /** POST {chain, token, wallet, nonce, expiresAt, signature, fields} → applies a creator-signed metadata edit. */ export async function POST(req: Request) { - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`edit:ip:${ip}`, 20)) return NextResponse.json({ error: "slow down" }, { status: 429 }); - let b: Record; - try { - b = (await req.json()) as Record; - } catch { - return NextResponse.json({ error: "bad json" }, { status: 400 }); - } + const b = await readJson(req, 16_384); + if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 }); if (!isChainKey(b.chain) || typeof b.token !== "string" || typeof b.wallet !== "string") return NextResponse.json({ error: "bad params" }, { status: 400 }); const r = await applySignedEdit({ chain: b.chain, token: b.token, wallet: b.wallet, nonce: b.nonce, expiresAt: b.expiresAt, signature: b.signature, fields: (b.fields ?? {}) as Record }); if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status }); diff --git a/app/src/app/api/launch/image/route.ts b/app/src/app/api/launch/image/route.ts index 456b3af1..1ee4b888 100644 --- a/app/src/app/api/launch/image/route.ts +++ b/app/src/app/api/launch/image/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { clientIp, readFormData } from "@/lib/http"; import { rateLimited } from "@/lib/launchpad/editServer"; import { BANNER_MAX_BYTES, IMAGE_UPLOADS_PER_HOUR, checkUpload, imageRole, isWalletParam, maxBytesFor, randomImageKey } from "@/lib/launchpad/images"; import { toBannerWebp, toLogoWebp } from "@/lib/launchpad/imageProcess"; @@ -18,18 +19,15 @@ const HOUR = 60 * 60_000; */ export async function POST(req: Request) { if (!imageUploadsEnabled()) return NextResponse.json({ error: "uploads not configured" }, { status: 503 }); - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`img:ip:${ip}`, IMAGE_UPLOADS_PER_HOUR * 2, HOUR)) return NextResponse.json({ error: "slow down" }, { status: 429 }); - const declared = Number(req.headers.get("content-length") || 0); - // the role is inside the body, so the early check allows the larger (banner) cap; the real cap is applied below - if (declared > BANNER_MAX_BYTES + 16 * 1024) return NextResponse.json({ error: "image must be ≤ 4 MB" }, { status: 413 }); - - let form: FormData; - try { - form = await req.formData(); - } catch { - return NextResponse.json({ error: "bad form" }, { status: 400 }); + // the role is inside the body, so the early cap allows the larger (banner) size; the role's real cap is applied below. + // readFormData caps chunked uploads too, before the multipart body is buffered. + const parsed = await readFormData(req, BANNER_MAX_BYTES + 16 * 1024); + if (!parsed.ok) { + return NextResponse.json({ error: parsed.tooLarge ? "image must be ≤ 4 MB" : "bad form" }, { status: parsed.tooLarge ? 413 : 400 }); } + const form = parsed.form; const wallet = form.get("wallet"); const file = form.get("file"); const role = imageRole(form.get("kind")); diff --git a/app/src/app/api/launch/meta/route.ts b/app/src/app/api/launch/meta/route.ts index 5c5fe2e5..f0c49e77 100644 --- a/app/src/app/api/launch/meta/route.ts +++ b/app/src/app/api/launch/meta/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { clientIp, readJson } from "@/lib/http"; import { rateLimited } from "@/lib/launchpad/editServer"; import { MetaConflict, saveMeta, validateMeta } from "@/lib/launchpad/meta"; import { LAUNCHPAD_CONFIGURED } from "@/lib/launchpad/config"; @@ -11,14 +12,10 @@ export async function POST(req: Request) { if (!LAUNCHPAD_CONFIGURED) return NextResponse.json({ error: "launchpad unconfigured" }, { status: 503 }); // Unsigned by design (salt secrecy decides conflicts), so the IP bucket is // the only thing stopping junk-row sprays and predictToken RPC burn. - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`meta:ip:${ip}`, 20)) return NextResponse.json({ error: "slow down" }, { status: 429 }); - let body: unknown; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "bad json" }, { status: 400 }); - } + const body = await readJson(req, 8_192); + if (!body) return NextResponse.json({ error: "bad json" }, { status: 400 }); const v = validateMeta((body ?? {}) as Partial[0]>); if (!v.ok) return NextResponse.json({ error: v.error }, { status: 400 }); try { diff --git a/app/src/app/api/launch/sync/route.ts b/app/src/app/api/launch/sync/route.ts index aa2b8385..d5d5c51d 100644 --- a/app/src/app/api/launch/sync/route.ts +++ b/app/src/app/api/launch/sync/route.ts @@ -1,9 +1,10 @@ import { NextResponse } from "next/server"; import { DEFAULT_CHAIN, isChainKey } from "@/lib/chainPublic"; +import { clientIp } from "@/lib/http"; import { rateLimited } from "@/lib/launchpad/editServer"; import { applyLaunchTx, pollAll } from "@/lib/launchpad/indexer"; import { forgetBoostLane } from "@/lib/launchpad/boostServer"; -import { publicError } from "@/lib/launchpad/redact"; +import { publicError, redactUrls } from "@/lib/launchpad/redact"; export const dynamic = "force-dynamic"; @@ -12,7 +13,7 @@ export async function POST(req: Request) { // Unauthenticated and the most expensive route in the app (a tx-less call // fans out over every configured chain: log ranges, heals and backfills), // so it gets the same per-IP bucket as the other write endpoints. - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`sync:ip:${ip}`, 10)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const u = new URL(req.url); const tx = u.searchParams.get("tx"); @@ -27,12 +28,23 @@ export async function POST(req: Request) { return NextResponse.json(r); } catch (err) { // never the raw message: a failed RPC read quotes the upstream URL, and a keyed provider URL carries its API key + console.warn("[launch-sync] receipt apply failed:", redactUrls(err instanceof Error ? err.message : String(err))); return NextResponse.json({ error: publicError(err, "sync failed") }, { status: 502 }); } } try { - return NextResponse.json(await pollAll()); + const all = await pollAll(); + // pollAll's per-chain reasons are viem messages too; redact before they reach an unauthenticated caller. + // Copy instead of mutating: a shared single-flight poll result can be read by the loop and other requests. + const safe = Object.fromEntries( + Object.entries(all).map(([chainKey, result]) => [ + chainKey, + result.reason ? { ...result, reason: redactUrls(result.reason) } : result, + ]), + ); + return NextResponse.json(safe); } catch (err) { + console.warn("[launch-sync] poll failed:", redactUrls(err instanceof Error ? err.message : String(err))); return NextResponse.json({ error: publicError(err, "sync failed") }, { status: 502 }); } } diff --git a/app/src/app/api/launch/watchlist/route.ts b/app/src/app/api/launch/watchlist/route.ts index 00e714a2..d169f053 100644 --- a/app/src/app/api/launch/watchlist/route.ts +++ b/app/src/app/api/launch/watchlist/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { dbConfigured } from "@/lib/db"; +import { clientIp } from "@/lib/http"; import { rateLimited } from "@/lib/launchpad/editServer"; import { ethUsd } from "@/lib/launchpad/ethPrice"; import { WatchlistInputError, getWatchlistData, parseWatchlistRequest, readWatchlistBody } from "@/lib/launchpad/watchlistData"; @@ -9,9 +10,7 @@ export const dynamic = "force-dynamic"; /** Read-only POST keeps the user's device-local token list out of URLs. Nothing is persisted. */ export async function POST(req: Request) { const headers = { "cache-control": "no-store" }; - // Fly's proxy sets fly-client-ip. The first x-forwarded-for entry is whatever the client sent, so the - // fallback is the last one, which the proxy appended: a spoofed header never buys a fresh rate-limit bucket. - const ip = req.headers.get("fly-client-ip")?.trim() || (req.headers.get("x-forwarded-for") ?? "").split(",").at(-1)?.trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`watchlist:ip:${ip}`, 60)) { return NextResponse.json({ error: "Too many refreshes. Try again shortly." }, { status: 429, headers: { ...headers, "retry-after": "60" } }); } diff --git a/app/src/app/api/posts/mod/route.ts b/app/src/app/api/posts/mod/route.ts index 6b3133dc..c0262a3a 100644 --- a/app/src/app/api/posts/mod/route.ts +++ b/app/src/app/api/posts/mod/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { clientIp, readJson } from "@/lib/http"; import { rateLimited } from "@/lib/launchpad/editServer"; import { moderate } from "@/lib/launchpad/postsServer"; import { adminFromRequest, fromAdminOrigin } from "@/lib/admin/session"; @@ -11,14 +12,10 @@ export const dynamic = "force-dynamic"; * session of the same wallet (moderate checks it), sent from the site's own pages. */ export async function POST(req: Request) { - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`mod:ip:${ip}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 }); - let b: Record; - try { - b = (await req.json()) as Record; - } catch { - return NextResponse.json({ error: "bad json" }, { status: 400 }); - } + const b = await readJson(req, 2_048); + if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 }); if (typeof b.wallet !== "string") return NextResponse.json({ error: "bad params" }, { status: 400 }); let session: string | null; try { diff --git a/app/src/app/api/posts/report/route.ts b/app/src/app/api/posts/report/route.ts index e37c72b2..fc8fd3e6 100644 --- a/app/src/app/api/posts/report/route.ts +++ b/app/src/app/api/posts/report/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { clientIp, readJson } from "@/lib/http"; import { rateLimited } from "@/lib/launchpad/editServer"; import { reportPost } from "@/lib/launchpad/postsServer"; @@ -6,14 +7,10 @@ export const dynamic = "force-dynamic"; /** POST {postId, wallet, reason, nonce, ts, signature} */ export async function POST(req: Request) { - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`report:ip:${ip}`, 20)) return NextResponse.json({ error: "slow down" }, { status: 429 }); - let b: Record; - try { - b = (await req.json()) as Record; - } catch { - return NextResponse.json({ error: "bad json" }, { status: 400 }); - } + const b = await readJson(req, 2_048); + if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 }); if (typeof b.wallet !== "string") return NextResponse.json({ error: "bad params" }, { status: 400 }); if (rateLimited(`report:wallet:${b.wallet.toLowerCase()}`, 20)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const r = await reportPost({ postId: b.postId, wallet: b.wallet, reason: b.reason, nonce: b.nonce, ts: b.ts, signature: b.signature }); diff --git a/app/src/app/api/posts/route.ts b/app/src/app/api/posts/route.ts index b8b35cec..9146682d 100644 --- a/app/src/app/api/posts/route.ts +++ b/app/src/app/api/posts/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { isAddress } from "viem"; import { isChainKey } from "@/lib/chainPublic"; +import { clientIp, readJson } from "@/lib/http"; import { rateLimited } from "@/lib/launchpad/editServer"; import { createPost, listFeed, listTokenPosts } from "@/lib/launchpad/postsServer"; import { memo } from "@/lib/launchpad/memo"; @@ -34,14 +35,10 @@ export async function GET(req: Request) { /** POST {chain, token, wallet, parentId?, body, nonce, ts, signature} → new post. */ export async function POST(req: Request) { - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); if (rateLimited(`post:ip:${ip}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 }); - let b: Record; - try { - b = (await req.json()) as Record; - } catch { - return NextResponse.json({ error: "bad json" }, { status: 400 }); - } + const b = await readJson(req, 4_096); + if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 }); if (!isChainKey(b.chain) || typeof b.token !== "string" || typeof b.wallet !== "string") return NextResponse.json({ error: "bad params" }, { status: 400 }); const r = await createPost({ chain: b.chain, token: b.token, wallet: b.wallet, parentId: b.parentId, body: b.body, nonce: b.nonce, ts: b.ts, signature: b.signature }); if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status }); diff --git a/app/src/app/api/presence/route.ts b/app/src/app/api/presence/route.ts index 3f2d29a4..e0cd0e53 100644 --- a/app/src/app/api/presence/route.ts +++ b/app/src/app/api/presence/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { clientIp } from "@/lib/http"; import { rateLimited } from "@/lib/launchpad/editServer"; import { looksLikeBot, prunePresence, readPulse, recordBeacon, visitorHash } from "@/lib/launchpad/presence"; import { memo } from "@/lib/launchpad/memo"; @@ -16,7 +17,7 @@ let lastPrune = 0; export async function POST(req: Request) { const ua = req.headers.get("user-agent") ?? ""; if (looksLikeBot(ua)) return NextResponse.json(await readPulse(), { headers: { "cache-control": "no-store" } }); - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + const ip = clientIp(req); // The visitor hash mixes in the UA, which the caller fully controls: without // a bucket, rotating UAs mints a fresh bb_presence row per request and // inflates visits. 30/min leaves normal beaconing (~every few seconds) alone. diff --git a/app/src/app/api/rpc/route.ts b/app/src/app/api/rpc/route.ts index 547bd818..71817e41 100644 --- a/app/src/app/api/rpc/route.ts +++ b/app/src/app/api/rpc/route.ts @@ -1,7 +1,9 @@ import { NextResponse } from "next/server"; import { b20RpcUrl, rpcUrl } from "@/lib/chain"; +import { clientIp, readJsonValue, readTextLimited } from "@/lib/http"; import { responseHasB20Error } from "@/lib/launchpad/baseStocks"; import { CHAINS, DEFAULT_CHAIN, isChainKey } from "@/lib/chainPublic"; +import { takeToken } from "@/lib/rateLimit"; import { upstreamStatus } from "@/lib/rpc-proxy"; export const runtime = "nodejs"; @@ -34,22 +36,10 @@ const ALLOWED = new Set([ ]); const BUCKET_CAP = 120; // requests const BUCKET_REFILL_PER_S = 4; -const buckets = new Map(); - -function take(ip: string): boolean { - const now = Date.now(); - const b = buckets.get(ip) ?? { tokens: BUCKET_CAP, at: now }; - b.tokens = Math.min(BUCKET_CAP, b.tokens + ((now - b.at) / 1000) * BUCKET_REFILL_PER_S); - b.at = now; - if (b.tokens < 1) { - buckets.set(ip, b); - return false; - } - b.tokens -= 1; - buckets.set(ip, b); - if (buckets.size > 10_000) buckets.clear(); - return true; -} +// a 20-call batch of ordinary calldata fits; a huge body is refused before it is buffered +const MAX_BODY_BYTES = 256 * 1024; +// eth_getLogs can answer with megabytes; the proxy must not buffer an unbounded one +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; type Req = { id?: unknown; jsonrpc?: string; method?: string; params?: unknown }; @@ -68,14 +58,12 @@ export async function POST(req: Request) { if (!isChainKey(c)) return NextResponse.json({ error: "bad chain" }, { status: 400 }); const upstream = rpcUrl(c) ?? CHAINS[c].rpcUrls.default.http[0]; if (!upstream) return NextResponse.json({ error: "rpc unconfigured" }, { status: 503 }); - const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; - if (!take(ip)) return NextResponse.json({ error: "rate limited" }, { status: 429, headers: { "retry-after": "2" } }); - let body: Req | Req[]; - try { - body = (await req.json()) as Req | Req[]; - } catch { - return NextResponse.json({ error: "bad json" }, { status: 400 }); - } + const ip = clientIp(req); + if (!takeToken(ip, BUCKET_CAP, BUCKET_REFILL_PER_S)) return NextResponse.json({ error: "rate limited" }, { status: 429, headers: { "retry-after": "2" } }); + // A JSON-RPC batch is an array, so this one uses the value reader instead of readJson's object-only contract. + const parsed = await readJsonValue(req, MAX_BODY_BYTES); + if (parsed === undefined) return NextResponse.json({ error: "bad json" }, { status: 400 }); + const body = parsed as Req | Req[]; const batch = Array.isArray(body); const list: Req[] = Array.isArray(body) ? body : [body]; if (list.length === 0) return NextResponse.json({ error: "empty batch" }, { status: 400 }); @@ -94,15 +82,25 @@ export async function POST(req: Request) { try { const payload = JSON.stringify(batch ? forward : forward[0]); const res = await fetch(upstream, { method: "POST", headers: { "content-type": "application/json" }, body: payload, signal: AbortSignal.timeout(15_000) }); - let text = await res.text(); + const read = await readTextLimited(res, MAX_RESPONSE_BYTES); + if (!read.ok) { + return NextResponse.json( + { error: read.tooLarge ? "The network RPC returned too much data. Try again." : "The network RPC is temporarily unavailable. Try again." }, + { status: 502, headers: { "cache-control": "no-store" } }, + ); + } + let text = read.text; let status = res.status; // Base-native B20 tokens (Coinbase stocks) are precompiles the primary provider may not execute // ("EVM error OpcodeNotFound"): replay the batch on a node that can, transparently to the wallet. if (c === "base" && status === 200 && text.includes("Opcode") && responseHasB20Error(safeJson(text))) { const alt = await fetch(b20RpcUrl(), { method: "POST", headers: { "content-type": "application/json" }, body: payload, signal: AbortSignal.timeout(15_000) }); if (alt.ok) { - text = await alt.text(); - status = alt.status; + const altRead = await readTextLimited(alt, MAX_RESPONSE_BYTES); + if (altRead.ok) { + text = altRead.text; + status = alt.status; + } } } // Rate limiting and malformed bodies become 429/502 so viem retries with backoff diff --git a/app/src/app/api/write-limits.test.ts b/app/src/app/api/write-limits.test.ts index 1fa58d2f..fb0c9324 100644 --- a/app/src/app/api/write-limits.test.ts +++ b/app/src/app/api/write-limits.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import test from "node:test"; /** @@ -14,10 +14,18 @@ function source(path: string): string { return readFileSync(new URL(path, import.meta.url), "utf8"); } +/** Every route.ts below this directory, recursively. */ +function routeFiles(dir: URL): URL[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const child = new URL(`${entry.name}${entry.isDirectory() ? "/" : ""}`, dir); + return entry.isDirectory() ? routeFiles(child) : entry.name === "route.ts" ? [child] : []; + }); +} + for ( const { file, bucket, mustPrecede } of [ { file: "./launch/sync/route.ts", bucket: "sync:ip", mustPrecede: "pollAll()" }, - { file: "./launch/meta/route.ts", bucket: "meta:ip", mustPrecede: "req.json()" }, + { file: "./launch/meta/route.ts", bucket: "meta:ip", mustPrecede: "readJson(req, 8_192)" }, { file: "./presence/route.ts", bucket: "presence:ip", mustPrecede: "recordBeacon(" }, { file: "./boost/seen/route.ts", bucket: "boost-seen:ip", mustPrecede: "readJson(" }, ] @@ -54,3 +62,53 @@ test("the Boosted lane beacon skips bots before the limiter, and caps its body", assert.ok(src.indexOf("rateLimited(`boost-seen:ip") < src.indexOf("recordBoostSeen(")); assert.match(src, /parseBoostSeen\(await readJson\(req, 512\)\)/); }); + +test("no route trusts the client-controlled first x-forwarded-for entry", () => { + for (const file of routeFiles(new URL(".", import.meta.url))) { + const src = readFileSync(file, "utf8"); + assert.doesNotMatch( + src, + /x-forwarded-for[\s\S]{0,160}?split\(","\)\[0\]/, + `${file.pathname} must use clientIp() instead of the first XFF entry`, + ); + } +}); + +test("the JSON write routes cap the body before parsing it", () => { + for (const { file, cap } of [ + { file: "./launch/edit/route.ts", cap: "16_384" }, + { file: "./launch/edit/nonce/route.ts", cap: "2_048" }, + { file: "./launch/meta/route.ts", cap: "8_192" }, + { file: "./posts/route.ts", cap: "4_096" }, + { file: "./posts/mod/route.ts", cap: "2_048" }, + { file: "./posts/report/route.ts", cap: "2_048" }, + ]) { + const src = source(file); + assert.doesNotMatch(src, /await req\.json\(\)/, `${file} must not buffer an unbounded JSON body`); + assert.ok(src.includes(`readJson(req, ${cap})`), `${file} must use readJson(req, ${cap})`); + } +}); + +test("the RPC proxy caps its request and response, and its token bucket is bounded without a clear-all", () => { + const src = source("./rpc/route.ts"); + assert.match(src, /readJsonValue\(req, MAX_BODY_BYTES\)/); + assert.match(src, /readTextLimited\(res, MAX_RESPONSE_BYTES\)/); + assert.match(src, /takeToken\(ip, BUCKET_CAP, BUCKET_REFILL_PER_S\)/); + assert.doesNotMatch(src, /buckets\.clear/); +}); + +test("the image upload caps chunked multipart bodies too", () => { + const src = source("./launch/image/route.ts"); + assert.match(src, /readFormData\(req, BANNER_MAX_BYTES \+ 16 \* 1024\)/); + assert.doesNotMatch(src, /req\.formData\(\)/); +}); + +test("sync never returns the raw viem message, which can carry the keyed RPC URL", () => { + const src = source("./launch/sync/route.ts"); + assert.match(src, /redactUrls\(/); + assert.match(src, /const safe = Object\.fromEntries\(/); + assert.match(src, /reason: redactUrls\(result\.reason\)/); + // the response copy must not mutate a shared single-flight poll result + assert.doesNotMatch(src, /r\.reason = redactUrls\(/); + assert.doesNotMatch(src, /error: err instanceof Error \? err\.message/); +}); diff --git a/app/src/lib/http.test.ts b/app/src/lib/http.test.ts new file mode 100644 index 00000000..8f4c8e2f --- /dev/null +++ b/app/src/lib/http.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { clientIp, readFormData, readJson, readJsonValue, readTextLimited } from "./http.ts"; + +function streamed(chunks: string[]): ReadableStream { + const enc = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(enc.encode(chunk)); + controller.close(); + }, + }); +} + +test("clientIp prefers the Fly header, then the proxy-appended last XFF hop", () => { + assert.equal(clientIp(new Request("http://x/")), "0.0.0.0"); + assert.equal(clientIp(new Request("http://x/", { headers: { "x-forwarded-for": "1.1.1.1, 2.2.2.2" } })), "2.2.2.2"); + assert.equal( + clientIp(new Request("http://x/", { headers: { "fly-client-ip": "3.3.3.3", "x-forwarded-for": "1.1.1.1, 2.2.2.2" } })), + "3.3.3.3", + "the edge header wins over both client-controlled hops", + ); +}); + +test("readJson caps declared and streamed bodies before parsing", async () => { + assert.deepEqual(await readJson(new Request("http://x/", { method: "POST", body: JSON.stringify({ a: 1 }) })), { a: 1 }); + assert.equal(await readJson(new Request("http://x/", { method: "POST", headers: { "content-length": "999999" }, body: "{}" })), null); + assert.equal(await readJson(new Request("http://x/", { method: "POST", body: streamed(["{", "bad"]), duplex: "half" } as RequestInit)), null); + assert.equal( + await readJson(new Request("http://x/", { method: "POST", body: streamed(["x".repeat(10)]), duplex: "half" } as RequestInit), 5), + null, + "a chunked body is cancelled as soon as it passes the cap", + ); + assert.equal(await readJson(new Request("http://x/", { method: "POST", body: "[1,2]" })), null, "arrays are not objects"); +}); + +test("readJsonValue accepts the batch array that readJson rejects", async () => { + assert.deepEqual(await readJsonValue(new Request("http://x/", { method: "POST", body: "[1,2]" })), [1, 2]); + assert.equal(await readJsonValue(new Request("http://x/", { method: "POST", body: "[1,2]" }), 3), undefined); + assert.equal(await readJsonValue(new Request("http://x/", { method: "POST", body: "{bad" })), undefined); +}); + +test("readFormData parses a normal multipart body and refuses an oversized chunked one", async () => { + const form = new FormData(); + form.set("wallet", "0xabc"); + form.set("file", new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }), "x.png"); + const req = new Request("http://x/", { method: "POST", body: form }); + const parsed = await readFormData(req, 4_096); + assert.equal(parsed.ok, true); + if (parsed.ok) { + assert.equal(parsed.form.get("wallet"), "0xabc"); + assert.equal((parsed.form.get("file") as Blob).size, 3); + } + + const oversized = new Request("http://x/", { + method: "POST", + headers: { "content-type": "multipart/form-data; boundary=x" }, + body: streamed(["x".repeat(20)]), + duplex: "half", + } as RequestInit); + assert.deepEqual(await readFormData(oversized, 10), { ok: false, tooLarge: true }); +}); + +test("readTextLimited stops an oversized upstream response", async () => { + assert.deepEqual(await readTextLimited(new Response("abc"), 3), { ok: true, text: "abc" }); + const declared = await readTextLimited(new Response("abcd"), 3); + assert.equal(declared.ok, false); + if (!declared.ok) assert.equal(declared.tooLarge, true); + const streamedOver = await readTextLimited(new Response(streamed(["a", "b", "c", "d"])), 3); + assert.equal(streamedOver.ok, false); + if (!streamedOver.ok) assert.equal(streamedOver.tooLarge, true); + const broken = new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error("boom")); + }, + }), + ); + assert.deepEqual(await readTextLimited(broken, 10), { ok: false, tooLarge: false }); +}); diff --git a/app/src/lib/http.ts b/app/src/lib/http.ts new file mode 100644 index 00000000..c661c6d4 --- /dev/null +++ b/app/src/lib/http.ts @@ -0,0 +1,130 @@ +/** + * Request helpers shared by API routes. No `server-only` import: the pure parts are unit-tested with node --test. + */ + +/** + * Client IP for rate-limit keys. Fly's proxy sets fly-client-ip. The first x-forwarded-for entry is whatever the + * client sent, so the fallback is the last one (the hop the nearest proxy appended): a spoofed left-hand entry never + * buys a fresh bucket. The cap keeps an absurd header from becoming an absurd map key. + */ +export function clientIp(req: Request): string { + const fly = req.headers.get("fly-client-ip")?.trim(); + if (fly) return fly.slice(0, 128); + const last = (req.headers.get("x-forwarded-for") ?? "").split(",").at(-1)?.trim(); + return (last || "0.0.0.0").slice(0, 128); +} + +type BytesResult = { ok: true; bytes: Uint8Array } | { ok: false; tooLarge: boolean }; + +/** + * Read a request body in bytes, refusing anything over `maxBytes`. The size cap holds before anything is buffered: a + * declared length over the cap is refused outright, and the stream is read in bytes and cancelled the moment it passes + * the cap (route handlers have no body limit of their own, and these routes are unauthenticated). + */ +async function readBytes(req: Request, maxBytes: number): Promise { + const declared = Number(req.headers.get("content-length") ?? ""); + if (Number.isFinite(declared) && declared > maxBytes) return { ok: false, tooLarge: true }; + if (!req.body) return { ok: false, tooLarge: false }; + const reader = req.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) { + await reader.cancel().catch(() => {}); + return { ok: false, tooLarge: true }; + } + chunks.push(value); + } + const bytes = new Uint8Array(size); + let at = 0; + for (const c of chunks) { + bytes.set(c, at); + at += c.byteLength; + } + return { ok: true, bytes }; + } catch { + return { ok: false, tooLarge: false }; + } +} + +/** A JSON value (object, array or primitive), or undefined when the body is over the cap or not JSON. */ +export async function readJsonValue(req: Request, maxBytes = 8_192): Promise { + const r = await readBytes(req, maxBytes); + if (!r.ok) return undefined; + try { + return JSON.parse(new TextDecoder().decode(r.bytes)) as unknown; + } catch { + return undefined; + } +} + +/** A small JSON object body, or null. Arrays are not objects: callers that accept a batch use readJsonValue. */ +export async function readJson(req: Request, maxBytes = 8_192): Promise | null> { + const v = await readJsonValue(req, maxBytes); + return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : null; +} + +/** A text body (for JSON-RPC batches, which may be an array), or null when it is over the cap or the stream fails. */ +export async function readText(req: Request, maxBytes = 8_192): Promise { + const r = await readBytes(req, maxBytes); + return r.ok ? new TextDecoder().decode(r.bytes) : null; +} + +/** A multipart form body, capped in bytes before parsing. */ +export async function readFormData(req: Request, maxBytes: number): Promise<{ ok: true; form: FormData } | { ok: false; tooLarge: boolean }> { + const r = await readBytes(req, maxBytes); + if (!r.ok) return { ok: false, tooLarge: r.tooLarge }; + const type = req.headers.get("content-type"); + if (!type) return { ok: false, tooLarge: false }; + try { + const body = new Uint8Array(r.bytes); // Uint8Array, which BodyInit accepts + const form = await new Response(body, { headers: { "content-type": type } }).formData(); + return { ok: true, form }; + } catch { + return { ok: false, tooLarge: false }; + } +} + +/** + * Read an upstream response body with a byte cap. Used by the JSON-RPC proxy: a huge eth_getLogs answer must become a + * 502, not an unbounded allocation. `tooLarge` distinguishes the cap from a timeout or a connection reset, which the + * route answers with its generic unavailable message. + */ +export type LimitedText = { ok: true; text: string } | { ok: false; tooLarge: boolean }; + +export async function readTextLimited(res: Response, maxBytes: number): Promise { + const declared = Number(res.headers.get("content-length") ?? ""); + if (Number.isFinite(declared) && declared > maxBytes) { + await res.body?.cancel().catch(() => {}); + return { ok: false, tooLarge: true }; + } + if (!res.body) return { ok: true, text: "" }; + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) { + await reader.cancel().catch(() => {}); + return { ok: false, tooLarge: true }; + } + chunks.push(value); + } + const bytes = new Uint8Array(size); + let at = 0; + for (const c of chunks) { + bytes.set(c, at); + at += c.byteLength; + } + return { ok: true, text: new TextDecoder().decode(bytes) }; + } catch { + return { ok: false, tooLarge: false }; + } +} diff --git a/app/src/lib/launchpad/editServer.ts b/app/src/lib/launchpad/editServer.ts index b998372e..030f2741 100644 --- a/app/src/lib/launchpad/editServer.ts +++ b/app/src/lib/launchpad/editServer.ts @@ -4,6 +4,7 @@ import { isAddress, type Address, type Hex } from "viem"; import { publicClient } from "@/lib/chain"; import { chainIdOf, type ChainKey } from "@/lib/chainPublic"; import { maybeDb } from "@/lib/db"; +import { rateLimited } from "@/lib/rateLimit"; import { EDIT_TTL_MS, buildEditMessage, isNonce, validateEdit, type EditFields } from "./editAuth"; /** @@ -12,19 +13,8 @@ import { EDIT_TTL_MS, buildEditMessage, isNonce, validateEdit, type EditFields } * wallets), the nonce is single-use with a 5-minute TTL, and the signer must * be the launcher recorded from the on-chain Launched event. */ -const buckets = new Map(); -/** Sliding limit: `max` hits per `windowMs` per key (wallet or ip). */ -export function rateLimited(key: string, max = 10, windowMs = 60_000): boolean { - const now = Date.now(); - const b = buckets.get(key); - if (!b || now - b.at > windowMs) { - buckets.set(key, { n: 1, at: now }); - if (buckets.size > 20_000) buckets.clear(); - return false; - } - b.n += 1; - return b.n > max; -} +// Kept as a re-export so the existing routes/profiles keep one import site. +export { rateLimited }; export async function issueNonce(chain: ChainKey, token: string, wallet: string): Promise<{ nonce: string; expiresAt: number } | null> { const db = maybeDb(); diff --git a/app/src/lib/launchpad/watchlistData.test.ts b/app/src/lib/launchpad/watchlistData.test.ts index 8ae4351f..afb1be42 100644 --- a/app/src/lib/launchpad/watchlistData.test.ts +++ b/app/src/lib/launchpad/watchlistData.test.ts @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; import ts from "typescript"; import type { WatchlistData, WatchlistRequestItem } from "./watchlistData"; import { CHAIN_IDS, isChainKey, type ChainKey } from "../chainKeys.ts"; +import { clientIp } from "../http.ts"; const source = readFileSync(new URL("./watchlistData.ts", import.meta.url), "utf8"); const route = readFileSync(new URL("../../app/api/launch/watchlist/route.ts", import.meta.url), "utf8"); @@ -184,6 +185,7 @@ function isolatedRoute(opts: { configured?: boolean; limited?: boolean; fail?: b class SnapshotDate extends Date { static now() { return NOW; } } const exported = compile(route, { "next/server": { NextResponse: { json: (body: unknown, init: ResponseInit) => Response.json(body, init) } }, + "@/lib/http": { clientIp }, "@/lib/db": { dbConfigured: () => opts.configured !== false }, "@/lib/launchpad/editServer": { rateLimited: (key: string) => { limitKeys.push(key); return Boolean(opts.limited); } }, "@/lib/launchpad/ethPrice": { ethUsd: async () => { reads++; return null; } }, diff --git a/app/src/lib/profiles/http.ts b/app/src/lib/profiles/http.ts index 82e8a800..bd2e2976 100644 --- a/app/src/lib/profiles/http.ts +++ b/app/src/lib/profiles/http.ts @@ -1,43 +1,5 @@ /** - * Client IP for rate-limit keys. Fly's proxy sets fly-client-ip; the first x-forwarded-for entry is whatever the - * client sent, so the fallback is the last one (the hop the proxy appended): a spoofed header never buys a fresh bucket. + * Compatibility shim: the request helpers now live in `@/lib/http` so launchpad/API routes do not import a profile + * module. Existing profile imports keep working. */ -export function clientIp(req: Request): string { - return req.headers.get("fly-client-ip")?.trim() || (req.headers.get("x-forwarded-for") ?? "").split(",").at(-1)?.trim() || "0.0.0.0"; -} - -/** - * A small JSON object body, or null. The size cap holds before anything is buffered: a declared length over the cap - * is refused outright, and the stream is read in bytes and cancelled the moment it passes the cap (route handlers have - * no body limit of their own, and these routes are unauthenticated). - */ -export async function readJson(req: Request, maxBytes = 8_192): Promise | null> { - const declared = Number(req.headers.get("content-length") ?? ""); - if (Number.isFinite(declared) && declared > maxBytes) return null; - if (!req.body) return null; - const reader = req.body.getReader(); - const chunks: Uint8Array[] = []; - let size = 0; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > maxBytes) { - await reader.cancel().catch(() => {}); - return null; - } - chunks.push(value); - } - const bytes = new Uint8Array(size); - let at = 0; - for (const c of chunks) { - bytes.set(c, at); - at += c.byteLength; - } - const v = JSON.parse(new TextDecoder().decode(bytes)) as unknown; - return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : null; - } catch { - return null; - } -} +export { clientIp, readFormData, readJson, readJsonValue, readText, readTextLimited } from "@/lib/http"; diff --git a/app/src/lib/rateLimit.test.ts b/app/src/lib/rateLimit.test.ts new file mode 100644 index 00000000..dac42202 --- /dev/null +++ b/app/src/lib/rateLimit.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MAX_RATE_BUCKETS, + MAX_TOKEN_BUCKETS, + pruneRateBuckets, + pruneTokenBuckets, + rateLimitSizesForTests, + rateLimited, + resetRateLimitsForTests, + takeToken, +} from "./rateLimit.ts"; + +test("fixed-window limiter isolates keys and blocks past the limit", () => { + resetRateLimitsForTests(); + assert.equal(rateLimited("a", 2), false); + assert.equal(rateLimited("a", 2), false); + assert.equal(rateLimited("a", 2), true); + assert.equal(rateLimited("b", 2), false, "another key is unaffected"); +}); + +test("fixed buckets are pruned by expiry, then oldest first, never cleared wholesale", () => { + const map = new Map([ + ["expired", { n: 1, at: 0, windowMs: 100 }], + ["oldest", { n: 1, at: 900, windowMs: 1_000 }], + ["middle", { n: 1, at: 910, windowMs: 1_000 }], + ["newest", { n: 1, at: 920, windowMs: 1_000 }], + ]); + pruneRateBuckets(map, 1_000, 2); + assert.deepEqual([...map.keys()], ["middle", "newest"]); + assert.equal(map.has("expired"), false); + assert.equal(map.has("oldest"), false); +}); + +test("the fixed map stays bounded under a burst of one-shot keys", () => { + resetRateLimitsForTests(); + for (let i = 0; i < MAX_RATE_BUCKETS + 50; i++) rateLimited(`burst-${i}`, 1); + assert.equal(rateLimitSizesForTests().fixed, MAX_RATE_BUCKETS); +}); + +test("token bucket refills at the configured rate", () => { + resetRateLimitsForTests(); + assert.equal(takeToken("ip", 2, 1, 1_000), true); + assert.equal(takeToken("ip", 2, 1, 1_000), true); + assert.equal(takeToken("ip", 2, 1, 1_000), false); + assert.equal(takeToken("ip", 2, 1, 2_000), true, "one second refills one token"); + assert.equal(takeToken("ip", 2, 1, 2_000), false); +}); + +test("token buckets are pruned after a full refill, then oldest first", () => { + const map = new Map([ + ["full", { tokens: 0, at: 0, cap: 2, refillPerSecond: 1 }], + ["oldest", { tokens: 0, at: 9_000, cap: 2, refillPerSecond: 1 }], + ["middle", { tokens: 0, at: 9_100, cap: 2, refillPerSecond: 1 }], + ]); + pruneTokenBuckets(map, 10_000, 1); + assert.deepEqual([...map.keys()], ["middle"], "full refills go first, then the oldest live bucket"); +}); + +test("the token map stays bounded under a burst of one-shot keys", () => { + resetRateLimitsForTests(); + for (let i = 0; i < MAX_TOKEN_BUCKETS + 50; i++) takeToken(`burst-${i}`, 1, 0.001, 1_000); + assert.equal(rateLimitSizesForTests().tokens, MAX_TOKEN_BUCKETS); +}); diff --git a/app/src/lib/rateLimit.ts b/app/src/lib/rateLimit.ts new file mode 100644 index 00000000..87d46bdc --- /dev/null +++ b/app/src/lib/rateLimit.ts @@ -0,0 +1,83 @@ +/** + * In-process rate-limit buckets. Two shapes: + * - fixed window (`rateLimited`): one hit count per key until its window ends; + * - token bucket (`takeToken`): a burst cap with a steady refill, for the JSON-RPC proxy. + * + * The maps are bounded. Expired entries go first; if everything is still live, the least recently used entries go. + * They are never cleared wholesale: a burst of new keys must not reset every active limit (the old clear-on-overflow + * did exactly that). + */ +export const MAX_RATE_BUCKETS = 20_000; +export const MAX_TOKEN_BUCKETS = 10_000; + +type Bucket = { n: number; at: number; windowMs: number }; +type TokenBucket = { tokens: number; at: number; cap: number; refillPerSecond: number }; + +const buckets = new Map(); +const tokenBuckets = new Map(); + +/** Expired first, then oldest (Map insertion order) until the map fits. Exported for tests. */ +export function pruneRateBuckets(map: Map, now: number, max: number): void { + for (const [key, b] of map) if (now - b.at > b.windowMs) map.delete(key); + while (map.size > max) { + const oldest = map.keys().next().value; + if (oldest === undefined) break; + map.delete(oldest); + } +} + +/** Fixed-window limit: `max` hits per `windowMs` per key. */ +export function rateLimited(key: string, max = 10, windowMs = 60_000): boolean { + const now = Date.now(); + const b = buckets.get(key); + if (!b || now - b.at > windowMs || b.windowMs !== windowMs) { + if (buckets.size >= MAX_RATE_BUCKETS) pruneRateBuckets(buckets, now, MAX_RATE_BUCKETS - 1); + buckets.set(key, { n: 1, at: now, windowMs }); + return false; + } + b.n += 1; + // Keep a hot key away from the eviction edge: a burst of one-shot keys must not reset it first. + buckets.delete(key); + buckets.set(key, b); + return b.n > max; +} + +/** Token bucket: `cap` burst, `refillPerSecond` steady state. `now` is injectable for tests. */ +export function takeToken(key: string, cap: number, refillPerSecond: number, now = Date.now()): boolean { + const b = tokenBuckets.get(key) ?? { tokens: cap, at: now, cap, refillPerSecond }; + b.cap = cap; + b.refillPerSecond = refillPerSecond; + b.tokens = Math.min(cap, b.tokens + ((now - b.at) / 1000) * refillPerSecond); + b.at = now; + // Keep a throttled key away from the eviction edge too: a flood of one-shot keys must not reset it. + tokenBuckets.delete(key); + tokenBuckets.set(key, b); + if (b.tokens < 1) return false; + b.tokens -= 1; + if (tokenBuckets.size > MAX_TOKEN_BUCKETS) pruneTokenBuckets(tokenBuckets, now, MAX_TOKEN_BUCKETS - 1); + return true; +} + +/** Fully refilled buckets carry no state and go first; then oldest. Exported for tests. */ +export function pruneTokenBuckets(map: Map, now: number, max: number): void { + for (const [key, b] of map) { + const fullAfter = b.refillPerSecond > 0 ? (b.cap / b.refillPerSecond) * 1000 : Infinity; + if (now - b.at >= fullAfter) map.delete(key); + } + while (map.size > max) { + const oldest = map.keys().next().value; + if (oldest === undefined) break; + map.delete(oldest); + } +} + +/** Test-only: drop every bucket so cases start isolated. */ +export function resetRateLimitsForTests(): void { + buckets.clear(); + tokenBuckets.clear(); +} + +/** Test-only: current bucket counts. */ +export function rateLimitSizesForTests(): { fixed: number; tokens: number } { + return { fixed: buckets.size, tokens: tokenBuckets.size }; +} From fbb57b560de798096bce1787ce9b11952acc6455 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Wed, 4 Nov 2026 13:38:54 +0530 Subject: [PATCH 2/6] fix(api): count trusted proxy hops and share one capped reader clientIp now counts x-forwarded-for in from the right by TRUSTED_PROXY_HOPS (default 1, so Fly behaviour is unchanged): the entry is the address our nearest proxy appended, not the one the client wrote, and a count deeper than the header falls back to 0.0.0.0 instead of to a client-controlled entry. A CDN in front of the app no longer keys every client on a proxy address, and the cap on the address length stays. readBytes and readTextLimited share one capped stream reader, so the size limit and the cancel-on-overflow behaviour cannot drift apart between request bodies and upstream responses. A fixed-window bucket that resets is deleted before it is reinserted: it moves to the back of the eviction order instead of sitting where the next prune picks it first, and only a genuinely new key runs the capacity prune, so resetting a bucket no longer evicts a live one. redactUrls matches a bracketed IPv6 host and the path behind it, so a keyed RPC URL cannot leave " ]/v2/" behind in the unauthenticated sync reason. --- app/.env.example | 5 +++ app/src/lib/http.test.ts | 18 ++++++++ app/src/lib/http.ts | 67 +++++++++++++--------------- app/src/lib/launchpad/redact.test.ts | 10 +++++ app/src/lib/launchpad/redact.ts | 5 ++- app/src/lib/rateLimit.test.ts | 18 ++++++++ app/src/lib/rateLimit.ts | 10 ++++- 7 files changed, 94 insertions(+), 39 deletions(-) diff --git a/app/.env.example b/app/.env.example index 2472366e..fa2dffd1 100644 --- a/app/.env.example +++ b/app/.env.example @@ -65,3 +65,8 @@ IMAGE_STORE=local # Wallets allowed to moderate posts (comma-separated, lowercase) ADMIN_WALLETS= + +# Rate-limit keys: how many of our own proxies append to x-forwarded-for when Fly is not the edge. Default 1 (just the +# Fly proxy); set 2 behind a CDN that adds its own hop, or a proxy address becomes everyone's bucket. Blank, 0 or junk +# means 1. fly-client-ip always wins when Fly sets it. +# TRUSTED_PROXY_HOPS=1 diff --git a/app/src/lib/http.test.ts b/app/src/lib/http.test.ts index 8f4c8e2f..95555a36 100644 --- a/app/src/lib/http.test.ts +++ b/app/src/lib/http.test.ts @@ -20,6 +20,24 @@ test("clientIp prefers the Fly header, then the proxy-appended last XFF hop", () "3.3.3.3", "the edge header wins over both client-controlled hops", ); + assert.equal(clientIp(new Request("http://x/", { headers: { "x-forwarded-for": "9".repeat(200) } })).length, 128, "an absurd header never becomes an absurd map key"); +}); + +test("clientIp counts trusted hops in from the right, and never falls back to a client-controlled entry", () => { + const before = process.env.TRUSTED_PROXY_HOPS; + const ip = (xff: string) => clientIp(new Request("http://x/", { headers: { "x-forwarded-for": xff } })); + try { + process.env.TRUSTED_PROXY_HOPS = "2"; // a CDN appending its own hop in front of Fly's proxy + assert.equal(ip("1.1.1.1, 2.2.2.2"), "1.1.1.1", "the address the outermost trusted proxy saw, not the proxy's own"); + assert.equal(ip("1.1.1.1"), "0.0.0.0", "fewer entries than trusted hops: one shared bucket, never a client entry"); + process.env.TRUSTED_PROXY_HOPS = "0"; + assert.equal(ip("1.1.1.1, 2.2.2.2"), "2.2.2.2", "zero means the single proxy every deployment has"); + process.env.TRUSTED_PROXY_HOPS = "junk"; + assert.equal(ip("1.1.1.1, 2.2.2.2"), "2.2.2.2", "a junk value falls back to that same single hop"); + } finally { + if (before === undefined) delete process.env.TRUSTED_PROXY_HOPS; + else process.env.TRUSTED_PROXY_HOPS = before; + } }); test("readJson caps declared and streamed bodies before parsing", async () => { diff --git a/app/src/lib/http.ts b/app/src/lib/http.ts index c661c6d4..3b9862f8 100644 --- a/app/src/lib/http.ts +++ b/app/src/lib/http.ts @@ -2,30 +2,39 @@ * Request helpers shared by API routes. No `server-only` import: the pure parts are unit-tested with node --test. */ +/** How many proxies of our own append to x-forwarded-for when Fly is not the edge (TRUSTED_PROXY_HOPS). */ +function trustedProxyHops(): number { + const n = Math.trunc(Number(process.env.TRUSTED_PROXY_HOPS ?? "")); + // blank, zero, negative or junk means the single proxy every deployment has + return n > 1 ? n : 1; +} + /** * Client IP for rate-limit keys. Fly's proxy sets fly-client-ip. The first x-forwarded-for entry is whatever the - * client sent, so the fallback is the last one (the hop the nearest proxy appended): a spoofed left-hand entry never - * buys a fresh bucket. The cap keeps an absurd header from becoming an absurd map key. + * client sent, so the fallback counts in from the right: the hop our nearest proxy appended. A spoofed left-hand + * entry never buys a fresh bucket, and TRUSTED_PROXY_HOPS keeps the key off a proxy address (one shared bucket for + * everyone) when a CDN appends an extra hop. A count deeper than the header falls back to 0.0.0.0 rather than to a + * client-controlled entry. The cap keeps an absurd header from becoming an absurd map key. */ export function clientIp(req: Request): string { const fly = req.headers.get("fly-client-ip")?.trim(); if (fly) return fly.slice(0, 128); - const last = (req.headers.get("x-forwarded-for") ?? "").split(",").at(-1)?.trim(); - return (last || "0.0.0.0").slice(0, 128); + const hops = trustedProxyHops(); + const entries = (req.headers.get("x-forwarded-for") ?? "").split(",").map((s) => s.trim()); + const picked = entries.length >= hops ? entries[entries.length - hops] : ""; + return (picked || "0.0.0.0").slice(0, 128); } type BytesResult = { ok: true; bytes: Uint8Array } | { ok: false; tooLarge: boolean }; /** - * Read a request body in bytes, refusing anything over `maxBytes`. The size cap holds before anything is buffered: a - * declared length over the cap is refused outright, and the stream is read in bytes and cancelled the moment it passes - * the cap (route handlers have no body limit of their own, and these routes are unauthenticated). + * Read a stream in bytes, refusing anything over `maxBytes`. The size cap holds before anything is buffered: the + * stream is read chunk by chunk and cancelled the moment it passes the cap (route handlers have no body limit of + * their own, and these routes are unauthenticated). `tooLarge` separates the cap from a read failure, so each caller + * answers with its own message. Shared by request bodies and upstream responses so one fix covers both. */ -async function readBytes(req: Request, maxBytes: number): Promise { - const declared = Number(req.headers.get("content-length") ?? ""); - if (Number.isFinite(declared) && declared > maxBytes) return { ok: false, tooLarge: true }; - if (!req.body) return { ok: false, tooLarge: false }; - const reader = req.body.getReader(); +async function readStreamLimited(body: ReadableStream, maxBytes: number): Promise { + const reader = body.getReader(); const chunks: Uint8Array[] = []; let size = 0; try { @@ -51,6 +60,14 @@ async function readBytes(req: Request, maxBytes: number): Promise { } } +/** A request body in bytes. A declared length over the cap is refused before the stream is touched. */ +async function readBytes(req: Request, maxBytes: number): Promise { + const declared = Number(req.headers.get("content-length") ?? ""); + if (Number.isFinite(declared) && declared > maxBytes) return { ok: false, tooLarge: true }; + if (!req.body) return { ok: false, tooLarge: false }; + return readStreamLimited(req.body, maxBytes); +} + /** A JSON value (object, array or primitive), or undefined when the body is over the cap or not JSON. */ export async function readJsonValue(req: Request, maxBytes = 8_192): Promise { const r = await readBytes(req, maxBytes); @@ -103,28 +120,6 @@ export async function readTextLimited(res: Response, maxBytes: number): Promise< return { ok: false, tooLarge: true }; } if (!res.body) return { ok: true, text: "" }; - const reader = res.body.getReader(); - const chunks: Uint8Array[] = []; - let size = 0; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > maxBytes) { - await reader.cancel().catch(() => {}); - return { ok: false, tooLarge: true }; - } - chunks.push(value); - } - const bytes = new Uint8Array(size); - let at = 0; - for (const c of chunks) { - bytes.set(c, at); - at += c.byteLength; - } - return { ok: true, text: new TextDecoder().decode(bytes) }; - } catch { - return { ok: false, tooLarge: false }; - } + const r = await readStreamLimited(res.body, maxBytes); + return r.ok ? { ok: true, text: new TextDecoder().decode(r.bytes) } : r; } diff --git a/app/src/lib/launchpad/redact.test.ts b/app/src/lib/launchpad/redact.test.ts index de35c0c1..9c4fe079 100644 --- a/app/src/lib/launchpad/redact.test.ts +++ b/app/src/lib/launchpad/redact.test.ts @@ -21,3 +21,13 @@ test("publicError keeps the first line alone, without URLs, bounded, with a fall assert.equal(publicError(new Error("")), "failed"); assert.equal(publicError({ secret: "abc123SECRET" }, "sync failed"), "sync failed"); }); + +test("redactUrls takes the path after an IPv6 literal host with it", () => { + assert.equal( + redactUrls("URL: https://[2001:db8::1]/v2/abc123SECRET\nDetails: fetch failed"), + "URL: \nDetails: fetch failed", + "the closing bracket must not leave the keyed path behind", + ); + assert.equal(redactUrls("ws://[::1]:8545/rpc?key=k"), ""); + assert.equal(redactUrls("see https://a.b/c (docs)"), "see (docs)", "a trailing paren still ends the URL"); +}); diff --git a/app/src/lib/launchpad/redact.ts b/app/src/lib/launchpad/redact.ts index 0e1e2156..5c3fe33f 100644 --- a/app/src/lib/launchpad/redact.ts +++ b/app/src/lib/launchpad/redact.ts @@ -2,10 +2,11 @@ /** * viem's HTTP errors quote the upstream URL ("URL: https://…/v2/"), and a keyed RPC provider carries its API key in - * the path. Every URL is replaced, since the key can also appear in "Request body" or a wrapped cause. + * the path. Every URL is replaced, since the key can also appear in "Request body" or a wrapped cause. A bracketed IPv6 + * host swallows its closing bracket and the path after it, so that tail cannot ride along behind "". */ export function redactUrls(message: string): string { - return message.replace(/\b(?:https?|wss?):\/\/[^\s"'`<>)\]]+/gi, ""); + return message.replace(/\b(?:https?|wss?):\/\/(?:\[[^\]\s]+\][^\s"'`<>)]*|[^\s"'`<>)\]]+)/gi, ""); } /** diff --git a/app/src/lib/rateLimit.test.ts b/app/src/lib/rateLimit.test.ts index dac42202..b4043fa3 100644 --- a/app/src/lib/rateLimit.test.ts +++ b/app/src/lib/rateLimit.test.ts @@ -5,6 +5,7 @@ import { MAX_TOKEN_BUCKETS, pruneRateBuckets, pruneTokenBuckets, + rateLimitedKeysForTests, rateLimitSizesForTests, rateLimited, resetRateLimitsForTests, @@ -38,6 +39,23 @@ test("the fixed map stays bounded under a burst of one-shot keys", () => { assert.equal(rateLimitSizesForTests().fixed, MAX_RATE_BUCKETS); }); +test("a reset bucket keeps its entry and moves to the back of the eviction order", () => { + resetRateLimitsForTests(); + for (let i = 0; i < MAX_RATE_BUCKETS; i++) rateLimited(`full-${i}`, 1); + // a different window resets the bucket instead of counting it, and the reinsert does not grow the map + rateLimited("full-1", 1, 30_000); + const keys = rateLimitedKeysForTests(); + assert.equal(keys.length, MAX_RATE_BUCKETS, "no live bucket may be dropped to make room that was never needed"); + assert.equal(keys[0], "full-0", "the untouched oldest bucket holds its place"); + assert.equal(keys.at(-1), "full-1", "the reset bucket is the newest, not the oldest"); + // so a burst of one-shot keys evicts the bucket that really is stale, not the one that just reset + rateLimited("extra", 1); + const after = rateLimitedKeysForTests(); + assert.equal(after.length, MAX_RATE_BUCKETS, "the map stays at its cap"); + assert.equal(after.includes("full-1"), true, "the just-reset bucket outlives the burst"); + assert.equal(after.includes("full-0"), false, "the oldest live bucket is the one that goes"); +}); + test("token bucket refills at the configured rate", () => { resetRateLimitsForTests(); assert.equal(takeToken("ip", 2, 1, 1_000), true); diff --git a/app/src/lib/rateLimit.ts b/app/src/lib/rateLimit.ts index 87d46bdc..8fa00d84 100644 --- a/app/src/lib/rateLimit.ts +++ b/app/src/lib/rateLimit.ts @@ -31,7 +31,10 @@ export function rateLimited(key: string, max = 10, windowMs = 60_000): boolean { const now = Date.now(); const b = buckets.get(key); if (!b || now - b.at > windowMs || b.windowMs !== windowMs) { - if (buckets.size >= MAX_RATE_BUCKETS) pruneRateBuckets(buckets, now, MAX_RATE_BUCKETS - 1); + // A reset bucket must not keep its old insertion position, or the next prune evicts it as the oldest live entry. + // Only a brand-new key grows the map, so only it can need room made. + if (b) buckets.delete(key); + else if (buckets.size >= MAX_RATE_BUCKETS) pruneRateBuckets(buckets, now, MAX_RATE_BUCKETS - 1); buckets.set(key, { n: 1, at: now, windowMs }); return false; } @@ -81,3 +84,8 @@ export function resetRateLimitsForTests(): void { export function rateLimitSizesForTests(): { fixed: number; tokens: number } { return { fixed: buckets.size, tokens: tokenBuckets.size }; } + +/** Test-only: fixed-window keys in eviction order (oldest first). */ +export function rateLimitedKeysForTests(): string[] { + return [...buckets.keys()]; +} From 0aac5a24993f94e2ddf4c0d4602b85b2ba8283ea Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Wed, 4 Nov 2026 13:50:22 +0530 Subject: [PATCH 3/6] fix(api): let the trusted hop count apply behind a CDN fly-client-ip is the address that reached Fly, which is the client only while Fly's proxy is the outermost one. The early return made TRUSTED_PROXY_HOPS a no-op in the one topology it exists for: with a CDN in front, fly-client-ip is the CDN, so every client was keyed on it and shared one rate-limit bucket. With a hop count above 1 the client now comes from x-forwarded-for, counted in from the right, and only while that chain ends at the address Fly recorded: a chain Fly did not write is trusted no further than that address. A deep count or an empty chain still falls back to the nearest address, or to 0.0.0.0 (one shared bucket), never to a client-written entry. The default of 1 keeps today's behaviour, where fly-client-ip is the answer. --- app/.env.example | 6 +++--- app/src/lib/http.test.ts | 6 +++++- app/src/lib/http.ts | 23 ++++++++++++++--------- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/app/.env.example b/app/.env.example index fa2dffd1..28a5c375 100644 --- a/app/.env.example +++ b/app/.env.example @@ -66,7 +66,7 @@ IMAGE_STORE=local # Wallets allowed to moderate posts (comma-separated, lowercase) ADMIN_WALLETS= -# Rate-limit keys: how many of our own proxies append to x-forwarded-for when Fly is not the edge. Default 1 (just the -# Fly proxy); set 2 behind a CDN that adds its own hop, or a proxy address becomes everyone's bucket. Blank, 0 or junk -# means 1. fly-client-ip always wins when Fly sets it. +# Rate-limit keys: how many proxies of our own a request passes. Default 1 (Fly's proxy is the edge, so fly-client-ip is +# the client). Set 2 when a CDN fronts the app: fly-client-ip is then that CDN, and the client is the hop the chain adds +# before it, which only holds while the CDN is the only way in. Blank, 0 or junk means 1. # TRUSTED_PROXY_HOPS=1 diff --git a/app/src/lib/http.test.ts b/app/src/lib/http.test.ts index 95555a36..b9b93849 100644 --- a/app/src/lib/http.test.ts +++ b/app/src/lib/http.test.ts @@ -25,13 +25,17 @@ test("clientIp prefers the Fly header, then the proxy-appended last XFF hop", () test("clientIp counts trusted hops in from the right, and never falls back to a client-controlled entry", () => { const before = process.env.TRUSTED_PROXY_HOPS; - const ip = (xff: string) => clientIp(new Request("http://x/", { headers: { "x-forwarded-for": xff } })); + const ip = (xff: string, fly?: string) => + clientIp(new Request("http://x/", { headers: { "x-forwarded-for": xff, ...(fly ? { "fly-client-ip": fly } : {}) } })); try { process.env.TRUSTED_PROXY_HOPS = "2"; // a CDN appending its own hop in front of Fly's proxy assert.equal(ip("1.1.1.1, 2.2.2.2"), "1.1.1.1", "the address the outermost trusted proxy saw, not the proxy's own"); + assert.equal(ip("1.1.1.1, 2.2.2.2", "2.2.2.2"), "1.1.1.1", "with a CDN in front, fly-client-ip is that CDN, so the client comes from the chain"); + assert.equal(ip("1.1.1.1, 9.9.9.9", "2.2.2.2"), "2.2.2.2", "a chain that does not end at the address Fly saw is trusted no further than that address"); assert.equal(ip("1.1.1.1"), "0.0.0.0", "fewer entries than trusted hops: one shared bucket, never a client entry"); process.env.TRUSTED_PROXY_HOPS = "0"; assert.equal(ip("1.1.1.1, 2.2.2.2"), "2.2.2.2", "zero means the single proxy every deployment has"); + assert.equal(ip("1.1.1.1, 2.2.2.2", "2.2.2.2"), "2.2.2.2", "and Fly's own address is then the answer"); process.env.TRUSTED_PROXY_HOPS = "junk"; assert.equal(ip("1.1.1.1, 2.2.2.2"), "2.2.2.2", "a junk value falls back to that same single hop"); } finally { diff --git a/app/src/lib/http.ts b/app/src/lib/http.ts index 3b9862f8..c342d122 100644 --- a/app/src/lib/http.ts +++ b/app/src/lib/http.ts @@ -2,7 +2,7 @@ * Request helpers shared by API routes. No `server-only` import: the pure parts are unit-tested with node --test. */ -/** How many proxies of our own append to x-forwarded-for when Fly is not the edge (TRUSTED_PROXY_HOPS). */ +/** How many proxies of our own a request passes before it reaches the app (TRUSTED_PROXY_HOPS). */ function trustedProxyHops(): number { const n = Math.trunc(Number(process.env.TRUSTED_PROXY_HOPS ?? "")); // blank, zero, negative or junk means the single proxy every deployment has @@ -10,19 +10,24 @@ function trustedProxyHops(): number { } /** - * Client IP for rate-limit keys. Fly's proxy sets fly-client-ip. The first x-forwarded-for entry is whatever the - * client sent, so the fallback counts in from the right: the hop our nearest proxy appended. A spoofed left-hand - * entry never buys a fresh bucket, and TRUSTED_PROXY_HOPS keeps the key off a proxy address (one shared bucket for - * everyone) when a CDN appends an extra hop. A count deeper than the header falls back to 0.0.0.0 rather than to a - * client-controlled entry. The cap keeps an absurd header from becoming an absurd map key. + * Client IP for rate-limit keys. Two sources: + * - fly-client-ip: the address that reached Fly. That is the client while Fly's proxy is the outermost one, and the + * CDN in front of it once TRUSTED_PROXY_HOPS counts a hop above Fly; + * - x-forwarded-for: counted in from the right, since the first entry is whatever the client wrote. + * Only the last entry of that chain is checkable against the address Fly recorded, so a chain that does not end there + * was not written by our proxies and nothing past the nearest address is trusted. A hop count deeper than the header + * falls back to that nearest address, or to 0.0.0.0 (one shared bucket), never to a client-controlled entry. Trusting + * hops above Fly still assumes the CDN is the only way in: a client that can reach the app around it seeds the chain + * itself, which is a firewall's job, not this header's. The cap keeps an absurd header from becoming an absurd map key. */ export function clientIp(req: Request): string { const fly = req.headers.get("fly-client-ip")?.trim(); - if (fly) return fly.slice(0, 128); const hops = trustedProxyHops(); + if (fly && hops === 1) return fly.slice(0, 128); const entries = (req.headers.get("x-forwarded-for") ?? "").split(",").map((s) => s.trim()); - const picked = entries.length >= hops ? entries[entries.length - hops] : ""; - return (picked || "0.0.0.0").slice(0, 128); + const chained = !fly || entries.at(-1) === fly; + const picked = chained && entries.length >= hops ? entries[entries.length - hops] : ""; + return (picked || fly || "0.0.0.0").slice(0, 128); } type BytesResult = { ok: true; bytes: Uint8Array } | { ok: false; tooLarge: boolean }; From d2880d66eb15df92e76b176f7d8c176866bf29c1 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Wed, 4 Nov 2026 14:26:47 +0530 Subject: [PATCH 4/6] fix(api): read the client past the entry Fly appends itself Fly's proxy appends an x-forwarded-for entry of its own: the address assigned to the app, always rightmost (fly.io/docs/networking/request-headers). With a proxy service in front of Fly, fly-client-ip is that proxy, so the equality check added last round compared the app's own address against the proxy's and could never hold: the lookup fell back to the proxy, and every client behind it shared one rate-limit bucket. The chain is now counted in from the right past Fly's own entry, which is what a hop count means, so the client-written head of the header is never reached and a CDN hop no longer collapses the buckets. The comment records the platform behaviour and keeps MDN's caveat: counting hops above Fly assumes that proxy is the only way in. --- app/.env.example | 5 +++-- app/src/lib/http.test.ts | 12 ++++++------ app/src/lib/http.ts | 23 ++++++++++++----------- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/app/.env.example b/app/.env.example index 28a5c375..d01656a2 100644 --- a/app/.env.example +++ b/app/.env.example @@ -67,6 +67,7 @@ IMAGE_STORE=local ADMIN_WALLETS= # Rate-limit keys: how many proxies of our own a request passes. Default 1 (Fly's proxy is the edge, so fly-client-ip is -# the client). Set 2 when a CDN fronts the app: fly-client-ip is then that CDN, and the client is the hop the chain adds -# before it, which only holds while the CDN is the only way in. Blank, 0 or junk means 1. +# the client). Set 2 when a proxy service fronts the app: fly-client-ip is then that proxy, and the client is the entry it +# appended, one in from the address Fly appends for the app itself. Only holds while that proxy is the only way in. +# Blank, 0 or junk means 1. # TRUSTED_PROXY_HOPS=1 diff --git a/app/src/lib/http.test.ts b/app/src/lib/http.test.ts index b9b93849..9cd2b8d2 100644 --- a/app/src/lib/http.test.ts +++ b/app/src/lib/http.test.ts @@ -23,19 +23,19 @@ test("clientIp prefers the Fly header, then the proxy-appended last XFF hop", () assert.equal(clientIp(new Request("http://x/", { headers: { "x-forwarded-for": "9".repeat(200) } })).length, 128, "an absurd header never becomes an absurd map key"); }); -test("clientIp counts trusted hops in from the right, and never falls back to a client-controlled entry", () => { +test("clientIp counts trusted hops in from the right, past the entry Fly appends itself", () => { const before = process.env.TRUSTED_PROXY_HOPS; const ip = (xff: string, fly?: string) => clientIp(new Request("http://x/", { headers: { "x-forwarded-for": xff, ...(fly ? { "fly-client-ip": fly } : {}) } })); try { - process.env.TRUSTED_PROXY_HOPS = "2"; // a CDN appending its own hop in front of Fly's proxy - assert.equal(ip("1.1.1.1, 2.2.2.2"), "1.1.1.1", "the address the outermost trusted proxy saw, not the proxy's own"); - assert.equal(ip("1.1.1.1, 2.2.2.2", "2.2.2.2"), "1.1.1.1", "with a CDN in front, fly-client-ip is that CDN, so the client comes from the chain"); - assert.equal(ip("1.1.1.1, 9.9.9.9", "2.2.2.2"), "2.2.2.2", "a chain that does not end at the address Fly saw is trusted no further than that address"); + // client 5.5.5.5 → CDN 2.2.2.2 → Fly, which appends the address assigned to the app (3.3.3.3) as the last entry + process.env.TRUSTED_PROXY_HOPS = "2"; + assert.equal(ip("5.5.5.5, 3.3.3.3", "2.2.2.2"), "5.5.5.5", "the client, not the CDN in fly-client-ip and not the app's own address"); + assert.equal(ip("1.1.1.1, 2.2.2.2"), "1.1.1.1", "no Fly header: the last entry is the peer a plain proxy appended, so step one in"); assert.equal(ip("1.1.1.1"), "0.0.0.0", "fewer entries than trusted hops: one shared bucket, never a client entry"); process.env.TRUSTED_PROXY_HOPS = "0"; assert.equal(ip("1.1.1.1, 2.2.2.2"), "2.2.2.2", "zero means the single proxy every deployment has"); - assert.equal(ip("1.1.1.1, 2.2.2.2", "2.2.2.2"), "2.2.2.2", "and Fly's own address is then the answer"); + assert.equal(ip("1.1.1.1, 2.2.2.2", "3.3.3.3"), "3.3.3.3", "and Fly's peer is then the answer, whatever the chain holds"); process.env.TRUSTED_PROXY_HOPS = "junk"; assert.equal(ip("1.1.1.1, 2.2.2.2"), "2.2.2.2", "a junk value falls back to that same single hop"); } finally { diff --git a/app/src/lib/http.ts b/app/src/lib/http.ts index c342d122..2456f1fd 100644 --- a/app/src/lib/http.ts +++ b/app/src/lib/http.ts @@ -10,23 +10,24 @@ function trustedProxyHops(): number { } /** - * Client IP for rate-limit keys. Two sources: - * - fly-client-ip: the address that reached Fly. That is the client while Fly's proxy is the outermost one, and the - * CDN in front of it once TRUSTED_PROXY_HOPS counts a hop above Fly; - * - x-forwarded-for: counted in from the right, since the first entry is whatever the client wrote. - * Only the last entry of that chain is checkable against the address Fly recorded, so a chain that does not end there - * was not written by our proxies and nothing past the nearest address is trusted. A hop count deeper than the header - * falls back to that nearest address, or to 0.0.0.0 (one shared bucket), never to a client-controlled entry. Trusting - * hops above Fly still assumes the CDN is the only way in: a client that can reach the app around it seeds the chain - * itself, which is a firewall's job, not this header's. The cap keeps an absurd header from becoming an absurd map key. + * Client IP for rate-limit keys. Fly Proxy sets fly-client-ip to the peer it accepted the connection from — the client + * while Fly's proxy is the outermost one, and the proxy service in front of it once TRUSTED_PROXY_HOPS counts a hop + * above Fly — and it appends one x-forwarded-for entry of its own: the address assigned to the app, always rightmost and + * never the client (fly.io/docs/networking/request-headers). So the chain is counted in from the right past that entry, + * and the address a client wrote at the front is never reached. + * + * With no Fly header the request came through a plain reverse proxy that appends the peer it saw, so the last entry is + * the client. A count deeper than the header falls back to fly-client-ip, or to 0.0.0.0 (one shared bucket), never to a + * client-written entry. Counting hops above Fly still assumes the proxy in front is the only way in: a client that + * reaches the app around it seeds the chain itself, which is a firewall's job, not this header's (the reverse parse + * MDN prescribes). The cap keeps an absurd header from becoming an absurd map key. */ export function clientIp(req: Request): string { const fly = req.headers.get("fly-client-ip")?.trim(); const hops = trustedProxyHops(); if (fly && hops === 1) return fly.slice(0, 128); const entries = (req.headers.get("x-forwarded-for") ?? "").split(",").map((s) => s.trim()); - const chained = !fly || entries.at(-1) === fly; - const picked = chained && entries.length >= hops ? entries[entries.length - hops] : ""; + const picked = entries.length >= hops ? entries[entries.length - hops] : ""; return (picked || fly || "0.0.0.0").slice(0, 128); } From b3582604f68b556f29852fd1646d74bc781e647f Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 13 Nov 2026 09:27:45 +0800 Subject: [PATCH 5/6] fix(api): signed bodies fit a smart wallet's signature The post, report and moderation routes capped their bodies at 4 KB, 2 KB and 2 KB. The server accepts smart-wallet signatures (ERC-1271, and ERC-6492 for a wallet not deployed yet), and a Coinbase Smart Wallet passkey signature in its ERC-6492 envelope makes a report body about 2.5 KB: such a wallet could not report a post at all. The three routes now share SIGNED_BODY_MAX (8 KB, the cap the profile routes already use for signed bodies). A test builds that signature from the wallet's ABI layout and checks the largest report and post fit. --- app/src/app/api/posts/mod/route.ts | 4 +-- app/src/app/api/posts/report/route.ts | 4 +-- app/src/app/api/posts/route.ts | 4 +-- app/src/app/api/write-limits.test.ts | 6 ++--- app/src/lib/http.test.ts | 38 ++++++++++++++++++++++++++- app/src/lib/http.ts | 8 ++++++ 6 files changed, 54 insertions(+), 10 deletions(-) diff --git a/app/src/app/api/posts/mod/route.ts b/app/src/app/api/posts/mod/route.ts index c0262a3a..a262ba04 100644 --- a/app/src/app/api/posts/mod/route.ts +++ b/app/src/app/api/posts/mod/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { clientIp, readJson } from "@/lib/http"; +import { SIGNED_BODY_MAX, clientIp, readJson } from "@/lib/http"; import { rateLimited } from "@/lib/launchpad/editServer"; import { moderate } from "@/lib/launchpad/postsServer"; import { adminFromRequest, fromAdminOrigin } from "@/lib/admin/session"; @@ -14,7 +14,7 @@ export const dynamic = "force-dynamic"; export async function POST(req: Request) { const ip = clientIp(req); if (rateLimited(`mod:ip:${ip}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 }); - const b = await readJson(req, 2_048); + const b = await readJson(req, SIGNED_BODY_MAX); if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 }); if (typeof b.wallet !== "string") return NextResponse.json({ error: "bad params" }, { status: 400 }); let session: string | null; diff --git a/app/src/app/api/posts/report/route.ts b/app/src/app/api/posts/report/route.ts index fc8fd3e6..51706c6e 100644 --- a/app/src/app/api/posts/report/route.ts +++ b/app/src/app/api/posts/report/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { clientIp, readJson } from "@/lib/http"; +import { SIGNED_BODY_MAX, clientIp, readJson } from "@/lib/http"; import { rateLimited } from "@/lib/launchpad/editServer"; import { reportPost } from "@/lib/launchpad/postsServer"; @@ -9,7 +9,7 @@ export const dynamic = "force-dynamic"; export async function POST(req: Request) { const ip = clientIp(req); if (rateLimited(`report:ip:${ip}`, 20)) return NextResponse.json({ error: "slow down" }, { status: 429 }); - const b = await readJson(req, 2_048); + const b = await readJson(req, SIGNED_BODY_MAX); if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 }); if (typeof b.wallet !== "string") return NextResponse.json({ error: "bad params" }, { status: 400 }); if (rateLimited(`report:wallet:${b.wallet.toLowerCase()}`, 20)) return NextResponse.json({ error: "slow down" }, { status: 429 }); diff --git a/app/src/app/api/posts/route.ts b/app/src/app/api/posts/route.ts index 9146682d..a76d48e2 100644 --- a/app/src/app/api/posts/route.ts +++ b/app/src/app/api/posts/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { isAddress } from "viem"; import { isChainKey } from "@/lib/chainPublic"; -import { clientIp, readJson } from "@/lib/http"; +import { SIGNED_BODY_MAX, clientIp, readJson } from "@/lib/http"; import { rateLimited } from "@/lib/launchpad/editServer"; import { createPost, listFeed, listTokenPosts } from "@/lib/launchpad/postsServer"; import { memo } from "@/lib/launchpad/memo"; @@ -37,7 +37,7 @@ export async function GET(req: Request) { export async function POST(req: Request) { const ip = clientIp(req); if (rateLimited(`post:ip:${ip}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 }); - const b = await readJson(req, 4_096); + const b = await readJson(req, SIGNED_BODY_MAX); if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 }); if (!isChainKey(b.chain) || typeof b.token !== "string" || typeof b.wallet !== "string") return NextResponse.json({ error: "bad params" }, { status: 400 }); const r = await createPost({ chain: b.chain, token: b.token, wallet: b.wallet, parentId: b.parentId, body: b.body, nonce: b.nonce, ts: b.ts, signature: b.signature }); diff --git a/app/src/app/api/write-limits.test.ts b/app/src/app/api/write-limits.test.ts index fb0c9324..be32c953 100644 --- a/app/src/app/api/write-limits.test.ts +++ b/app/src/app/api/write-limits.test.ts @@ -79,9 +79,9 @@ test("the JSON write routes cap the body before parsing it", () => { { file: "./launch/edit/route.ts", cap: "16_384" }, { file: "./launch/edit/nonce/route.ts", cap: "2_048" }, { file: "./launch/meta/route.ts", cap: "8_192" }, - { file: "./posts/route.ts", cap: "4_096" }, - { file: "./posts/mod/route.ts", cap: "2_048" }, - { file: "./posts/report/route.ts", cap: "2_048" }, + { file: "./posts/route.ts", cap: "SIGNED_BODY_MAX" }, + { file: "./posts/mod/route.ts", cap: "SIGNED_BODY_MAX" }, + { file: "./posts/report/route.ts", cap: "SIGNED_BODY_MAX" }, ]) { const src = source(file); assert.doesNotMatch(src, /await req\.json\(\)/, `${file} must not buffer an unbounded JSON body`); diff --git a/app/src/lib/http.test.ts b/app/src/lib/http.test.ts index 9cd2b8d2..3bffb9ce 100644 --- a/app/src/lib/http.test.ts +++ b/app/src/lib/http.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { clientIp, readFormData, readJson, readJsonValue, readTextLimited } from "./http.ts"; +import { concat, encodeAbiParameters, encodeFunctionData, parseAbi, type Hex } from "viem"; +import { SIGNED_BODY_MAX, clientIp, readFormData, readJson, readJsonValue, readTextLimited } from "./http.ts"; function streamed(chunks: string[]): ReadableStream { const enc = new TextEncoder(); @@ -100,3 +101,38 @@ test("readTextLimited stops an oversized upstream response", async () => { ); assert.deepEqual(await readTextLimited(broken, 10), { ok: false, tooLarge: false }); }); + +/** + * A Coinbase Smart Wallet passkey signature as the server receives it, built from the wallet's ABI layout: a WebAuthn + * assertion inside its SignatureWrapper, and for a wallet not deployed yet that again inside an ERC-6492 envelope with + * the factory call that would deploy it. The clientDataJSON carries the extra key Chrome adds. + */ +function smartWalletSignature(deployed: boolean): Hex { + const word = BigInt("0x" + "ab".repeat(32)); + const clientDataJSON = `{"type":"webauthn.get","challenge":"${"A".repeat(43)}","origin":"https://keys.coinbase.com","crossOrigin":false,` + + `"other_keys_can_be_added_here":"do not compare clientDataJSON against a template. See the WebAuthn spec 7.2"}`; + const webauthn = encodeAbiParameters( + [{ type: "tuple", components: [{ type: "bytes" }, { type: "string" }, { type: "uint256" }, { type: "uint256" }, { type: "uint256" }, { type: "uint256" }] }], + [[("0x" + "11".repeat(37)) as Hex, clientDataJSON, 23n, 1n, word, word]], + ); + const wrapped = encodeAbiParameters([{ type: "tuple", components: [{ type: "uint256" }, { type: "bytes" }] }], [[0n, webauthn]]); + if (deployed) return wrapped; + const owner = encodeAbiParameters([{ type: "uint256" }, { type: "uint256" }], [word, word]); + const deploy = encodeFunctionData({ abi: parseAbi(["function createAccount(bytes[] owners, uint256 nonce)"]), functionName: "createAccount", args: [[owner], 0n] }); + const envelope = encodeAbiParameters([{ type: "address" }, { type: "bytes" }, { type: "bytes" }], ["0x0ba5ed0c6aa8c49038f819e587e2633c4a9f428a", deploy, wrapped]); + return concat([envelope, ("0x" + "6492".repeat(16)) as Hex]); +} + +test("signed bodies from a smart wallet not deployed yet fit SIGNED_BODY_MAX", async () => { + const wallet = "0x" + "12".repeat(20); + const signature = smartWalletSignature(false); + assert.ok(signature.length > smartWalletSignature(true).length, "the ERC-6492 envelope is the larger case"); + const report = JSON.stringify({ postId: 123456, wallet, reason: "other", nonce: "f".repeat(32), ts: Date.now(), signature }); + // POST_MAX is 500 UTF-16 units: 250 emoji is the most bytes a post body can carry + const post = JSON.stringify({ chain: "base", token: wallet, wallet, parentId: 123456, body: "\u{1F600}".repeat(250), nonce: "f".repeat(32), ts: Date.now(), signature }); + assert.ok(new TextEncoder().encode(report).byteLength > 2_048, "the report body this guards is over the 2 KB cap it replaced"); + for (const body of [report, post]) { + assert.ok(new TextEncoder().encode(body).byteLength < SIGNED_BODY_MAX / 2, "with room to spare"); + assert.notEqual(await readJson(new Request("http://x/", { method: "POST", body }), SIGNED_BODY_MAX), null); + } +}); diff --git a/app/src/lib/http.ts b/app/src/lib/http.ts index 2456f1fd..d69ac72d 100644 --- a/app/src/lib/http.ts +++ b/app/src/lib/http.ts @@ -74,6 +74,14 @@ async function readBytes(req: Request, maxBytes: number): Promise { return readStreamLimited(req.body, maxBytes); } +/** + * The cap for a JSON body that carries a wallet signature. An EOA signs in 65 bytes, but the server also accepts smart + * wallets (ERC-1271, and ERC-6492 for one not deployed yet): a Coinbase Smart Wallet passkey signature runs to about + * 0.7 KB, and wrapped for a wallet that is not deployed yet to about 1.2 KB, which hex doubles. Its report body is then + * about 2.5 KB and its longest post about 3.6 KB, so this leaves room over both. + */ +export const SIGNED_BODY_MAX = 8_192; + /** A JSON value (object, array or primitive), or undefined when the body is over the cap or not JSON. */ export async function readJsonValue(req: Request, maxBytes = 8_192): Promise { const r = await readBytes(req, maxBytes); From cc489d7723f9ba073ef70dde8e817908e5a72cc9 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 13 Nov 2026 09:52:12 +0800 Subject: [PATCH 6/6] fix(api): address CodeRabbit's review of the rebased #113 - /api/rpc: when the Base B20 fallback answers but its body is over the cap or its stream fails, return the same retryable 502 as a failed primary read, instead of handing the wallet the primary's B20 error. - Token buckets count as full once the tokens they are short of have refilled, not a whole cap's worth, so pruning drops buckets that carry no state before it evicts a depleted one. - clientIp reads fly-client-ip only on a Fly Machine (FLY_APP_NAME or FLY_MACHINE_ID, which Fly sets in every Machine). Behind any other proxy a client could send the header itself and pick its own bucket. Route tests whose requests stand for Fly-delivered ones now say so. --- .../api/launch/chart-provider/route.test.ts | 2 + app/src/app/api/rpc/route.test.ts | 16 ++++++++ app/src/app/api/rpc/route.ts | 23 ++++++----- app/src/lib/bridge/relay.routes.test.ts | 2 + app/src/lib/http.test.ts | 38 +++++++++++++++++-- app/src/lib/http.ts | 10 ++++- app/src/lib/rateLimit.test.ts | 10 +++++ app/src/lib/rateLimit.ts | 3 +- 8 files changed, 88 insertions(+), 16 deletions(-) diff --git a/app/src/app/api/launch/chart-provider/route.test.ts b/app/src/app/api/launch/chart-provider/route.test.ts index 858672af..5411715f 100644 --- a/app/src/app/api/launch/chart-provider/route.test.ts +++ b/app/src/app/api/launch/chart-provider/route.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { GET } from "./route"; +// these requests stand for ones Fly Proxy delivered: clientIp reads fly-client-ip only on a Fly Machine +process.env.FLY_APP_NAME ??= "openlaunch-test"; const identity={chain:"base",token:"0x"+"a".repeat(40),quote:"0x"+"0".repeat(40),pool:"0x"+"b".repeat(64)}; function request(overrides: Partial = {}, ip="chart-test") { return new Request("http://localhost/api/launch/chart-provider?"+new URLSearchParams({...identity,...overrides}),{headers:{"fly-client-ip":ip}}); diff --git a/app/src/app/api/rpc/route.test.ts b/app/src/app/api/rpc/route.test.ts index 4a50b677..57eb5059 100644 --- a/app/src/app/api/rpc/route.test.ts +++ b/app/src/app/api/rpc/route.test.ts @@ -186,6 +186,22 @@ test("Base B20 fallback still receives only allowed reads", async (t) => { assert.deepEqual((await response.json()).find((r: { id: number }) => r.id === 1), result(1, "0x42")); }); +test("an unreadable Base B20 fallback is a retryable 502, never the primary's B20 error", async (t) => { + let calls = 0; + t.mock.method(globalThis, "fetch", async () => { + calls++; + if (calls === 1) return Response.json([{ jsonrpc: "2.0", id: 1, error: { code: -32603, message: "EVM error OpcodeNotFound" } }]); + // the fallback node answers 200 and its body stream breaks + return new Response(new ReadableStream({ start(controller) { controller.error(new Error("reset")); } }), { status: 200 }); + }); + const response = await POST(request([call(1, "eth_call")])); + assert.equal(calls, 2); + assert.equal(response.status, 502); + const body = await response.json(); + assert.equal(Array.isArray(body), false, "a non-RPC body, so viem takes its HTTP retry path"); + assert.match(body.error, /temporarily unavailable/); +}); + test("Arc routing uses its configured server upstream with the same read-only allowlist", async (t) => { const previous = process.env.ARC_RPC_URL; process.env.ARC_RPC_URL = "https://arc-rpc.example.test/private-test-key"; diff --git a/app/src/app/api/rpc/route.ts b/app/src/app/api/rpc/route.ts index 71817e41..1d7d86cf 100644 --- a/app/src/app/api/rpc/route.ts +++ b/app/src/app/api/rpc/route.ts @@ -43,6 +43,14 @@ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; type Req = { id?: unknown; jsonrpc?: string; method?: string; params?: unknown }; +/** An upstream body over the cap, or one whose stream failed: a non-RPC 502, so viem retries with backoff. */ +function unreadable(tooLarge: boolean) { + return NextResponse.json( + { error: tooLarge ? "The network RPC returned too much data. Try again." : "The network RPC is temporarily unavailable. Try again." }, + { status: 502, headers: { "cache-control": "no-store" } }, + ); +} + function safeJson(text: string): unknown { try { return JSON.parse(text); @@ -83,12 +91,7 @@ export async function POST(req: Request) { const payload = JSON.stringify(batch ? forward : forward[0]); const res = await fetch(upstream, { method: "POST", headers: { "content-type": "application/json" }, body: payload, signal: AbortSignal.timeout(15_000) }); const read = await readTextLimited(res, MAX_RESPONSE_BYTES); - if (!read.ok) { - return NextResponse.json( - { error: read.tooLarge ? "The network RPC returned too much data. Try again." : "The network RPC is temporarily unavailable. Try again." }, - { status: 502, headers: { "cache-control": "no-store" } }, - ); - } + if (!read.ok) return unreadable(read.tooLarge); let text = read.text; let status = res.status; // Base-native B20 tokens (Coinbase stocks) are precompiles the primary provider may not execute @@ -96,11 +99,11 @@ export async function POST(req: Request) { if (c === "base" && status === 200 && text.includes("Opcode") && responseHasB20Error(safeJson(text))) { const alt = await fetch(b20RpcUrl(), { method: "POST", headers: { "content-type": "application/json" }, body: payload, signal: AbortSignal.timeout(15_000) }); if (alt.ok) { + // the primary's answer is a B20 error the wallet cannot use: an unreadable fallback is a retryable 502 too const altRead = await readTextLimited(alt, MAX_RESPONSE_BYTES); - if (altRead.ok) { - text = altRead.text; - status = alt.status; - } + if (!altRead.ok) return unreadable(altRead.tooLarge); + text = altRead.text; + status = alt.status; } } // Rate limiting and malformed bodies become 429/502 so viem retries with backoff diff --git a/app/src/lib/bridge/relay.routes.test.ts b/app/src/lib/bridge/relay.routes.test.ts index d3ad811a..e52c354f 100644 --- a/app/src/lib/bridge/relay.routes.test.ts +++ b/app/src/lib/bridge/relay.routes.test.ts @@ -4,6 +4,8 @@ import { POST } from "../../app/api/bridge/quote/route.ts"; import { GET } from "../../app/api/bridge/status/route.ts"; import { BASE_USDC } from "./types.ts"; import { FIXTURE_INPUT, FIXTURE_NOW, addApprovalFixture, relayChainsFixture, relayRouteFixture } from "./relay.fixture.ts"; +// these requests stand for ones Fly Proxy delivered: clientIp reads fly-client-ip only on a Fly Machine +process.env.FLY_APP_NAME ??= "openlaunch-test"; function quoteRequest(body: string, ip: string, headers: Record = {}) { return new Request("https://openlaunch.example/api/bridge/quote", { method: "POST", body, headers: { "Content-Type": "application/json", "fly-client-ip": ip, ...headers } }); diff --git a/app/src/lib/http.test.ts b/app/src/lib/http.test.ts index 3bffb9ce..7dad9af1 100644 --- a/app/src/lib/http.test.ts +++ b/app/src/lib/http.test.ts @@ -13,7 +13,37 @@ function streamed(chunks: string[]): ReadableStream { }); } -test("clientIp prefers the Fly header, then the proxy-appended last XFF hop", () => { +/** Run `fn` as on a Fly Machine (Fly sets FLY_APP_NAME), or off one, restoring the environment after. */ +function withFly(on: boolean, fn: () => T): T { + const before = { app: process.env.FLY_APP_NAME, machine: process.env.FLY_MACHINE_ID }; + if (on) process.env.FLY_APP_NAME = "openlaunch-test"; + else delete process.env.FLY_APP_NAME; + delete process.env.FLY_MACHINE_ID; + try { + return fn(); + } finally { + if (before.app === undefined) delete process.env.FLY_APP_NAME; + else process.env.FLY_APP_NAME = before.app; + if (before.machine === undefined) delete process.env.FLY_MACHINE_ID; + else process.env.FLY_MACHINE_ID = before.machine; + } +} + +test("off Fly, a client-sent fly-client-ip never picks the bucket", () => { + withFly(false, () => { + const spoof = (fly: string) => clientIp(new Request("http://x/", { headers: { "fly-client-ip": fly, "x-forwarded-for": "1.1.1.1, 2.2.2.2" } })); + assert.equal(spoof("9.9.9.9"), "2.2.2.2", "the proxy-appended hop decides, not the header the client wrote"); + assert.equal(spoof("8.8.8.8"), "2.2.2.2", "so rotating it buys no fresh bucket"); + assert.equal(clientIp(new Request("http://x/", { headers: { "fly-client-ip": "9.9.9.9" } })), "0.0.0.0", "nor does it stand in for a missing chain"); + }); + withFly(true, () => { + process.env.FLY_MACHINE_ID = "e286"; + delete process.env.FLY_APP_NAME; + assert.equal(clientIp(new Request("http://x/", { headers: { "fly-client-ip": "3.3.3.3" } })), "3.3.3.3", "FLY_MACHINE_ID alone marks a Fly Machine too"); + }); +}); + +test("clientIp prefers the Fly header, then the proxy-appended last XFF hop", () => withFly(true, () => { assert.equal(clientIp(new Request("http://x/")), "0.0.0.0"); assert.equal(clientIp(new Request("http://x/", { headers: { "x-forwarded-for": "1.1.1.1, 2.2.2.2" } })), "2.2.2.2"); assert.equal( @@ -22,9 +52,9 @@ test("clientIp prefers the Fly header, then the proxy-appended last XFF hop", () "the edge header wins over both client-controlled hops", ); assert.equal(clientIp(new Request("http://x/", { headers: { "x-forwarded-for": "9".repeat(200) } })).length, 128, "an absurd header never becomes an absurd map key"); -}); +})); -test("clientIp counts trusted hops in from the right, past the entry Fly appends itself", () => { +test("clientIp counts trusted hops in from the right, past the entry Fly appends itself", () => withFly(true, () => { const before = process.env.TRUSTED_PROXY_HOPS; const ip = (xff: string, fly?: string) => clientIp(new Request("http://x/", { headers: { "x-forwarded-for": xff, ...(fly ? { "fly-client-ip": fly } : {}) } })); @@ -43,7 +73,7 @@ test("clientIp counts trusted hops in from the right, past the entry Fly appends if (before === undefined) delete process.env.TRUSTED_PROXY_HOPS; else process.env.TRUSTED_PROXY_HOPS = before; } -}); +})); test("readJson caps declared and streamed bodies before parsing", async () => { assert.deepEqual(await readJson(new Request("http://x/", { method: "POST", body: JSON.stringify({ a: 1 }) })), { a: 1 }); diff --git a/app/src/lib/http.ts b/app/src/lib/http.ts index d69ac72d..e0f8e9e9 100644 --- a/app/src/lib/http.ts +++ b/app/src/lib/http.ts @@ -2,6 +2,11 @@ * Request helpers shared by API routes. No `server-only` import: the pure parts are unit-tested with node --test. */ +/** Fly sets FLY_APP_NAME and FLY_MACHINE_ID in every Machine (docs.fly.io/machines/runtime-environment). */ +function onFly(): boolean { + return Boolean(process.env.FLY_APP_NAME?.trim() || process.env.FLY_MACHINE_ID?.trim()); +} + /** How many proxies of our own a request passes before it reaches the app (TRUSTED_PROXY_HOPS). */ function trustedProxyHops(): number { const n = Math.trunc(Number(process.env.TRUSTED_PROXY_HOPS ?? "")); @@ -21,9 +26,12 @@ function trustedProxyHops(): number { * client-written entry. Counting hops above Fly still assumes the proxy in front is the only way in: a client that * reaches the app around it seeds the chain itself, which is a firewall's job, not this header's (the reverse parse * MDN prescribes). The cap keeps an absurd header from becoming an absurd map key. + * + * fly-client-ip is read only on a Fly Machine, where Fly Proxy sets it. Behind any other proxy a client could send the + * header itself and pick its own bucket, so off Fly it is ignored and the chain alone decides. */ export function clientIp(req: Request): string { - const fly = req.headers.get("fly-client-ip")?.trim(); + const fly = onFly() ? req.headers.get("fly-client-ip")?.trim() : undefined; const hops = trustedProxyHops(); if (fly && hops === 1) return fly.slice(0, 128); const entries = (req.headers.get("x-forwarded-for") ?? "").split(",").map((s) => s.trim()); diff --git a/app/src/lib/rateLimit.test.ts b/app/src/lib/rateLimit.test.ts index b4043fa3..8b8c8885 100644 --- a/app/src/lib/rateLimit.test.ts +++ b/app/src/lib/rateLimit.test.ts @@ -75,6 +75,16 @@ test("token buckets are pruned after a full refill, then oldest first", () => { assert.deepEqual([...map.keys()], ["middle"], "full refills go first, then the oldest live bucket"); }); +test("a token bucket is full once its deficit refills, not a whole cap's worth", () => { + const map = new Map([ + ["one-down", { tokens: 9, at: 100, cap: 10, refillPerSecond: 1 }], // full at 1_100 + ["drained", { tokens: 0, at: 100, cap: 10, refillPerSecond: 1 }], // full at 10_100 + ["frozen", { tokens: 9, at: 100, cap: 10, refillPerSecond: 0 }], // never refills + ]); + pruneTokenBuckets(map, 1_100, 10); + assert.deepEqual([...map.keys()], ["drained", "frozen"], "only the bucket that is already full carries no state"); +}); + test("the token map stays bounded under a burst of one-shot keys", () => { resetRateLimitsForTests(); for (let i = 0; i < MAX_TOKEN_BUCKETS + 50; i++) takeToken(`burst-${i}`, 1, 0.001, 1_000); diff --git a/app/src/lib/rateLimit.ts b/app/src/lib/rateLimit.ts index 8fa00d84..3cb34e3a 100644 --- a/app/src/lib/rateLimit.ts +++ b/app/src/lib/rateLimit.ts @@ -64,7 +64,8 @@ export function takeToken(key: string, cap: number, refillPerSecond: number, now /** Fully refilled buckets carry no state and go first; then oldest. Exported for tests. */ export function pruneTokenBuckets(map: Map, now: number, max: number): void { for (const [key, b] of map) { - const fullAfter = b.refillPerSecond > 0 ? (b.cap / b.refillPerSecond) * 1000 : Infinity; + // full once the tokens it is short of have refilled (a bucket one token down is full after one token's time) + const fullAfter = b.refillPerSecond > 0 ? (Math.max(0, b.cap - b.tokens) / b.refillPerSecond) * 1000 : Infinity; if (now - b.at >= fullAfter) map.delete(key); } while (map.size > max) {