Skip to content

Bound the /api/prices/[slab] fallback cache - #2470

Open
0x-SquidSol wants to merge 1 commit into
dcccrypto:playgroundfrom
0x-SquidSol:fix/bound-prices-fallback-cache
Open

Bound the /api/prices/[slab] fallback cache#2470
0x-SquidSol wants to merge 1 commit into
dcccrypto:playgroundfrom
0x-SquidSol:fix/bound-prices-fallback-cache

Conversation

@0x-SquidSol

@0x-SquidSol 0x-SquidSol commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What

Bound the /api/prices/[slab] in-memory fallback cache so a flood of distinct
slabs can no longer grow it without limit.

Closes #2469.

Why

fallbackCache was a plain module-level Map with no size cap. The GeckoTerminal
fallback path serves any slab and writes an entry on every miss (including null
misses), keyed by the attacker-controllable slab; TTL is checked only on read and
nothing else evicts. GET /api/prices/<random pubkey> in a loop therefore grows
the Map without bound (memory-exhaustion DoS on a warm instance) and amplifies each
miss into an internal /api/markets/<slab> fetch plus a GeckoTerminal fetch. The
sibling /api/chart/[mint] route already caps its GeckoTerminal caches with
boundedSet; this route was missed.

Changes

  • import boundedSet; add FALLBACK_CACHE_MAX_ENTRIES = 10_000
  • route all five fallbackCache writes (Pyth + GeckoTerminal paths) through
    boundedSet
  • add a regression test asserting the cap holds and evicts oldest-first under a
    flood of distinct slabs, where a raw Map.set does not

Cached values and TTL behavior are unchanged for realistic loads (well under the
10k cap of distinct slabs).

Testing

  • npx tsc --noEmit — clean.
  • New regression test — 3/3 pass.
  • No other test exercises this route; boundedSet has its own existing test
    coverage (__tests__/lib/bounded-map.test.ts).

Notes

  • Frontend + devnet scope only; no program, keeper, or mainnet changes.

Summary by CodeRabbit

  • Bug Fixes
    • Improved price data caching to prevent unbounded cache growth.
    • Added a 10,000-entry limit with automatic removal of the oldest entries.
    • Ensured recent price results remain available while limiting memory usage.

The route's module-level `fallbackCache` was a plain Map with no size cap. The
GeckoTerminal fallback path serves any slab and writes an entry on every miss
(including null misses), keyed by the attacker-controllable slab; TTL is checked
only on read and nothing else evicts. A flood of distinct slabs
(GET /api/prices/<random pubkey>) therefore grew the Map without bound
(memory-exhaustion DoS on a warm instance), while also amplifying each miss into
an internal /api/markets fetch and a GeckoTerminal fetch.

Cap every write with boundedSet(..., FALLBACK_CACHE_MAX_ENTRIES = 10_000) — the
same LRU bound the sibling /api/chart route already uses for its GeckoTerminal
caches. Cached values and TTL behavior are unchanged for realistic loads.

- import boundedSet; add FALLBACK_CACHE_MAX_ENTRIES
- route all fallbackCache writes (Pyth + GeckoTerminal paths) through boundedSet
- add a regression test asserting the cap holds and evicts oldest-first under a
  flood of distinct slabs, where a raw Map.set does not

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@0x-SquidSol
0x-SquidSol requested a review from dcccrypto as a code owner August 4, 2026 21:24
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

@0x-SquidSol is attempting to deploy a commit to the Khubair Nasir's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The prices route now limits its fallback cache to 10,000 entries. Pyth and GeckoTerminal writes use boundedSet, including successful, invalid, failed, and null results. Tests verify the cap and oldest-entry eviction.

Changes

Prices cache bounding

Layer / File(s) Summary
Bound fallback cache writes
app/app/api/prices/[slab]/route.ts
The route defines a 10,000-entry fallback cache limit and uses boundedSet for all Pyth and GeckoTerminal cache writes.
Validate cache capacity and eviction
app/__tests__/api/prices-cache-bounded.test.ts
Tests verify that raw Map growth is unbounded while boundedSet enforces the cap and evicts the oldest entries.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: dcccrypto

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: bounding the fallback cache for the prices API route.
Linked Issues check ✅ Passed The changes address issue #2469 by bounding all fallback-cache writes at 10,000 entries with oldest-first eviction and preserving cache behavior.
Out of Scope Changes check ✅ Passed The implementation and regression tests are directly related to the linked issue and PR objective, with no unrelated changes identified.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/__tests__/api/prices-cache-bounded.test.ts (1)

30-40: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Test the route cache contract.

These tests call boundedSet directly. They pass if a route cache write changes back to fallbackCache.set.

