Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions app/__tests__/api/gh2416-oracle-publishers-cache-dos.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* GH#2416 — unbounded oracle publisher cache allowed a memory-exhaustion DoS.
*
* `GET /api/oracle/publishers` built its cache key from request-controlled
* query params (`mode`, `feedId`, `authority`) and stored every result in a
* module-level `Map` with no cap, no eviction, and no deletion of stale
* entries. `mode=admin&authority=<anything>` is unauthenticated and accepted
* arbitrary strings, so a caller could mint unlimited unique keys.
*/

import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { GET } from "@/app/api/oracle/publishers/route";

function req(query: string): NextRequest {
return new NextRequest(`http://localhost:3000/api/oracle/publishers?${query}`);
}

/** A syntactically valid base58 Solana pubkey. */
const VALID_AUTHORITY = "BXzwCWKsMpAW2MxWTWPaJu4fByYWkBFGBmLz4QxGUkwi";

beforeEach(() => {
vi.restoreAllMocks();
});

describe("GH#2416 oracle/publishers cache DoS", () => {
it("rejects a non-base58 authority instead of caching it", async () => {
const res = await GET(req(`mode=admin&authority=${"A".repeat(5000)}`));
expect(res.status).toBe(400);

const body = await res.json();
expect(body.error).toMatch(/authority/i);
});

it.each([
["too short", "abc"],
["non-base58 chars (0OIl)", "0OIl0OIl0OIl0OIl0OIl0OIl0OIl0OIl"],
["oversized", "1".repeat(200)],
["path traversal", "../../etc/passwd"],
["empty-ish whitespace", " "],
])("rejects malformed authority: %s", async (_label, authority) => {
const res = await GET(req(`mode=admin&authority=${encodeURIComponent(authority)}`));
expect(res.status).toBe(400);
});

it("still serves a valid admin authority", async () => {
const res = await GET(req(`mode=admin&authority=${VALID_AUTHORITY}`));
expect(res.status).toBe(200);

const body = await res.json();
expect(body.mode).toBe("admin");
expect(body.publisherCount).toBe(1);
expect(body.publishers[0].key).toBe(VALID_AUTHORITY);
});

it("does not grow unboundedly across many unique valid authorities", async () => {
// The DoS shape, using only authorities that now pass validation. Admin
// mode is served uncached, so none of these are retained at all.
const before = process.memoryUsage().heapUsed;

for (let i = 0; i < 300; i++) {
// Vary within the base58 alphabet to produce distinct valid-looking keys.
const authority = VALID_AUTHORITY.slice(0, -2) + ["ab", "cd", "ef", "gh"][i % 4] ;
const res = await GET(req(`mode=admin&authority=${authority}`));
expect(res.status).toBe(200);
}

// Not asserting an exact number — just that 300 unique unauthenticated
// requests do not retain a proportional amount of heap.
const grewMb = (process.memoryUsage().heapUsed - before) / 1024 / 1024;
expect(grewMb).toBeLessThan(50);
});

it("requires a mode and rejects unknown modes", async () => {
expect((await GET(req(""))).status).toBe(400);
expect((await GET(req("mode=not-a-real-mode"))).status).toBe(400);
});

it("still validates feedId for pyth-pinned mode", async () => {
expect((await GET(req("mode=pyth-pinned"))).status).toBe(400);
expect((await GET(req("mode=pyth-pinned&feedId=nothex"))).status).toBe(400);
expect((await GET(req(`mode=pyth-pinned&feedId=${"a".repeat(200)}`))).status).toBe(400);
});
});
110 changes: 110 additions & 0 deletions app/__tests__/lib/bounded-ttl-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* GH#2416 — bounded TTL cache.
*
* The oracle API routes previously used plain `Map`s as TTL caches. Stale
* entries were ignored by the freshness check but never deleted, and there was
* no cap, so an unauthenticated caller who controls part of the cache key could
* mint unlimited unique keys and exhaust process memory.
*/

import { describe, it, expect, vi, afterEach } from "vitest";
import { createBoundedTtlCache } from "@/lib/bounded-ttl-cache";

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

