Skip to content

[HIGH] Unbounded Oracle API Caches Allow Persistent Memory-Exhaustion DoS #2416

Description

@Bayyan16

Summary

Two unauthenticated oracle API routes use module-level JavaScript Map objects as TTL caches without enforcing a maximum cardinality, actively deleting expired entries, or applying an eviction policy.

The primary affected route is:

app/app/api/oracle/publishers/route.ts

The route derives its cache key from request-controlled query parameters:

const cacheKey = `${mode}:${feedId || authority || ""}`;

For mode=admin, an unauthenticated requester can supply arbitrary unique authority values:

case "admin":
  result = getAdminPublishers(authority);
  break;

Every successful result is then written to a module-level cache:

cache.set(cacheKey, {
  data: result,
  timestamp: Date.now(),
});

The configured five-minute TTL is used only as a freshness check:

const cached = cache.get(cacheKey);

if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
  return NextResponse.json(cached.data);
}

When an entry becomes stale, it is ignored but never removed from the Map.

There is currently no:

  • Maximum cache cardinality.
  • LRU or FIFO eviction policy.
  • Periodic expired-entry sweep.
  • Stale-entry deletion during cache lookup.
  • Explicit maximum authority length.
  • Canonical Solana public-key validation for authority.
  • Cache bypass for the inexpensive local admin mode.

As a result, unique attacker-controlled cache keys and cached response objects remain strongly referenced by the process after their logical TTL expires. These values cannot be reclaimed by JavaScript garbage collection while the module-level Map remains alive.

A similar unbounded cache pattern also exists in:

app/app/api/oracle/resolve/[ca]/route.ts

The resolver route additionally performs parallel Jupiter and DexScreener requests for unique cache misses, creating a secondary upstream request-amplification condition.


Severity

High — deployment-dependent

Suggested CVSS 3.1 score:

7.5 — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Relevant weakness classifications:

  • CWE-400: Uncontrolled Resource Consumption.
  • CWE-770: Allocation of Resources Without Limits or Throttling.

The demonstrated security impact is availability degradation or denial of service through persistent process-memory growth.

This report does not claim:

  • Private-key disclosure.
  • Unauthorized transaction signing.
  • Theft of user funds.
  • Unauthorized modification of on-chain state.
  • Confidentiality impact.
  • Integrity impact.

The operational impact depends on the hosting model. Frequently recycled serverless instances may reduce the retention window. Warm serverless instances and long-lived Node.js processes can retain attacker-controlled entries for the lifetime of the process.


Affected Revision

Repository:

dcccrypto/percolator-launch

Branch:

playground

Tested commit:

bb0f7674ee0754b56fc7c5303d97a33173bf8649

Commit subject:

refactor(launch): single shared builder for the registration payload (#2387 fast-follow) (#2399)

The local validation branch was created directly from the tested upstream/playground revision.

No production source files were modified during PoC validation.


Affected Components

Primary affected route

app/app/api/oracle/publishers/route.ts

Secondary affected route

app/app/api/oracle/resolve/[ca]/route.ts

Root Cause Analysis

1. Unbounded module-level cache

The publishers route creates a module-level Map:

const CACHE_TTL_MS = 5 * 60 * 1000;

interface CacheEntry {
  data: PublishersResponse;
  timestamp: number;
}

const cache = new Map<string, CacheEntry>();

Because this cache is defined at module scope, its contents remain available across requests for the lifetime of the Node.js process or warm serverless instance.

No maximum number of entries is enforced.


2. Request-controlled cache cardinality

The cache key includes query parameters controlled by the requester:

const mode = searchParams.get("mode");
const feedId = searchParams.get("feedId");
const authority = searchParams.get("authority");

const cacheKey = `${mode}:${feedId || authority || ""}`;

For mode=admin, each unique authority creates a distinct key:

admin:<attacker-controlled-value>

An attacker can therefore increase cache cardinality by sending requests containing previously unused authority values.


3. Admin mode writes to the cache without external dependencies

The admin-mode response is generated locally:

case "admin":
  result = getAdminPublishers(authority);
  break;

The result is subsequently cached:

cache.set(cacheKey, {
  data: result,
  timestamp: Date.now(),
});

This allocation path does not require:

  • Solana RPC.
  • Pyth.
  • Jupiter.
  • DexScreener.
  • Helius.
  • Any other external HTTP request.

The PoC installs a failing fetch spy and confirms that cache allocation succeeds without invoking fetch.


4. Expired entries are ignored but never deleted

The cache freshness check is:

const cached = cache.get(cacheKey);

if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
  return NextResponse.json(cached.data);
}