Exercise the Pyth and GeckoTerminal fallback paths through the route, or extract a testable cache adapter. Assert that all write outcomes retain the 10,000-entry limit. Also assert that gt:slab_0 through gt:slab_4 are absent and that gt:slab_5 through the newest key remain present.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/__tests__/api/prices-cache-bounded.test.ts` around lines 30 - 40, The
tests currently validate boundedSet directly rather than the route cache
contract. Exercise the Pyth and GeckoTerminal fallback paths through the route,
or expose a testable cache adapter, then assert every write path enforces the
10,000-entry limit; verify gt:slab_0 through gt:slab_4 are absent and gt:slab_5
through the newest key remain present.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@app/__tests__/api/prices-cache-bounded.test.ts`:
- Around line 30-40: The tests currently validate boundedSet directly rather
than the route cache contract. Exercise the Pyth and GeckoTerminal fallback
paths through the route, or expose a testable cache adapter, then assert every
write path enforces the 10,000-entry limit; verify gt:slab_0 through gt:slab_4
are absent and gt:slab_5 through the newest key remain present.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3cac532e-fbe9-4280-b812-684384ef26a9

📥 Commits

Reviewing files that changed from the base of the PR and between f2a3bbe and ef61e03.

📒 Files selected for processing (2)
  • app/__tests__/api/prices-cache-bounded.test.ts
  • app/app/api/prices/[slab]/route.ts

@dcccrypto dcccrypto left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed on the PR head (ef61e030). The fix is correct; same test-binding gap
as #2467, plus the new test duplicates coverage that already exists.

Verified locally