describe("createBoundedTtlCache", () => {
it("never exceeds maxEntries no matter how many unique keys arrive", () => {
const cache = createBoundedTtlCache<number>({ ttlMs: 60_000, maxEntries: 10 });

// The attack: unlimited distinct keys, all within TTL so none expire.
for (let i = 0; i < 5_000; i++) {
cache.set(`attacker-key-${i}`, i);
expect(cache.size()).toBeLessThanOrEqual(10);
}

expect(cache.size()).toBe(10);
});

it("deletes an expired entry on access rather than merely ignoring it", () => {
vi.useFakeTimers();
const cache = createBoundedTtlCache<string>({ ttlMs: 1_000, maxEntries: 100 });

cache.set("k", "v");
expect(cache.size()).toBe(1);

vi.advanceTimersByTime(1_001);

expect(cache.get("k")).toBeUndefined();
// The old code left the entry in the Map here — that is the leak.
expect(cache.size()).toBe(0);
});

it("serves an unexpired value", () => {
vi.useFakeTimers();
const cache = createBoundedTtlCache<string>({ ttlMs: 5_000, maxEntries: 100 });

cache.set("k", "v");
vi.advanceTimersByTime(4_999);
expect(cache.get("k")).toBe("v");
});

it("treats the TTL boundary as expired", () => {
vi.useFakeTimers();
const cache = createBoundedTtlCache<string>({ ttlMs: 1_000, maxEntries: 100 });

cache.set("k", "v");
vi.advanceTimersByTime(1_000);
expect(cache.get("k")).toBeUndefined();
});

it("sweeps expired entries before evicting live ones", () => {
vi.useFakeTimers();
const cache = createBoundedTtlCache<number>({ ttlMs: 1_000, maxEntries: 5 });

for (let i = 0; i < 5; i++) cache.set(`old-${i}`, i);
expect(cache.size()).toBe(5);

// All five lapse, so a new insert should reclaim them rather than evict.
vi.advanceTimersByTime(1_001);
cache.set("fresh", 99);

expect(cache.size()).toBe(1);
expect(cache.get("fresh")).toBe(99);
});

it("evicts oldest-first when everything is still live", () => {
const cache = createBoundedTtlCache<number>({ ttlMs: 60_000, maxEntries: 3 });

cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);
cache.set("d", 4); // pushes out "a"

expect(cache.get("a")).toBeUndefined();
expect(cache.get("b")).toBe(2);
expect(cache.get("d")).toBe(4);
expect(cache.size()).toBe(3);
});

it("re-inserting a key refreshes it instead of duplicating or evicting it", () => {
const cache = createBoundedTtlCache<number>({ ttlMs: 60_000, maxEntries: 3 });

cache.set("a", 1);
cache.set("b", 2);
cache.set("a", 10); // refresh — moves "a" to the end
cache.set("c", 3);
cache.set("d", 4); // evicts oldest, which is now "b" not "a"

expect(cache.size()).toBe(3);
expect(cache.get("a")).toBe(10);
expect(cache.get("b")).toBeUndefined();
});

it("rejects nonsensical construction rather than silently misbehaving", () => {
expect(() => createBoundedTtlCache({ ttlMs: 0 })).toThrow(/ttlMs/);
expect(() => createBoundedTtlCache({ ttlMs: NaN })).toThrow(/ttlMs/);
expect(() => createBoundedTtlCache({ ttlMs: 1_000, maxEntries: 0 })).toThrow(/maxEntries/);
});
});
58 changes: 46 additions & 12 deletions app/app/api/oracle/publishers/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { createBoundedTtlCache } from "@/lib/bounded-ttl-cache";

