diff --git a/app/.env.example b/app/.env.example index 2472366e..d01656a2 100644 --- a/app/.env.example +++ b/app/.env.example @@ -65,3 +65,9 @@ IMAGE_STORE=local # Wallets allowed to moderate posts (comma-separated, lowercase) 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 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/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.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/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..a262ba04 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 { 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"; @@ -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, 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; try { diff --git a/app/src/app/api/posts/report/route.ts b/app/src/app/api/posts/report/route.ts index e37c72b2..51706c6e 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 { SIGNED_BODY_MAX, 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, 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 }); 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..a76d48e2 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 { 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"; @@ -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, 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 }); 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.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 547bd818..1d7d86cf 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,25 +36,21 @@ 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 }; +/** 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); @@ -68,14 +66,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,14 +90,19 @@ 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 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 // ("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(); + // 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) return unreadable(altRead.tooLarge); + text = altRead.text; status = alt.status; } } diff --git a/app/src/app/api/write-limits.test.ts b/app/src/app/api/write-limits.test.ts index 1fa58d2f..be32c953 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: "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`); + 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/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 new file mode 100644 index 00000000..7dad9af1 --- /dev/null +++ b/app/src/lib/http.test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +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(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(enc.encode(chunk)); + controller.close(); + }, + }); +} + +/** 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( + 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", + ); + 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", () => 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 } : {}) } })); + try { + // 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", "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 { + 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 }); + 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 }); +}); + +/** + * 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 new file mode 100644 index 00000000..e0f8e9e9 --- /dev/null +++ b/app/src/lib/http.ts @@ -0,0 +1,147 @@ +/** + * 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 ?? "")); + // blank, zero, negative or junk means the single proxy every deployment has + return n > 1 ? n : 1; +} + +/** + * 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. + * + * 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 = 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()); + const picked = 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 }; + +/** + * 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 readStreamLimited(body: ReadableStream, maxBytes: number): Promise { + const reader = 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 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); +} + +/** + * 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); + 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 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/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/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/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..8b8c8885 --- /dev/null +++ b/app/src/lib/rateLimit.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MAX_RATE_BUCKETS, + MAX_TOKEN_BUCKETS, + pruneRateBuckets, + pruneTokenBuckets, + rateLimitedKeysForTests, + 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("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); + 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("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); + 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..3cb34e3a --- /dev/null +++ b/app/src/lib/rateLimit.ts @@ -0,0 +1,92 @@ +/** + * 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) { + // 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; + } + 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) { + // 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) { + 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 }; +} + +/** Test-only: fixed-window keys in eviction order (oldest first). */ +export function rateLimitedKeysForTests(): string[] { + return [...buckets.keys()]; +}