When the TTL condition evaluates to false, execution continues without removing the stale entry.

The route does not call:

cache.delete(cacheKey);

Therefore, the TTL controls whether cached data is returned, but it does not control how long the underlying key and value remain allocated.


5. Attacker-controlled data is retained in both the key and cached value

The admin publisher helper stores the supplied authority inside the response:

publishers: [
  {
    key: authority,
    name: `Authority ${authority.slice(0, 4)}${authority.slice(-4)}`,
    status: "active",
  },
],

The supplied value is therefore retained:

  1. Inside the cache key.
  2. Inside the cached response object.

The route does not enforce an explicit maximum string length before retaining the value.

It also does not require the value to be a canonical Solana public key before caching it.


Secondary Resolver Cache

The resolver route uses a similar module-level cache:

const cache = new Map<string, CacheEntry>();
const CACHE_TTL_MS = 5 * 60 * 1000;

An entry is returned only while it remains fresh:

const cached = cache.get(ca);

if (cached && Date.now() < cached.expiresAt) {
  return NextResponse.json({
    ...cached.data,
    cached: true,
  });
}

When the entry expires, it is ignored but not deleted.

Successful unique resolutions are cached without a maximum cardinality:

cache.set(ca, {
  data: result,
  expiresAt: Date.now() + CACHE_TTL_MS,
});

Unique cache misses also initiate parallel upstream requests:

const [jupResult, dexResult] = await Promise.all([
  fetchJupiterPrice(ca),
  fetchDexScreenerInfo(ca),
]);

This creates two related resource-consumption risks:

  1. Persistent growth of the module-level cache.
  2. Upstream request amplification for unique resolver inputs.

Attack Scenario

  1. An unauthenticated requester sends:

    /api/oracle/publishers?mode=admin&authority=<unique-value>
    
  2. The unique authority produces a unique cache key.

  3. The admin-mode response is generated locally.

  4. The result is inserted into the module-level Map.

  5. The requester repeats the operation with different authority values.

  6. After five minutes, previous entries become logically stale.

  7. The stale entries remain physically stored because no deletion or eviction occurs.

  8. Subsequent unique requests continue increasing cache cardinality.

  9. The Map retains strong references to all stored keys and response objects.

  10. JavaScript garbage collection cannot reclaim those objects while they remain referenced.

  11. On a sufficiently long-lived instance, sustained accumulation may cause:

    • Increased Node.js heap utilization.
    • Increased garbage-collection pressure.
    • Higher API latency.
    • Memory-limit exhaustion.
    • Process termination.
    • Repeated serverless instance restarts.
    • HTTP 5xx responses.
    • Partial or complete API unavailability.

Why the Existing TTL Is Insufficient

The current TTL is only a freshness check.

It prevents stale data from being returned, but it does not:

  • Remove the expired key.
  • Remove the expired response object.
  • Reduce cache cardinality.
  • Release retained memory.
  • Restrict the number of unique entries.
  • Prevent persistent growth across requests.

A TTL cache without active deletion or a hard size limit can still grow indefinitely.


Existing Rate Limiting Does Not Remove the Root Cause

The repository applies general API rate limiting, which is useful defense-in-depth.

However, rate limiting does not:

  • Enforce maximum cache cardinality.
  • Delete expired entries.
  • Prevent gradual accumulation over time.
  • Prevent distributed-source accumulation.
  • Enforce a maximum retained input length.
  • Prevent one unique allocation per permitted request.

The cache must independently enforce a hard resource limit.


Safe Proof of Concept

The PoC is implemented as a deterministic Vitest unit test:

app/__tests__/api/oracle-publishers-cache-security.test.ts

The test runs entirely against the locally imported Next.js route handler.

It does not:

  • Send traffic to a deployed Percolator service.
  • Contact Pyth.
  • Contact Jupiter.
  • Contact DexScreener.
  • Contact Helius.
  • Contact Solana RPC.
  • Submit blockchain transactions.
  • Access user data.
  • Perform live load testing.
  • Attempt to crash a server.
  • Modify production source code.

PoC Methodology