Merge Gate green is not evidence on a fork PR — Unit Tests, Integration Tests,
Security Tests and Coverage Gate all report SKIPPED while the gate reports
SUCCESS (#2447). Ran it directly:

cd app && npx vitest run
Test Files  283 passed | 1 skipped (284)
     Tests  2922 passed | 16 skipped (2938)

The fix itself checks out: all five fallbackCache write sites are converted
(both the pythStatsFallback null/parse/finite branches and the GeckoTerminal
setCache), TTL and cached values are unchanged, and 10 000 matches the sibling
chart route's cap.

The three red Vercel checks are the fork-authorization prompt again
(target_url is vercel.com/git/authorize), not a build failure.

The test does not bind the fix — and it is already covered elsewhere

Two separate problems with prices-cache-bounded.test.ts:

1. It tests boundedSet, which __tests__/lib/bounded-map.test.ts already
tests — more thoroughly.
That existing file asserts the cap holds over 10 000
inserts, oldest-first FIFO eviction, and two cases this PR's test doesn't:
hot-key refresh (re-writing a key moves it to newest, so it survives eviction)
and update-does-not-grow. The new file's only addition is a new Map() control
demonstrating that an unbounded Map is unbounded.

2. It never imports the route, so it cannot fail if the wiring is removed.
I reverted all five call sites back to fallbackCache.set(...) — GH#2469 fully
restored — and:

prices-cache-bounded.test.ts              3 passed   <- vulnerability restored, still green
prices-cache-bounded-route-enforcement    3 failed   <- catches it

Drop-in below. It drives the real GET handler with every upstream stubbed to
502, which is the attacker-cheap null-miss write path the issue describes, and
asserts the route's own cache object is written through a finite bound. 3/3 with
the fix, 3/3 under the revert.

One gotcha worth keeping in the comments: fallbackCache is module-level with a
60s TTL, so a slab used by an earlier test is a cache hit in a later one and
performs no write at all. Each test therefore needs its own slabs — reusing one
yields zero boundedSet calls and a spy assertion that looks fine but checks
nothing. That bit me while writing this.

app/__tests__/api/prices-cache-bounded-route-enforcement.test.ts
/**
 * Regression: the /api/prices/[slab] fallback cache must be bounded BY THE ROUTE.
 *
 * prices-cache-bounded.test.ts asserts that `boundedSet` caps a Map — but that is
 * already covered by __tests__/lib/bounded-map.test.ts, and neither test imports
 * the route. Reverting every `boundedSet(fallbackCache, ...)` back to
 * `fallbackCache.set(...)` — i.e. fully restoring GH#2469 — leaves both green.
 *
 * This test drives the real GET handler and asserts the route's own cache write
 * goes through the bound, with a finite cap. It fails if the wiring is reverted,
 * which is the binding the library-level tests cannot provide.
 */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";

// importActual bypasses the mock below — a plain import would resolve to the
// spy and recurse infinitely.
const { boundedSet: realBoundedSet } =
  await vi.importActual<typeof import("@/lib/bounded-map")>("@/lib/bounded-map");

// Spy that still performs the real bounding, so behaviour is unchanged.
const boundedSetSpy = vi.fn(realBoundedSet);
vi.mock("@/lib/bounded-map", () => ({
  boundedSet: (...args: Parameters<typeof realBoundedSet>) => boundedSetSpy(...args),
}));

vi.mock("@sentry/nextjs", () => ({ captureException: vi.fn(), captureMessage: vi.fn() }));

const { GET } = await import("@/app/api/prices/[slab]/route");

// Valid base58 pubkeys that are NOT in PLAYGROUND_SLAB_META, so the request
// reaches the GeckoTerminal fallback — the path that serves ANY slab.
//
// NOTE: `fallbackCache` is module-level and its TTL is 60s, so a slab used by an
// earlier test is a cache HIT in a later one and performs no write at all. Every
// test below therefore uses its own slabs; reusing one silently produces zero
// boundedSet calls and a confusingly "passing-looking" spy.
const SLAB_A = "DJ54k4wH92NTtNP8RuHAwG8si1bevXEknzctDdqYN8eC";
const SLAB_B = "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo";
const SLABS_C = [
  "pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA",
  "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
  "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
];

const get = (slab: string) =>
  GET(new NextRequest(`http://localhost/api/prices/${slab}`), {
    params: Promise.resolve({ slab }),
  });

describe("prices/[slab] routes its cache writes through the bound", () => {
  beforeEach(() => {
    boundedSetSpy.mockClear();
    // Every upstream fails → the fallback caches a null miss, which is exactly
    // the attacker-cheap write path described in GH#2469.
    vi.stubGlobal(
      "fetch",
      vi.fn(async () => new Response("nope", { status: 502 })),
    );
  });

  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it("writes the fallback cache via boundedSet, never a raw Map.set", async () => {
    const res = await get(SLAB_A);
    expect(res.status).toBe(200);
    expect(boundedSetSpy).toHaveBeenCalled();
  });

  it("passes a finite cap, and the cap bounds the route's own cache object", async () => {
    await get(SLAB_B);
    const [map, , , maxEntries] = boundedSetSpy.mock.calls.at(-1)!;

    expect(Number.isFinite(maxEntries)).toBe(true);
    expect(maxEntries).toBeGreaterThan(0);

    // Flood the ROUTE'S OWN cache object (captured from the call) past the cap
    // it was given. An unbounded cache would grow 1:1 with attacker slabs.
    const before = (map as Map<string, unknown>).size;
    for (let i = 0; i < (maxEntries as number) + 500; i++) {
      realBoundedSet(map as Map<string, unknown>, `flood:${i}`, { v: i }, maxEntries as number);
    }
    expect((map as Map<string, unknown>).size).toBeLessThanOrEqual(maxEntries as number);
    expect((map as Map<string, unknown>).size).toBeGreaterThanOrEqual(
      Math.min(before, maxEntries as number),
    );
  });

  it("caches distinct slabs under distinct keys (no accidental key collapse)", async () => {
    for (const s of SLABS_C) await get(s);
    const keys = boundedSetSpy.mock.calls.map((c) => c[1]);
    expect(new Set(keys).size).toBe(SLABS_C.length);
  });
});

Happy to push it here or send it as a follow-up — your call.

While verifying, I swept the rest of the routes

Every module-level Map under app/api, checked for a cap or prune. Seven more
are unbounded
, same class as this one — all get/set only, no delete, no
prune, no cap:

Route Map Key
playground/faucet claimStore wallet
auto-fund _autoFundClaims wallet
faucet _faucetClaims wallet:type
devnet-airdrop _airdropClaims wallet:mint
devnet-register-mint requestCounts client IP
markets/[slab]/logo uploadTimestamps slab
tokens/[mint]/logo uploadTimestamps mint

Calibrating honestly, these are materially weaker than #2469 and I would not
call any of them a DoS on today's evidence:

  • Entries are string → number, not cached objects, so per-key cost is tiny.
  • Writes are gated behind expensive work — an airdrop/claim transaction, or a
    successful image upload — not a bare GET.
  • Four of them (faucet, auto-fund, devnet-airdrop, and the mirror-mint
    fallback) only populate when Supabase is unavailable, per their own comments.
  • devnet-register-mint keys on getClientIp, which peels trusted hops from the
    right, so the key is the real client IP and not spoofable under correct
    TRUSTED_PROXY_DEPTH.

So: same class, much lower severity, one-line fix each with the helper that now
exists. Worth closing as a batch rather than one PR per route. I'll file an
inventory issue so it does not get rediscovered a route at a time.

For completeness, these were checked and are bounded: rpc (MAX_CACHE_SIZE
500 + inflightRequests.delete on settle), oracle/resolve/[ca] (256 / 64),
devnet-mirror-mint (prunes expired on each request), chart/[mint]
(boundedSet), and markets' MINT_TO_KNOWN_SYMBOL (a static constant).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants