From 8b19a2569a32564ca35749c019b2033dbb2b6544 Mon Sep 17 00:00:00 2001 From: Dan Anderson Date: Thu, 29 Oct 2026 00:12:03 -0400 Subject: [PATCH] Protect and cache Counterparty read gateway --- apps/api/src/env.ts | 5 ++ apps/api/src/read/node.ts | 125 ++++++++++++++++++++++++++++++------- apps/api/wrangler.toml | 13 ++++ tests/node-gateway.test.ts | 87 +++++++++++++++++++++++--- 4 files changed, 199 insertions(+), 31 deletions(-) diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 346d5e0..25f9298 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -30,4 +30,9 @@ export interface Env { /** The Counterparty API's `/v2` root. Unset reads the public node, * https://api.counterparty.io:4000/v2. */ COUNTERPARTY_API_BASE?: string; + /** Per-caller guard on the public compatibility gateway. This is not the + * upstream quota: cache coalescing and the shared 429 cooldown protect + * Counterparty itself. It keeps one outside integration from turning our + * first-party fallback into its general-purpose API. */ + NODE_PUBLIC_RATE_LIMITER: RateLimit; } diff --git a/apps/api/src/read/node.ts b/apps/api/src/read/node.ts index 77a535a..a500c5a 100644 --- a/apps/api/src/read/node.ts +++ b/apps/api/src/read/node.ts @@ -1,6 +1,8 @@ /** Read-only fallback for routine protocol reads that the first-party index cannot answer. Wallet * composition, broadcasts and Electrs spend checks use the node directly. Never parse response JSON - * (quantities exceed 2^53), cache live state, or introduce automatic upstream retries here. */ + * (quantities exceed 2^53) or introduce automatic upstream retries here. Counterparty's own public + * cache policy is preserved at our edge: stripping its max-age made every tab re-hit the upstream, + * which defeated the gateway's original purpose. */ import { Hono } from "hono"; import type { Env } from "#api/env"; import { retryAfterDeadline } from "#api/integrations/cooldown"; @@ -13,6 +15,8 @@ const HASH = /^[a-fA-F0-9]{64}$/; const UTXO = /^[a-fA-F0-9]{64}:(0|[1-9][0-9]{0,9})$/; const ASSET = /^[A-Za-z0-9_.!@-]{1,250}$/; const EVENT = /^[A-Z][A-Z0-9_]{0,79}$/; +const FIRST_PARTY_ORIGINS = new Set(["https://xcp.fun", "https://www.xcp.fun"]); +const MAX_CACHE_SECONDS = 300; const names = (value: string) => new Set(value.split(/\s+/).filter(Boolean)); const READ_PARAMS = names(`limit cursor verbose status sort type addresses event_name @@ -83,19 +87,59 @@ function validQuery(url: URL): boolean { return true; } -const responseHeaders = () => new Headers({ - "cache-control": "no-store", - "access-control-allow-origin": "*", - "access-control-allow-headers": "Accept, Content-Type", - "access-control-expose-headers": "Retry-After", -}); -const errorResponse = (error: string, status: number, extra?: Record) => { - const headers = responseHeaders(); +const responseHeaders = (origin: string | null, cacheControl = "no-store") => { + const headers = new Headers({ + "cache-control": cacheControl, + // The gateway must execute on every request so CORS and the per-caller budget cannot be skipped. + // Browsers still honor Cache-Control, while our explicit Cache API copy absorbs upstream reads. + "cloudflare-cdn-cache-control": "no-store", + "access-control-allow-headers": "Accept, Content-Type", + "access-control-expose-headers": "Retry-After, X-XCP-Proxy-Cache", + "vary": "Origin", + "x-robots-tag": "noindex, nofollow", + }); + if (origin && FIRST_PARTY_ORIGINS.has(origin)) headers.set("access-control-allow-origin", origin); + return headers; +}; +const errorResponse = (error: string, status: number, origin: string | null, extra?: Record) => { + const headers = responseHeaders(origin); headers.set("content-type", "application/json"); for (const [key, value] of Object.entries(extra ?? {})) headers.set(key, value); return new Response(JSON.stringify({ error }), { status, headers }); }; +/** Use one canonical key across the custom domain, workers.dev and service bindings. Query parameter + * order is not semantically significant here (duplicates were rejected above), so sorting also stops + * callers from manufacturing cache misses by shuffling an otherwise identical query. */ +const cacheKey = (url: URL) => { + const canonical = new URL(url); + canonical.protocol = "https:"; + canonical.host = "api.xcp.fun"; + canonical.searchParams.sort(); + return new Request(canonical.toString(), { method: "GET" }); +}; + +/** Counterparty owns the freshness decision. Admit only an explicit public max-age, clamp an + * unexpectedly large value, and keep mempool/no-store responses live. */ +function cacheControl(upstream: Headers): string { + const value = upstream.get("cache-control") ?? ""; + if (/\b(?:no-store|private)\b/i.test(value) || !/\bpublic\b/i.test(value)) return "no-store"; + const match = /(?:^|,)\s*max-age\s*=\s*(\d+)/i.exec(value); + if (!match) return "no-store"; + const seconds = Math.min(Number(match[1]), MAX_CACHE_SECONDS); + return Number.isSafeInteger(seconds) && seconds > 0 ? `public, max-age=${seconds}` : "no-store"; +} + +function clientResponse(response: Response, origin: string | null, cacheStatus: "HIT" | "MISS", control: string) { + const headers = responseHeaders(origin, control); + headers.set("x-xcp-proxy-cache", cacheStatus); + for (const key of ["content-type", "content-encoding", "retry-after"]) { + const value = response.headers.get(key); + if (value !== null) headers.set(key, value); + } + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + // A refusal observed by browser traffic must not consume the indexer's permits or prevent a cron // from progressing. This gate shares only a deadline in this isolate, not bodies, // promises, a global quota, or user wallet state. Concurrent refusals can only extend it. @@ -103,21 +147,47 @@ let cooldown = 0; export const nodeRoute = new Hono<{ Bindings: Env }>(); nodeRoute.all("/node/*", async (c) => { const url = new URL(c.req.url); + const origin = c.req.header("origin") ?? null; + // This is a useful compatibility layer for our own browser, not a public API product. An explicit + // outside browser origin is cheap to reject and removes the easiest form of third-party reuse. + // Non-browser clients do not send Origin, so the per-caller ceiling below remains the real backstop. + if (origin && !FIRST_PARTY_ORIGINS.has(origin)) { + return errorResponse("This first-party compatibility gateway is not available to external browser origins", 403, null); + } const target = targetFor(url.pathname); - if (!target) return errorResponse("Unsupported node operation", 404); + if (!target) return errorResponse("Unsupported node operation", 404, origin); if (c.req.method === "OPTIONS") { - const headers = responseHeaders(); + const headers = responseHeaders(origin); headers.set("access-control-allow-methods", "GET, OPTIONS"); return new Response(null, { status: 204, headers }); } - if (c.req.method !== "GET") return errorResponse("Method not allowed", 405, { allow: "GET, OPTIONS" }); - if (!validQuery(url)) return errorResponse("Invalid node query", 400); + if (c.req.method !== "GET") return errorResponse("Method not allowed", 405, origin, { allow: "GET, OPTIONS" }); + if (!validQuery(url)) return errorResponse("Invalid node query", 400, origin); if (c.req.raw.body) { void c.req.raw.body.cancel().catch(() => undefined); - return errorResponse("Request body is not supported", 400); + return errorResponse("Request body is not supported", 400, origin); + } + + const actor = c.req.header("cf-connecting-ip") ?? "unattributed"; + const admission = await c.env.NODE_PUBLIC_RATE_LIMITER.limit({ key: actor }); + if (!admission.success) { + return errorResponse( + "First-party compatibility gateway limit reached; external integrations should use api.counterparty.io", + 429, + origin, + { "retry-after": "60" }, + ); + } + + // A hit is safe even during an upstream cooldown: it performs no upstream request. The cache holds + // only successful responses for exactly the freshness window Counterparty itself advertised. + const key = cacheKey(url); + if (typeof caches !== "undefined") { + const hit = await caches.default.match(key); + if (hit) return clientResponse(hit, origin, "HIT", hit.headers.get("cache-control") ?? "no-store"); } const remaining = cooldown - Date.now(); - if (remaining > 0) return errorResponse("Upstream is rate limited; retry after the indicated delay", 429, { + if (remaining > 0) return errorResponse("Upstream is rate limited; retry after the indicated delay", 429, origin, { "retry-after": String(Number.isFinite(remaining) ? Math.ceil(remaining / 1_000) : 31_536_000), }); let upstream: Response; @@ -133,7 +203,7 @@ nodeRoute.all("/node/*", async (c) => { signal, }); } catch (error) { - return errorResponse("Node temporarily unavailable", error instanceof Error && /^(TimeoutError|AbortError)$/.test(error.name) ? 504 : 502); + return errorResponse("Node temporarily unavailable", error instanceof Error && /^(TimeoutError|AbortError)$/.test(error.name) ? 504 : 502, origin); } if (upstream.status === 429) { const now = Date.now(); @@ -144,12 +214,23 @@ nodeRoute.all("/node/*", async (c) => { // including missing/pending transactions, 429 and 503, retain their raw protocol response. if (upstream.status >= 300 && upstream.status < 400) { void upstream.body?.cancel().catch(() => undefined); - return errorResponse("Unexpected node redirect", 502); + return errorResponse("Unexpected node redirect", 502, origin); } - const headers = responseHeaders(); - for (const key of ["content-type", "content-encoding", "retry-after"]) { - const value = upstream.headers.get(key); - if (value !== null) headers.set(key, value); + + const control = cacheControl(upstream.headers); + const response = clientResponse(upstream, origin, "MISS", control); + if (upstream.ok && control !== "no-store" && typeof caches !== "undefined") { + const stored = response.clone(); + // CORS is a property of this caller, not of the protocol answer. Add it after a hit so one origin + // can never populate another origin's access-control header. + stored.headers.delete("access-control-allow-origin"); + stored.headers.delete("access-control-allow-headers"); + stored.headers.delete("access-control-expose-headers"); + stored.headers.delete("cloudflare-cdn-cache-control"); + stored.headers.delete("vary"); + stored.headers.delete("x-xcp-proxy-cache"); + stored.headers.delete("x-robots-tag"); + c.executionCtx.waitUntil(caches.default.put(key, stored).catch(() => undefined)); } - return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers }); + return response; }); diff --git a/apps/api/wrangler.toml b/apps/api/wrangler.toml index 2d453b5..a3f5db8 100644 --- a/apps/api/wrangler.toml +++ b/apps/api/wrangler.toml @@ -35,6 +35,19 @@ CHAT_ENABLED = "true" # Optional COUNTERPARTY_API_BASE is the Counterparty API's /v2 root; unset # reads https://api.counterparty.io:4000/v2. +# The node compatibility gateway exists for xcp.fun, not as a hosted public +# Counterparty node. This approximate per-colo/per-caller ceiling is generous +# for a page (five reads a second sustained) but makes bulk reuse unattractive. +# Ordinary upstream responses are also cached under Counterparty's own TTL, so +# repeated first-party reads normally do not reach the node at all. +[[ratelimits]] +name = "NODE_PUBLIC_RATE_LIMITER" +namespace_id = "2026102901" + + [ratelimits.simple] + limit = 300 + period = 60 + # Blocks land every ~10 minutes, and Counterparty's /fairminters list has no # server-side filter beyond status — every tick re-lists the chain's ENTIRE # fairminter universe (not just xcp.fun's own tracked set), so this job's diff --git a/tests/node-gateway.test.ts b/tests/node-gateway.test.ts index 683924b..7f4ef14 100644 --- a/tests/node-gateway.test.ts +++ b/tests/node-gateway.test.ts @@ -15,25 +15,47 @@ afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals( async function fixture() { const fetch = vi.fn(async () => new Response('{"result":[]}', { headers: { "content-type": "application/json" } })); vi.stubGlobal("fetch", fetch); + const entries = new Map(); + const cache = { + match: vi.fn(async (request: Request) => entries.get(request.url)?.clone()), + put: vi.fn(async (request: Request, response: Response) => { entries.set(request.url, response.clone()); }), + } as unknown as Cache; + vi.stubGlobal("caches", { default: cache }); + const limiter = { limit: vi.fn(async () => ({ success: true })) } as unknown as RateLimit; + const pending: Promise[] = []; + const executionCtx = { + waitUntil(promise: Promise) { pending.push(promise); }, + passThroughOnException() {}, + } as ExecutionContext; const { nodeRoute } = await import("#api/read/node"); - const request = (path: string, init?: RequestInit) => nodeRoute.request(`https://api.xcp.fun/node${path}`, init); - return { fetch, request }; + const request = (path: string, init?: RequestInit) => nodeRoute.request( + `https://api.xcp.fun/node${path}`, + init, + { NODE_PUBLIC_RATE_LIMITER: limiter } as never, + executionCtx, + ); + const flush = () => Promise.all(pending.splice(0)); + return { cache, fetch, flush, limiter, request }; } describe("first-party read-only protocol fallback", () => { - it("preserves raw integer digits, next_cursor and query encoding without touching a cache or forwarding credentials", async () => { + it("preserves raw integer digits, next_cursor and query encoding without forwarding credentials", async () => { const { fetch, request } = await fixture(); const raw = '{"result":[{"quantity":10000000000000001}],"next_cursor":"a+b/c=="}'; fetch.mockResolvedValue(new Response(raw, { headers: { "content-type": "application/json", "cache-control": "public,max-age=300", "set-cookie": "upstream-session=secret", } })); const path = `/v2/addresses/${ADDRESS}/balances?type=address&limit=1000&cursor=a%2Bb%2Fc%3D%3D`; - const response = await request(path, { headers: { authorization: "Bearer secret", cookie: "session=private", "x-admin-token": "secret" } }); + const response = await request(path, { headers: { + origin: "https://xcp.fun", authorization: "Bearer secret", cookie: "session=private", "x-admin-token": "secret", + } }); expect(response.status).toBe(200); expect(await response.text()).toBe(raw); - expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("cache-control")).toBe("public, max-age=300"); + expect(response.headers.get("cloudflare-cdn-cache-control")).toBe("no-store"); expect(response.headers.get("set-cookie")).toBeNull(); - expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("access-control-allow-origin")).toBe("https://xcp.fun"); + expect(response.headers.get("x-xcp-proxy-cache")).toBe("MISS"); expect(fetch).toHaveBeenCalledExactlyOnceWith(`${CP}${path.slice(3)}`, expect.objectContaining({ method: "GET", cache: "no-store", redirect: "manual", headers: { accept: "application/json" }, signal: expect.any(AbortSignal), })); @@ -123,7 +145,7 @@ describe("first-party read-only protocol fallback", () => { const { fetch, request } = await fixture(); const response = await request(path, { method }); expect(response.status).toBe(expected); - expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); expect(response.headers.get("cache-control")).toBe("no-store"); expect(fetch).not.toHaveBeenCalled(); }); @@ -137,13 +159,57 @@ describe("first-party read-only protocol fallback", () => { it("answers preflight locally with only that operation's method", async () => { const { fetch, request } = await fixture(); - const response = await request("/v2/blocks/last", { method: "OPTIONS" }); + const response = await request("/v2/blocks/last", { method: "OPTIONS", headers: { origin: "https://xcp.fun" } }); expect(response.status).toBe(204); expect(response.headers.get("access-control-allow-methods")).toBe("GET, OPTIONS"); expect(response.headers.get("access-control-expose-headers")).toContain("Retry-After"); + expect(response.headers.get("access-control-allow-origin")).toBe("https://xcp.fun"); expect(fetch).not.toHaveBeenCalled(); }); + it("rejects an outside browser origin before consuming a rate-limit check or upstream request", async () => { + const { fetch, limiter, request } = await fixture(); + const response = await request("/v2/blocks/last", { headers: { origin: "https://outside.example" } }); + expect(response.status).toBe(403); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + expect(limiter.limit).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("caps a caller before either a cache read or an upstream request", async () => { + const { cache, fetch, limiter, request } = await fixture(); + vi.mocked(limiter.limit).mockResolvedValue({ success: false }); + const response = await request("/v2/blocks/last", { headers: { "cf-connecting-ip": "192.0.2.1" } }); + expect(response.status).toBe(429); + expect(response.headers.get("retry-after")).toBe("60"); + expect(limiter.limit).toHaveBeenCalledExactlyOnceWith({ key: "192.0.2.1" }); + expect(cache.match).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("collapses repeated cacheable reads while leaving no-store responses live", async () => { + const { fetch, flush, request } = await fixture(); + fetch + .mockResolvedValueOnce(new Response('{"result":{"block_index":1}}', { + headers: { "cache-control": "public, max-age=60", "content-type": "application/json" }, + })) + .mockResolvedValue(new Response('{"result":[]}', { + headers: { "cache-control": "no-store", "content-type": "application/json" }, + })); + + const first = await request("/v2/blocks/last", { headers: { origin: "https://xcp.fun" } }); + expect(first.headers.get("x-xcp-proxy-cache")).toBe("MISS"); + await flush(); + const second = await request("/v2/blocks/last", { headers: { origin: "https://xcp.fun" } }); + expect(second.headers.get("x-xcp-proxy-cache")).toBe("HIT"); + expect(await second.text()).toBe('{"result":{"block_index":1}}'); + expect(fetch).toHaveBeenCalledTimes(1); + + expect((await request("/v2/mempool/events?limit=1")).headers.get("cache-control")).toBe("no-store"); + expect((await request("/v2/mempool/events?limit=1")).headers.get("x-xcp-proxy-cache")).toBe("MISS"); + expect(fetch).toHaveBeenCalledTimes(3); + }); + it.each([400, 404, 500, 503])("keeps upstream status %d, body and Retry-After visible without retries or negative caching", async status => { const { fetch, request } = await fixture(); fetch.mockImplementation(async () => new Response('{"error":"original upstream failure"}', { status, headers: { "retry-after": "120" } })); @@ -184,6 +250,9 @@ describe("first-party read-only protocol fallback", () => { fetch.mockImplementation(() => new Promise(r => { resolve.push(r); })); const first = request("/v2/blocks/last"); const second = request(`/v2/transactions/${HASH}`); + await Promise.resolve(); // rate-limit binding + await Promise.resolve(); // edge-cache lookup + await Promise.resolve(); // upstream fetch expect(resolve).toHaveLength(2); resolve[0]!(new Response(null, { status: 429, headers: { "retry-after": "120" } })); await first; @@ -216,7 +285,7 @@ describe("first-party read-only protocol fallback", () => { upstreamSignal.addEventListener("abort", () => reject(upstreamSignal!.reason), { once: true }); })); const pending = request("/v2/blocks/last", { signal: caller.signal }); - expect(fetch).toHaveBeenCalledOnce(); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce()); if (cancelledBy === "caller") caller.abort(); else deadline.abort(new DOMException("Read deadline expired", "TimeoutError")); expect((await pending).status).toBe(504);