The test performs the following operations:

  1. Enables Vitest fake timers.
  2. Freezes the system clock at a deterministic timestamp.
  3. Stores a reference to the native JavaScript Map.
  4. Temporarily replaces the global Map constructor during route-module initialization.
  5. Captures the route's private module-level cache without editing production code.
  6. Restores the native global Map before requests are created.
  7. Installs a failing fetch spy.
  8. Sends 128 unique mode=admin requests directly to the local route handler.
  9. Uses unique 2,048-character authority values.
  10. Confirms that every request returns HTTP 200.
  11. Confirms that the response retains the complete supplied authority.
  12. Identifies the publishers cache through keys beginning with admin:.
  13. Confirms that the cache contains 128 entries.
  14. Advances the mocked clock beyond the configured five-minute TTL.
  15. Sends one additional unique request.
  16. Confirms that the cache grows from 128 entries to 129.
  17. Confirms that the oldest expired entry remains present.
  18. Confirms that no external fetch request was performed.

Complete PoC Test

/**
 * SECURITY PoC — Oracle publisher cache retains attacker-controlled entries
 *
 * Safe scope:
 * - Runs entirely in Vitest against the local route handler.
 * - Does not send traffic to any public deployment.
 * - Does not call Pyth, Jupiter, DexScreener, Helius, or Solana RPC.
 *
 * Vulnerable behavior demonstrated:
 * - Unique mode=admin authority values create unique cache entries.
 * - Entries remain strongly referenced after the documented 5-minute TTL.
 * - Cache cardinality continues growing without a maximum bound.
 */

import { afterEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";

type CapturedMap = Map<unknown, unknown>;

function makeRequest(authority: string): NextRequest {
  const url = new URL("http://localhost:3000/api/oracle/publishers");

  url.searchParams.set("mode", "admin");
  url.searchParams.set("authority", authority);

  return new NextRequest(url);
}

function uniqueAuthority(index: number, length = 2048): string {
  const suffix = index.toString(36).padStart(16, "0");

  return `${"A".repeat(Math.max(1, length - suffix.length))}${suffix}`;
}

describe("SECURITY: /api/oracle/publishers unbounded cache retention", () => {
  afterEach(() => {
    vi.useRealTimers();
    vi.unstubAllGlobals();
    vi.restoreAllMocks();
    vi.resetModules();
  });

  it("retains every unique attacker-controlled authority after TTL expiry", async () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-07-14T00:00:00.000Z"));
    vi.resetModules();

    const NativeMap = globalThis.Map;
    const capturedMaps: CapturedMap[] = [];

    class CapturingMap<K, V> extends NativeMap<K, V> {
      constructor(entries?: readonly (readonly [K, V])[] | null) {
        super(entries as Iterable<readonly [K, V]> | undefined);

        capturedMaps.push(this as CapturedMap);
      }
    }

    /*
     * Capture the route's private module-level Map during module loading.
     * Production source code is not modified.
     */
    vi.stubGlobal("Map", CapturingMap);

    const { GET } = await import("@/app/api/oracle/publishers/route");

    /*
     * Restore the native Map before creating requests.
     * The route cache remains the captured CapturingMap instance.
     */
    vi.unstubAllGlobals();

    /*
     * Prove that the admin-mode allocation path does not require an
     * external HTTP request.
     */
    const fetchSpy = vi.fn(() => {
      throw new Error("Unexpected external network request");
    });

    vi.stubGlobal("fetch", fetchSpy);

    const entryCount = 128;

    for (let index = 0; index < entryCount; index += 1) {
      const authority = uniqueAuthority(index);
      const response = await GET(makeRequest(authority));

      expect(response.status).toBe(200);

      const body = await response.json();

      expect(body.mode).toBe("admin");
      expect(body.publishers[0].key).toBe(authority);
    }

    const oracleCache = capturedMaps.find((candidate) =>
      [...candidate.keys()].some(
        (key) => typeof key === "string" && key.startsWith("admin:"),
      ),
    );

    expect(
      oracleCache,
      "module-level oracle publishers cache was not captured",
    ).toBeDefined();

    expect(oracleCache!.size).toBe(entryCount);

    /*
     * Move beyond CACHE_TTL_MS.
     *
     * The vulnerable implementation rejects stale cache hits,
     * but does not delete stale entries or enforce maximum cardinality.
     */
    vi.advanceTimersByTime(5 * 60 * 1000 + 1);

    const finalAuthority = uniqueAuthority(entryCount);
    const finalResponse = await GET(makeRequest(finalAuthority));

    expect(finalResponse.status).toBe(200);
    expect(oracleCache!.size).toBe(entryCount + 1);
    expect(fetchSpy).not.toHaveBeenCalled();

    /*
     * Demonstrate that the oldest expired attacker-controlled entry
     * remains strongly referenced after TTL expiry.
     */
    expect(oracleCache!.has(`admin:${uniqueAuthority(0)}`)).toBe(true);
  });
});

