From b8943693b7d3ad3393a074a0cca4e08d54fa37fd Mon Sep 17 00:00:00 2001 From: Mike Purvis Date: Thu, 18 Jun 2026 10:15:57 -0700 Subject: [PATCH] add /debug route for rate-limit testing with origin/referer from CF --- docusaurus.config.js | 1 + src/components/RateLimitDebugger/index.js | 621 ++++++++++++++++++++++ src/pages/debug.js | 26 + 3 files changed, 648 insertions(+) create mode 100644 src/components/RateLimitDebugger/index.js create mode 100644 src/pages/debug.js diff --git a/docusaurus.config.js b/docusaurus.config.js index 551b49d7..ca3d03ef 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -512,6 +512,7 @@ const config = { '/redocly-config', '/transaction-flow', '/transaction-flow/**', + '/debug', ...(hideEarlyApiFamilies ? ['/transfers/**', '/fastdata/**'] : []), ]), lastmod: 'date', diff --git a/src/components/RateLimitDebugger/index.js b/src/components/RateLimitDebugger/index.js new file mode 100644 index 00000000..28a46863 --- /dev/null +++ b/src/components/RateLimitDebugger/index.js @@ -0,0 +1,621 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; + +import ApiKeyManager from "@site/src/components/ApiKeyManager"; +import { usePortalAuth } from "@site/src/components/FastnearDirectOperation/portalAuth"; + +const RPC_MAINNET = "https://rpc.mainnet.fastnear.com"; + +// Endpoint presets, RPC-first (the thing we want to "spam"). The authenticated +// JSON-RPC calls flow through the Cloudflare key-matcher snippet, so they +// actually exercise the UI-key origin restriction. /status is auth-optional and +// may return 200 regardless of origin — a reachability baseline only. +const PRESETS = { + "rpc-block": { + label: "RPC block — latest final block (default)", + url: RPC_MAINNET, + method: "POST", + body: '{"jsonrpc":"2.0","id":"debug","method":"block","params":{"finality":"final"}}', + }, + "rpc-view-account": { + label: "RPC query view_account (root.near) — mirrors account-lookup", + url: RPC_MAINNET, + method: "POST", + body: '{"jsonrpc":"2.0","id":"debug","method":"query","params":{"request_type":"view_account","account_id":"root.near","finality":"final"}}', + }, + "rpc-gas-price": { + label: "RPC gas_price", + url: RPC_MAINNET, + method: "POST", + body: '{"jsonrpc":"2.0","id":"debug","method":"gas_price","params":[null]}', + }, + "rpc-validators": { + label: "RPC validators — heavier; account-lookup fans out from this", + url: RPC_MAINNET, + method: "POST", + body: '{"jsonrpc":"2.0","id":"debug","method":"validators","params":[null]}', + }, + "rest-status": { + label: "REST GET /status — public baseline (may 200 regardless of origin)", + url: "https://api.fastnear.com/status", + method: "GET", + body: "", + }, + custom: { + label: "Custom URL", + url: RPC_MAINNET, + method: "POST", + body: '{"jsonrpc":"2.0","id":"debug","method":"block","params":{"finality":"final"}}', + }, +}; + +const mono = { + fontFamily: + "var(--ifm-font-family-monospace, ui-monospace, SFMono-Regular, Menlo, monospace)", +}; + +function classify(status, bodyText) { + if (status == null) return "network/CORS error"; + if (status >= 200 && status < 300) return "ok"; + if (status === 401) return "missing/invalid key (401)"; + if (status === 402) return "quota exceeded (402)"; + if (status === 403) { + return /origin_not_allowed/.test(bodyText || "") + ? "origin_not_allowed (403)" + : "forbidden (403)"; + } + if (status === 429) return "rate limited (429)"; + return `status ${status}`; +} + +function rowColor(row) { + if (row.status == null) return "var(--ifm-color-warning-dark, #b8860b)"; + if (row.status >= 200 && row.status < 300) return "var(--ifm-color-success-dark, #1a7f37)"; + if (row.status === 429) return "var(--ifm-color-warning-dark, #b8860b)"; + return "var(--ifm-color-danger-dark, #c1372f)"; +} + +function percentile(sorted, p) { + if (!sorted.length) return 0; + const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length)); + return sorted[idx]; +} + +// Single-quote a value for a POSIX shell command. +function shquote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'`; +} + +const sleep = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +export default function RateLimitDebugger() { + const auth = usePortalAuth(); + const apiKey = auth?.apiKey || ""; + + const [presetKey, setPresetKey] = useState("rpc-block"); + const [customUrl, setCustomUrl] = useState(RPC_MAINNET); + const [customMethod, setCustomMethod] = useState("POST"); + const [customBody, setCustomBody] = useState(PRESETS["rpc-block"].body); + const [authMode, setAuthMode] = useState("bearer"); // bearer | query | none + + const [count, setCount] = useState(25); + const [concurrency, setConcurrency] = useState(1); + const [delayMs, setDelayMs] = useState(200); + + const [running, setRunning] = useState(false); + const [results, setResults] = useState([]); + const [autoRetry, setAutoRetry] = useState(null); + + const controllerRef = useRef(null); + const cancelledRef = useRef(false); + const autoRunDoneRef = useRef(false); + + const effective = useMemo(() => { + if (presetKey === "custom") { + return { url: customUrl, method: customMethod, body: customBody }; + } + const p = PRESETS[presetKey]; + return { url: p.url, method: p.method, body: p.body }; + }, [presetKey, customUrl, customMethod, customBody]); + + // Build the actual request the browser will send, applying the chosen auth mode. + const buildRequest = () => { + const headers = { Accept: "application/json" }; + let url = effective.url; + let body; + + if (effective.method !== "GET" && effective.method !== "HEAD") { + headers["Content-Type"] = "application/json"; + body = effective.body || undefined; + } + + if (authMode === "bearer" && apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } else if (authMode === "query" && apiKey) { + try { + const u = new URL(url); + u.searchParams.set("apiKey", apiKey); + url = u.toString(); + } catch (_error) { + // leave url untouched if it is not parseable + } + } + + return { url, method: effective.method, headers, body }; + }; + + const origin = typeof window !== "undefined" ? window.location.origin : "(server)"; + + const curlCommand = useMemo(() => { + const { url, method, headers, body } = buildRequest(); + const lines = [`curl -s ${shquote(url)}`]; + if (method !== "GET") lines.push(` -X ${method}`); + Object.entries(headers).forEach(([k, v]) => { + lines.push(` -H ${shquote(`${k}: ${v}`)}`); + }); + if (body) lines.push(` --data-raw ${shquote(body)}`); + return lines.join(" \\\n"); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effective, authMode, apiKey]); + + // hey load-test script for the off-origin / lifted-key scenarios. + const heyScript = useMemo(() => { + const n = Math.max(1, Math.floor(Number(count) || 1)); + const c = Math.max(1, Math.floor(Number(concurrency) || 1)); + + const heyLine = (extraHeaders) => { + const { url, method, headers, body } = buildRequest(); + const parts = ["hey", `-n ${n}`, `-c ${c}`, `-m ${method}`]; + Object.entries(headers).forEach(([k, v]) => parts.push(`-H ${shquote(`${k}: ${v}`)}`)); + extraHeaders.forEach((h) => parts.push(`-H ${shquote(h)}`)); + if (body) parts.push(`-d ${shquote(body)}`); + parts.push(shquote(url)); + return parts.join(" "); + }; + + return [ + "#!/usr/bin/env bash", + '# Off-origin load test of a "lifted" API key using hey (github.com/rakyll/hey).', + `# Compare hey's status-code + latency summary against the in-browser run on`, + `# ${origin} (the browser sends that true Origin and cannot forge it).`, + "", + "echo '== 1) Naive lifted key: no Origin/Referer (expect 403 origin_not_allowed) =='", + heyLine([]), + "", + `echo '== 2) Forged MATCHING Origin: ${origin} (Origin is forgeable outside a browser -> may return 200) =='`, + heyLine([`Origin: ${origin}`]), + "", + "echo '== 3) Wrong Origin (expect 403 origin_not_allowed) =='", + heyLine(["Origin: https://evil.example.com"]), + "", + ].join("\n"); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effective, authMode, apiKey, count, concurrency, origin]); + + const summary = useMemo(() => { + const byStatus = {}; + results.forEach((r) => { + const key = r.status == null ? "ERR" : String(r.status); + byStatus[key] = (byStatus[key] || 0) + 1; + }); + const first403 = results.find((r) => r.status === 403); + const first429 = results.find((r) => r.status === 429); + const last = results[results.length - 1]; + const elapsedMs = last ? last.atMs + last.durationMs : 0; + const rps = elapsedMs > 0 ? results.length / (elapsedMs / 1000) : 0; + const durs = results.map((r) => r.durationMs).sort((a, b) => a - b); + const avgMs = durs.length + ? Math.round(durs.reduce((a, b) => a + b, 0) / durs.length) + : 0; + return { + byStatus, + first403, + first429, + elapsedMs, + rps, + total: results.length, + avgMs, + p50: percentile(durs, 50), + p95: percentile(durs, 95), + }; + }, [results]); + + const runTest = async (countOverride) => { + if (running) return; + setResults([]); + setRunning(true); + cancelledRef.current = false; + const controller = new AbortController(); + controllerRef.current = controller; + + const startedAt = performance.now(); + const total = Math.max(1, Math.floor(Number(countOverride ?? count) || 1)); + const workers = Math.max(1, Math.floor(Number(concurrency) || 1)); + const pause = Math.max(0, Math.floor(Number(delayMs) || 0)); + const indexRef = { current: 0 }; + + const doOne = async () => { + const i = indexRef.current; + indexRef.current += 1; + if (i >= total || cancelledRef.current) return false; + + const atMs = Math.round(performance.now() - startedAt); + const reqStart = performance.now(); + const { url, method, headers, body } = buildRequest(); + + let row; + try { + const res = await fetch(url, { method, headers, body, signal: controller.signal }); + const text = await res.text().catch(() => ""); + row = { + i, + atMs, + status: res.status, + ok: res.ok, + classification: classify(res.status, text), + durationMs: Math.round(performance.now() - reqStart), + score: res.headers.get("x-rate-limit-score"), + cfRay: res.headers.get("cf-ray"), + retryAfter: res.headers.get("retry-after"), + bodySnippet: text.slice(0, 160), + error: null, + }; + } catch (error) { + if (error && error.name === "AbortError") return false; + row = { + i, + atMs, + status: null, + ok: false, + classification: "network/CORS (possibly blocked without CORS headers)", + durationMs: Math.round(performance.now() - reqStart), + score: null, + cfRay: null, + retryAfter: null, + bodySnippet: String((error && error.message) || error), + error: (error && error.name) || "Error", + }; + } + + setResults((prev) => [...prev, row]); + return true; + }; + + const worker = async () => { + // eslint-disable-next-line no-constant-condition + while (true) { + if (cancelledRef.current || indexRef.current >= total) return; + const didRun = await doOne(); + if (!didRun) return; + if (pause > 0 && !cancelledRef.current) await sleep(pause); + } + }; + + try { + await Promise.all(Array.from({ length: workers }, () => worker())); + } finally { + setRunning(false); + controllerRef.current = null; + } + }; + + // ?debug_retry=N -> set count to N and auto-run once on load. + useEffect(() => { + if (autoRunDoneRef.current || typeof window === "undefined") return; + const retry = parseInt( + new URLSearchParams(window.location.search).get("debug_retry") || "", + 10 + ); + if (Number.isFinite(retry) && retry > 0) { + autoRunDoneRef.current = true; + setCount(retry); + setAutoRetry(retry); + runTest(retry); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const stopTest = () => { + cancelledRef.current = true; + if (controllerRef.current) controllerRef.current.abort(); + }; + + const applyBurst = () => { + setConcurrency(10); + setDelayMs(0); + }; + + const copy = (text) => { + if (typeof navigator !== "undefined" && navigator.clipboard) { + navigator.clipboard.writeText(text).catch(() => {}); + } + }; + + const keyStatus = apiKey + ? `present (from ${auth.apiKeySource === "url" ? "?apiKey= URL param" : "localStorage"})` + : "none"; + + const cell = { + padding: "4px 8px", + borderBottom: "1px solid var(--ifm-table-border-color, #ddd)", + whiteSpace: "nowrap", + }; + + return ( +
+

API Key / Rate Limit Debugger

+

+ Spams the FastNear RPC from this browser origin so you can + see how the edge treats keyed requests. The browser sets{" "} + Origin/Referer automatically and JavaScript cannot + override them, so this page tests the legit on-origin UI path. To + simulate a lifted key used from elsewhere, copy the{" "} + hey script below and run it on another machine. +

+ +
+
+ Browser origin sent on every request: {origin} +
+
+ API key: {keyStatus} +
+
+ Tip: open {`${origin}/debug?apiKey=YOUR_KEY&debug_retry=100`}{" "} + to auto-run 100 requests on load. +
+ {autoRetry ? ( +
+ Auto-running {autoRetry} requests from{" "} + ?debug_retry. +
+ ) : null} +
+ + + +

Request

+
+ + + {presetKey === "custom" ? ( + <> + + + {customMethod !== "GET" && customMethod !== "HEAD" ? ( +