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
+
+
+ Endpoint preset
+ setPresetKey(e.target.value)}
+ style={{ width: "100%", padding: 6 }}
+ >
+ {Object.entries(PRESETS).map(([k, p]) => (
+
+ {p.label}
+
+ ))}
+
+
+
+ {presetKey === "custom" ? (
+ <>
+
+ URL
+ setCustomUrl(e.target.value)}
+ style={{ width: "100%", padding: 6, ...mono }}
+ placeholder="https://rpc.testnet.fastnear.com"
+ />
+
+
+ Method
+ setCustomMethod(e.target.value)}
+ style={{ padding: 6 }}
+ >
+ {["GET", "POST", "PUT", "DELETE", "HEAD"].map((m) => (
+
+ {m}
+
+ ))}
+
+
+ {customMethod !== "GET" && customMethod !== "HEAD" ? (
+
+ Body
+
+ ) : null}
+ >
+ ) : (
+
+ {effective.method} {effective.url}
+ {effective.body ? ` — ${effective.body}` : ""}
+
+ )}
+
+
+ Auth attachment
+ setAuthMode(e.target.value)}
+ style={{ padding: 6 }}
+ >
+ Authorization: Bearer <key>
+ ?apiKey=<key> query param
+ No key
+
+
+
+
+
+
+ {!running ? (
+ runTest()} type="button">
+ Run
+
+ ) : (
+
+ Stop
+
+ )}
+
+ Burst preset (×10, 0ms)
+
+ setResults([])}
+ type="button"
+ disabled={running}
+ >
+ Clear results
+
+
+
+
+ ⚠ Each run consumes real rate-limit budget against this key.
+
+
+
+
Off-origin / lifted-key test (run with hey elsewhere)
+
+ Origin/Referer restriction only binds real browsers. A non-browser client
+ holding a lifted key can forge the Origin{" "}
+ header — variant 2 demonstrates that limitation. Run this where{" "}
+ hey is installed, then compare its
+ status-code + latency summary against this page's results.
+
+
{heyScript}
+
+ copy(heyScript)} type="button">
+ Copy hey script
+
+ copy(curlCommand)} type="button">
+ Copy single curl
+
+
+
+ {results.length > 0 ? (
+ <>
+
Summary (this browser origin)
+
+ Total: {summary.total}
+
+ By status:{" "}
+ {Object.entries(summary.byStatus)
+ .map(([k, v]) => `${k}×${v}`)
+ .join(", ")}
+
+
+ First 403:{" "}
+ {summary.first403
+ ? `request #${summary.first403.i} at ${summary.first403.atMs}ms`
+ : "none"}
+
+
+ First 429:{" "}
+ {summary.first429
+ ? `request #${summary.first429.i} at ${summary.first429.atMs}ms`
+ : "none"}
+
+ Elapsed: {summary.elapsedMs}ms · Achieved: {summary.rps.toFixed(2)} req/s
+
+ Latency: avg {summary.avgMs}ms · p50 {summary.p50}ms · p95 {summary.p95}ms
+
+
+
+
Results
+
+ Status code and body are reliable. x-rate-limit-score,{" "}
+ cf-ray and retry-after are only visible if the
+ server exposes them via Access-Control-Expose-Headers —
+ otherwise they read as —. Browser latencies include CORS
+ preflight; hey latencies do not.
+
+
+
+
+
+ {["#", "t+ms", "status", "class", "dur", "score", "cf-ray", "retry", "body"].map(
+ (h) => (
+
+ {h}
+
+ )
+ )}
+
+
+
+ {results.map((r) => (
+
+ {r.i}
+ {r.atMs}
+
+ {r.status == null ? "ERR" : r.status}
+
+ {r.classification}
+ {r.durationMs}
+ {r.score || "—"}
+ {r.cfRay || "—"}
+ {r.retryAfter || "—"}
+
+ {r.bodySnippet}
+
+
+ ))}
+
+
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/src/pages/debug.js b/src/pages/debug.js
new file mode 100644
index 00000000..020e37e5
--- /dev/null
+++ b/src/pages/debug.js
@@ -0,0 +1,26 @@
+import React from "react";
+import Layout from "@theme/Layout";
+import Head from "@docusaurus/Head";
+import BrowserOnly from "@docusaurus/BrowserOnly";
+
+// Hidden internal tool. Not linked from any nav/sidebar/footer and excluded from
+// the sitemap (see docusaurus.config.js). The `noindex` meta keeps it out of
+// search engines; the local search theme already uses `indexPages: false`.
+export default function DebugPage() {
+ return (
+
+
+
+
+
+ Loading debugger…
}>
+ {() => {
+ const RateLimitDebugger =
+ require("@site/src/components/RateLimitDebugger").default;
+ return ;
+ }}
+
+
+
+ );
+}