Reproduction Steps

1. Fetch the latest tested branch state

git fetch upstream playground --prune

2. Check out the tested revision

git switch --detach bb0f7674ee0754b56fc7c5303d97a33173bf8649

3. Place the PoC at

app/__tests__/api/oracle-publishers-cache-security.test.ts

4. Install dependencies when necessary

pnpm --dir app install

5. Run only the targeted PoC

CI=1 pnpm --dir app exec vitest run \
  __tests__/api/oracle-publishers-cache-security.test.ts \
  --reporter=verbose

Observed Result

RUN  v4.0.18 C:/Users/PC/percolator-launch/app

✓ __tests__/api/oracle-publishers-cache-security.test.ts
  > SECURITY: /api/oracle/publishers unbounded cache retention
  > retains every unique attacker-controlled authority after TTL expiry

Test Files  1 passed (1)
Tests       1 passed (1)

Process exit code:

0

The passing test confirms that:

  • Unique admin authorities create unique cache entries.
  • 128 unique inputs result in 128 retained entries.
  • Advancing time beyond the five-minute TTL does not remove old entries.
  • One additional unique request increases cache size to 129.
  • The oldest expired entry remains strongly referenced.
  • No external HTTP request is required for the allocation path.
  • Production source code does not need to be modified to reproduce the issue.

PoC Environment

Operating environment: Windows / Git Bash
Node.js: v26.3.0
pnpm: 10.33.3
Vitest: 4.0.18

Production source status during validation:

UNCHANGED

Evidence Integrity

PoC SHA-256:

3beadf97f622be1b314cfaaef8655adaf22f5e56e1f672c1e7c860cfa821bd81

Evidence archive SHA-256:

8574cdba5907bb9dc3128f8d33fdf44a928333aa475fdfa545e973439f8a3b67

Evidence files:

oracle-cache-security-evidence.tar.gz
oracle-publishers-cache-security.test.ts
oracle-cache-poc-hardened-output.txt
oracle-cache-poc-metadata.txt

Recommended Remediation

1. Do not cache admin-mode responses

Admin-mode publisher data is generated locally and is inexpensive to calculate.

The safest approach is to return it before entering the shared cache-write path:

case "admin": {
  const result = getAdminPublishers(validatedAuthority);

  return NextResponse.json(result, {
    headers: {
      "Cache-Control": "no-store",
    },
  });
}

Alternatively, explicitly exclude admin mode from cache insertion:

if (mode !== "admin") {
  cache.set(cacheKey, {
    data: result,
    timestamp: Date.now(),
  });
}

2. Strictly validate the authority parameter

Before processing mode=admin:

  • Require authority to be present.
  • Enforce the expected Solana address length.
  • Reject oversized values before constructing the cache key.
  • Validate base58 encoding.
  • Parse it using PublicKey.
  • Require canonical serialization.
  • Return HTTP 400 for invalid values.

Example:

let validatedAuthority: string;

try {
  if (!authority) {
    throw new Error("Missing authority");
  }

  const publicKey = new PublicKey(authority);
  validatedAuthority = publicKey.toBase58();

  if (validatedAuthority !== authority) {
    throw new Error("Non-canonical authority");
  }
} catch {
  return NextResponse.json(
    { error: "Invalid authority" },
    { status: 400 },
  );
}

3. Introduce a bounded cache abstraction

Replace the raw module-level Map with a bounded TTL/LRU cache.

The implementation should enforce:

  • A fixed maximum number of entries.
  • Active expiration.
  • LRU or FIFO eviction.
  • Removal of stale entries.
  • Restricted key length.
  • Optional cache-size metrics.

A conservative maximum such as 256 or 512 entries may be appropriate, depending on expected production usage.


4. Delete stale entries during lookup

const cached = cache.get(cacheKey);

