Skip to content

/api/devnet-register-mint can register syntactically valid addresses without verifying an on-chain devnet SPL mint exists #2520

Description

@Bayyan16
Weakness class CWE-306 (Missing Authentication), CWE-345 (Insufficient Verification of Data Authenticity)
Affected routes POST /api/devnet-register-mint (the writer); POST /api/devnet-mirror-mint (the poisoned consumer)
Affected files app/app/api/devnet-register-mint/route.ts lines 82-86 (format-only check), 112-121 (upsert); app/app/api/devnet-mirror-mint/route.ts lines 248-260 (early return), 384-396 (ignoreDuplicates upsert)
Assessed at 7ce465b — HEAD of playground as of 2026-08-16. First verified at 8d3e280; re-verified after GH#2519 (confirmation checks only — registry logic untouched)
Related issues #1771 / #1799 (production incidents proving devnet_mints rows gate user-facing flows), #2477 (this route's per-instance-only throttle)

TL;DR

POST /api/devnet-register-mint writes rows into devnet_mints — the registry that /api/devnet-airdrop and /api/devnet-pre-fund consult — after checking nothing but the base58 shape of the submitted address. Anyone can insert rows naming any mainnet CA with attacker-chosen symbol and decimals, at 20 rows/hour per serverless instance, and those rows can never be corrected through the API (ignoreDuplicates: true). Today this pollutes a registry that two live routes trust for authorization and amount math; if /api/devnet-mirror-mint is ever re-wired to a frontend flow, the same rows permanently break mirror creation for every squatted CA.

1. What the route does

The route exists to record a devnet mint in the devnet_mints mapping table (mainnet_cadevnet_mint, plus name, symbol, decimals). Its entire validation posture is:

// app/app/api/devnet-register-mint/route.ts
try { new PublicKey(mintAddress); } catch { ... 400 }     // lines 82-86 — the ONLY check
...
const { error } = await supabase.from("devnet_mints").upsert({
  mainnet_ca: mintAddress,        // attacker-chosen registry key
  devnet_mint: mintAddress,       // the "devnet mint" IS the (mainnet!) address itself
  name: safeName, symbol: rawSymbol, decimals: safeDecimals,
}, { onConflict: "mainnet_ca", ignoreDuplicates: true }); // lines 112-121 — first writer wins forever

Never verified: that the address exists on devnet, that it is an SPL mint, or that its mint authority is the server keypair — a check this codebase already ships in app/app/api/devnet-pre-fund/route.ts:402-429 (getMint() + authority comparison).

2. What is exploitable today (verified at HEAD)

  1. Unauthenticated, unverified registry writes — 20 rows/hour/instance (the in-memory Map at lines 26-40 is per-instance, the failure class Rate limiters silently fail open (per-instance) when Upstash is unconfigured #2477 tracks).
  2. Unrepairable through the APIignoreDuplicates: true (line 120) means a squatted mainnet_ca row can never be corrected by re-registration; cleanup requires direct DB access.
  3. Attacker-chosen decimals feeds consumer math/api/devnet-airdrop reads decimals from devnet_mints rows (route.ts:491-503) and scales grant amounts with 10 ** decimals; the stored value is accepted verbatim (any integer 0-18).
  4. The registry feeds an allowlist/api/devnet-pre-fund treats a devnet_mints row as "permitted" (route.ts:190-215); for a squatted CA the row's devnet_mint equals the mainnet address, so the DB check passes and only the downstream on-chain getMint authority check stops the mint. The allowlist is being fed unverified data.

3. The latent poisoning chain (why severity is Low, not Medium)

If /api/devnet-mirror-mint is ever called with a squatted CA — by a future wizard re-wiring, old runbook docs, or any new consumer — it consults the registry before creating a mirror and returns early on any hit:

// app/app/api/devnet-mirror-mint/route.ts:248-260
const existing = supabase
  ? (await supabase.from("devnet_mints").select("devnet_mint, name, symbol, decimals, logo_url")
      .eq("mainnet_ca", mainnetCA).maybeSingle()).data
  : null;
if (existing?.devnet_mint) {
  return NextResponse.json({
    status: "existing",
    devnetMint: existing.devnet_mint,   // ← for a squatted row: the MAINNET address itself
    ...
  });
}

The caller is told a devnet mint exists; none does. Every later on-chain step fails far from the cause, and both repair paths refuse (ignoreDuplicates here at line 120 and in mirror-mint's own upsert at line 392) — the mapping is broken permanently until manual DB cleanup.

Why latent: the current wizard no longer calls devnet-mirror-mint at all — the mirror-collateral model was removed (CreateMarketWizard.tsx:330-342: "devnet collateral is ALWAYS Sim-USDC … skip it for every devnet market"), and the post-launch airdrop goes through /api/devnet-airdrop (useCreateMarket.ts:3342-3346). A repo-wide search finds no runtime frontend caller for devnet-mirror-mint, devnet-register-mint, or devnet-mint-token. The §2 exposure, however, is live today against the public deployment.

4. Impact

Today: unauthenticated pollution of a registry two live routes consult, with attacker-influenced metadata (decimals, symbol) and no API-level repair. Latent: permanent mirror-creation poisoning for any squatted CA the moment a devnet_mints.by_mainnet_ca consumer is (re)introduced. Severity Low reflects the removed primary consumer; the unverified-write defect itself is unconditional.

5. Proof of concept

# 1) Unverified write — the "mint" need not exist on any cluster.
CA="<any mainnet CA not yet in devnet_mints>"
curl -s -X POST https://<playground-host>/api/devnet-register-mint \
  -H 'Content-Type: application/json' \
  -d "{\"mintAddress\":\"$CA\",\"name\":\"Squatted\",\"symbol\":\"SQAT\",\"decimals\":18}"
# → {"status":"registered","mintAddress":"$CA"}
#    Registry row: mainnet_ca=$CA, devnet_mint=$CA, decimals=18  (unverified, unrepairable)

# 2) Permanence — re-registration cannot repair:
curl -s -X POST https://<playground-host>/api/devnet-register-mint \
  -H 'Content-Type: application/json' \
  -d "{\"mintAddress\":\"$CA\",\"name\":\"Legit\",\"symbol\":\"REAL\",\"decimals\":6}"
# → {"status":"registered"} but the row is UNCHANGED (onConflict + ignoreDuplicates).

# 3) Live consumer effect — devnet-airdrop resolves through devnet_mints and trusts
#    the stored decimals (route.ts:491-503): the squatted row's decimals=18 feeds
#    10**decimals in any airdrop that resolves through it; for the self-referencing
#    row the native-mint fallback engages — the registry's row shape directly
#    steers a live route's behavior.

# 4) LATENT chain — fire mirror-mint directly (what any future consumer would hit):
curl -s -X POST https://<playground-host>/api/devnet-mirror-mint \
  -H 'Content-Type: application/json' \
  -d "{\"mainnetCA\":\"$CA\",\"walletAddress\":\"<any wallet>\"}"
# → {"status":"existing","devnetMint":"$CA","name":"Squatted",...}
#    Asserts a devnet mint exists — but $CA is a MAINNET address. No devnet mint
#    is ever created for this CA again (early return + ignoreDuplicates everywhere).

6. Recommended fix

Gate registration on a property only genuine server-created mirror mints possess — their mint authority — copying the in-repo pattern from devnet-pre-fund/route.ts:402-429:

// after the format check (line 86):
const mintInfo = await connection.getMint(new PublicKey(mintAddress)); // throws if absent on devnet
if (!mintInfo.mintAuthority?.equals(serverMintAuthorityPk)) {          // getDevnetMintSigner().publicKey()
  return NextResponse.json({ error: "Not a Percolator mirror mint" }, { status: 400 });
}

Given that no frontend calls this route (or devnet-mint-token/devnet-mirror-mint) at HEAD, removing the three legacy routes — or gating them behind checkAdminSecret — equally retires the latent chain. If they stay: join the shared durable fund limiter (checkFundRateLimit) in place of the per-instance Map (#2477 class), and replace ignoreDuplicates: true with a plain upsert so verified re-registrations can repair rows.

7. References

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