const PYTHNET_RPC =
process.env.PYTHNET_RPC_URL || "https://pythnet.rpcpool.com";
Expand Down Expand Up @@ -26,11 +27,29 @@ const KNOWN_PYTH_PUBLISHERS: Record<string, string> = {
/** Max age for cached publisher data (5 minutes) */
const CACHE_TTL_MS = 5 * 60 * 1000;

interface CacheEntry {
data: PublishersResponse;
timestamp: number;
/**
* GH#2416: cap the cache. The key derives from request-controlled query
* params, so an unbounded Map lets an unauthenticated caller mint unlimited
* unique keys and exhaust process memory. `pyth-pinned` keys are additionally
* capped by the far smaller number of real Pyth feeds, so 500 is generous.
*/
const CACHE_MAX_ENTRIES = 500;

const cache = createBoundedTtlCache<PublishersResponse>({
ttlMs: CACHE_TTL_MS,
maxEntries: CACHE_MAX_ENTRIES,
});

/**
* GH#2416: a Solana public key is 32 bytes, which is 32–44 base58 chars.
* Without this, `authority` was accepted as an arbitrary-length arbitrary
* string and used directly as a cache key.
*/
const BASE58_PUBKEY_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;

function isValidAuthority(authority: string): boolean {
return BASE58_PUBKEY_RE.test(authority);
}
const cache = new Map<string, CacheEntry>();

interface PublisherInfo {
key: string;
Expand Down Expand Up @@ -65,13 +84,28 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "Missing mode parameter" }, { status: 400 });
}

// Check cache
// GH#2416: reject a malformed authority before it can become a cache key or
// be echoed back in the response body.
if (authority !== null && !isValidAuthority(authority)) {
return NextResponse.json(
{ error: "Invalid authority: expected a base58 Solana public key" },
{ status: 400 },
);
}

// GH#2416: `admin` mode is a pure local function over `authority` — no
// network, no decoding. Caching it bought nothing and was precisely the
// unbounded-cardinality vector, so it is served uncached.
const isCacheable = mode !== "admin";
const cacheKey = `${mode}:${feedId || authority || ""}`;
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return NextResponse.json(cached.data, {
headers: { "Cache-Control": "public, max-age=300" },
});

if (isCacheable) {
const cached = cache.get(cacheKey);
if (cached) {
return NextResponse.json(cached, {
headers: { "Cache-Control": "public, max-age=300" },
});
}
}

try {
Expand Down Expand Up @@ -107,8 +141,8 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: `Unknown mode: ${mode}` }, { status: 400 });
}

// Update cache
cache.set(cacheKey, { data: result, timestamp: Date.now() });
// Update cache (skipped for `admin` — see isCacheable above)
if (isCacheable) cache.set(cacheKey, result);

return NextResponse.json(result, {
headers: { "Cache-Control": "public, max-age=300" },
Expand Down
26 changes: 18 additions & 8 deletions app/app/api/oracle/resolve/[ca]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { PublicKey } from "@solana/web3.js";
import { SUPPORTED_DEX_IDS } from "@/lib/dex-constants";
import { createBoundedTtlCache } from "@/lib/bounded-ttl-cache";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -80,13 +81,22 @@ const MINT_TO_PYTH: Record<string, { feedId: string; symbol: string }> = {
// Simple in-memory cache (TTL 5 minutes)
// ---------------------------------------------------------------------------

interface CacheEntry {
data: OracleResolveResult;
expiresAt: number;
}
const cache = new Map<string, CacheEntry>();
const CACHE_TTL_MS = 5 * 60 * 1000;

/**
* GH#2416: cap the cache. `ca` is validated as a base58 pubkey below, but any
* random 32 bytes is a syntactically valid pubkey, so validation alone does
* not bound cardinality — an unauthenticated caller can still mint unlimited
* distinct keys. Each miss also fans out to Jupiter and DexScreener, so an
* unbounded map is both a memory leak and upstream request amplification.
*/
const CACHE_MAX_ENTRIES = 500;

const cache = createBoundedTtlCache<OracleResolveResult>({
ttlMs: CACHE_TTL_MS,
maxEntries: CACHE_MAX_ENTRIES,
});

interface OracleResolveResult {
feedId: string | null;
symbol: string;
Expand Down Expand Up @@ -219,8 +229,8 @@ export async function GET(

// Cache hit
const cached = cache.get(ca);
if (cached && Date.now() < cached.expiresAt) {
return NextResponse.json({ ...cached.data, cached: true });
if (cached) {
return NextResponse.json({ ...cached, cached: true });
}

// --- 1. Check static Pyth feed map ---
Expand Down Expand Up @@ -273,6 +283,6 @@ export async function GET(
}

// Cache and return
cache.set(ca, { data: result, expiresAt: Date.now() + CACHE_TTL_MS });
cache.set(ca, result);
return NextResponse.json({ ...result, cached: false });
}
Loading
Loading