if (cached) {
  if (Date.now() - cached.timestamp < CACHE_TTL_MS) {
    return NextResponse.json(cached.data);
  }

  cache.delete(cacheKey);
}

This prevents a repeatedly accessed expired key from remaining retained.

Read-time deletion alone is not sufficient because attacker-generated keys may never be requested again. A hard maximum capacity remains required.


5. Sweep expired entries before insertion

const now = Date.now();

for (const [key, entry] of cache) {
  if (now - entry.timestamp >= CACHE_TTL_MS) {
    cache.delete(key);
  }
}

This should be used together with a hard cardinality limit.


6. Enforce maximum cardinality before cache insertion

Example FIFO-style protection:

const MAX_CACHE_ENTRIES = 256;

while (cache.size >= MAX_CACHE_ENTRIES) {
  const oldestKey = cache.keys().next().value;

  if (oldestKey === undefined) {
    break;
  }

  cache.delete(oldestKey);
}

cache.set(cacheKey, {
  data: result,
  timestamp: Date.now(),
});

A true LRU implementation would provide better eviction behavior, but any deterministic hard limit is safer than the current unbounded cache.


7. Apply equivalent protections to the resolver route

app/app/api/oracle/resolve/[ca]/route.ts should receive:

  • Strict path-parameter validation.
  • Maximum parameter length.
  • Hard cache-cardinality limit.
  • Active expired-entry cleanup.
  • LRU or FIFO eviction.
  • In-flight request deduplication.
  • Bounded negative caching.
  • Route-specific rate limiting where appropriate.
  • Upstream request concurrency limits.

8. Deduplicate concurrent resolver requests

Concurrent requests for the same unresolved ca should reuse one in-flight promise instead of issuing duplicate Jupiter and DexScreener operations.

Conceptually:

const inFlight = new Map<string, Promise<OracleResolveResult>>();

if (inFlight.has(ca)) {
  return inFlight.get(ca);
}

const operation = resolveOracle(ca).finally(() => {
  inFlight.delete(ca);
});

inFlight.set(ca, operation);

return operation;

The in-flight map must also be bounded and cleaned reliably.


Recommended Regression Tests

Add tests verifying that:

  • Missing admin authority returns HTTP 400.
  • Invalid base58 authority returns HTTP 400.
  • Oversized authority returns HTTP 400.
  • Non-canonical authority returns HTTP 400.
  • Admin-mode responses are not cached.
  • Expired entries are actively removed.
  • Cache cardinality never exceeds the configured maximum.
  • Insertion beyond capacity evicts the expected entry.
  • The oldest expired entry is no longer retained.
  • Resolver cache remains within its configured maximum.
  • Concurrent identical resolver requests share one upstream operation.
  • Unique resolver inputs cannot create unbounded upstream concurrency.
  • Existing valid publisher and resolver responses remain backward-compatible.

Non-Duplication Review

I reviewed open and closed repository issues using terms including:

oracle cache
publishers cache
memory exhaustion
unbounded cache
oracle publishers
resource exhaustion
cache DoS

I did not identify an existing issue specifically describing persistent unbounded cache cardinality in these oracle routes.

The closest previously discussed availability issue involved large getProgramAccounts responses through the RPC proxy.

That issue has a different:

  • Endpoint.
  • Input path.
  • Root cause.
  • Resource-consumption mechanism.
  • Persistence behavior.
  • Upstream dependency.
  • Required remediation.

The RPC issue concerns resource consumption caused by one potentially large upstream response.

This report concerns persistent accumulation of independently retained attacker-controlled cache entries across multiple requests.


Testing Safety

No traffic was sent to a public Percolator deployment.

No live denial-of-service testing, stress testing, heap-exhaustion attempt, or destructive operation was performed.

The PoC is a deterministic white-box unit test designed to demonstrate the vulnerable cache state without affecting users or infrastructure.


Suggested Fix Acceptance Criteria

The issue should be considered resolved when all of the following conditions are met:

  • Admin-mode responses no longer create cache entries.
  • Invalid or oversized authorities are rejected before response construction.
  • Both oracle caches enforce a hard maximum cardinality.
  • Expired entries are actively removed.
  • Resolver requests use bounded concurrency and in-flight deduplication.
  • Regression tests confirm that cache size cannot grow beyond its configured maximum.
  • Existing oracle publisher and resolver functionality remains operational.
  • Targeted tests, type checking, linting, and build validation pass.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions