Skip to content
Merged
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
82 changes: 82 additions & 0 deletions app/__tests__/api/rpc-gpa-program-allowlist.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const cfg = getConfig() as Record<string, unknown>;
const extra = ['nftProgramId', 'vaultProgramId', 'matcherProgramId']
.map((k) => cfg[k])
.filter((v): v is string => typeof v === 'string' && v.length > 0);
return new Set<string>([...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);
});
});
30 changes: 29 additions & 1 deletion app/app/api/rpc/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -281,6 +281,34 @@ function validateRequest(req: Record<string, unknown>): { jsonrpc: string; error
if (method === "getProgramAccounts") {
const params = req?.params;
const cfg = Array.isArray(params) ? (params[1] as Record<string, unknown> | 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<string, unknown>;
const extraIds = ["nftProgramId", "vaultProgramId", "matcherProgramId"]
.map((k) => appConfig[k])
.filter((v): v is string => typeof v === "string" && v.length > 0);
const gpaAllowed = new Set<string>([...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) &&
Expand Down
Loading