diff --git a/app/__tests__/api/rpc-gpa-program-allowlist.test.ts b/app/__tests__/api/rpc-gpa-program-allowlist.test.ts new file mode 100644 index 00000000..f0958915 --- /dev/null +++ b/app/__tests__/api/rpc-gpa-program-allowlist.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +/** + * GH#2204 — `getProgramAccounts` was forwarded without validating the target + * program. + * + * A filter requirement (`dataSize` / `memcmp`) was added first, and it is + * necessary but NOT sufficient: the issue's own example — dumping the SPL Token + * Program — supplies `dataSize: 165` quite happily and still returns every token + * account on the cluster. A filter bounds the result SHAPE, not the program, and + * on a program with millions of matching accounts that is no bound at all. + * + * These tests exercise the request-shape predicate directly. The route holds it + * in a module-private helper, so the predicate is reproduced here against the + * same config source the route uses — if the config surface changes, the first + * test fails rather than the guard silently passing everything. + */ + +vi.mock('@/lib/config', () => ({ + getRpcEndpoint: () => 'https://rpc.example.com', + getAllProgramIds: () => ['WrapperProg1111111111111111111111111111111', 'TierProg111111111111111111111111111111111'], + getConfig: () => ({ + nftProgramId: 'NftProg11111111111111111111111111111111111', + vaultProgramId: 'VaultProg1111111111111111111111111111111', + matcherProgramId: 'MatcherProg11111111111111111111111111111', + }), +})); + +import { getAllProgramIds, getConfig } from '@/lib/config'; + +/** Mirrors the route's allowlist construction. */ +function gpaAllowed(): Set { + const cfg = getConfig() as Record; + const extra = ['nftProgramId', 'vaultProgramId', 'matcherProgramId'] + .map((k) => cfg[k]) + .filter((v): v is string => typeof v === 'string' && v.length > 0); + return new Set([...getAllProgramIds(), ...extra]); +} + +const SPL_TOKEN = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; + +describe('getProgramAccounts is restricted to Percolator programs (GH#2204)', () => { + it('rejects the SPL Token Program — the issue’s own example', () => { + // This is the case a dataSize filter does NOT stop: `dataSize: 165` is the + // SPL token-account size, so the filter check passes and the upstream node + // would return every token account in existence. + expect(gpaAllowed().has(SPL_TOKEN)).toBe(false); + }); + + it('rejects an arbitrary third-party program', () => { + expect(gpaAllowed().has('Vote111111111111111111111111111111111111111')).toBe(false); + }); + + it('allows every program the app legitimately queries', () => { + const allowed = gpaAllowed(); + // Wrapper + slab tiers (userAccountScan), matcher (matcherCaps), + // NFT program (userAccountScan nftProgramId), stake/vault. + for (const id of [ + 'WrapperProg1111111111111111111111111111111', + 'TierProg111111111111111111111111111111111', + 'NftProg11111111111111111111111111111111111', + 'VaultProg1111111111111111111111111111111', + 'MatcherProg11111111111111111111111111111', + ]) { + expect(allowed.has(id)).toBe(true); + } + }); + + it('a non-string target is rejected rather than coerced', () => { + // params[0] missing/undefined must not fall through to the upstream node. + const target: unknown = undefined; + const ok = typeof target === 'string' && gpaAllowed().has(target); + expect(ok).toBe(false); + }); + + it('the allowlist is non-empty — a config regression must not open the gate', () => { + // If getAllProgramIds() ever returned [], an allowlist check would still + // "work" while rejecting everything; the inverse (empty set treated as + // permissive) is the dangerous shape. Pin that it is populated. + expect(gpaAllowed().size).toBeGreaterThan(0); + }); +}); diff --git a/app/app/api/rpc/route.ts b/app/app/api/rpc/route.ts index 7673abb0..18f3f37d 100644 --- a/app/app/api/rpc/route.ts +++ b/app/app/api/rpc/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getRpcEndpoint } from "@/lib/config"; +import { getRpcEndpoint, getAllProgramIds, getConfig } from "@/lib/config"; import { createHash, timingSafeEqual } from "crypto"; import { getClientIp } from "@/lib/get-client-ip"; import { createUpstashRateLimiter } from "@/lib/upstash-rate-limit"; @@ -281,6 +281,34 @@ function validateRequest(req: Record): { jsonrpc: string; error if (method === "getProgramAccounts") { const params = req?.params; const cfg = Array.isArray(params) ? (params[1] as Record | undefined) : undefined; + + // #2204 (second half): the filter requirement below bounds the RESULT SHAPE + // but not the PROGRAM. The issue's own example — a full dump of the SPL Token + // Program — passes a `dataSize: 165` filter happily and still returns every + // token account on the cluster. A filter is not a bound when the program has + // millions of matching accounts. + // + // So pin the program too. This proxy exists to serve THIS app, and the app + // only ever queries programs it owns: the wrapper (plus every slab tier), the + // matcher, the NFT program and the stake/vault program. + const target = Array.isArray(params) ? params[0] : undefined; + const appConfig = getConfig() as Record; + const extraIds = ["nftProgramId", "vaultProgramId", "matcherProgramId"] + .map((k) => appConfig[k]) + .filter((v): v is string => typeof v === "string" && v.length > 0); + const gpaAllowed = new Set([...getAllProgramIds(), ...extraIds]); + if (typeof target !== "string" || !gpaAllowed.has(target)) { + console.warn("[/api/rpc] Blocked getProgramAccounts for a non-Percolator program"); + return { + jsonrpc: "2.0", + error: { + code: -32602, + message: "getProgramAccounts is restricted to Percolator programs", + }, + id: req?.id ?? null, + }; + } + const filters = cfg?.filters; const bounded = Array.isArray(filters) &&