Skip to content

/api/oracle/set-price-cap treats malformed JSON as an all-market administrative operation #2509

Description

@Bayyan16

Classification

Field Value
Category Logic error; missing error handling; runtime type validation
Affected endpoint POST /api/oracle/set-price-cap
Privilege required Valid administrative secret
Audited branch playground
Audited commit 41bb1304705cd7652b49ef7a4303454d1646415c
Validation date 2026-08-12

Executive summary

The administrative price-cap route deliberately interprets an empty request body as an instruction to update every admin-oracle market controlled by the configured crank keypair. However, it implements that contract by initializing body to {} and swallowing every exception raised by req.json().

As a result, an authenticated request containing non-empty but malformed or truncated JSON is indistinguishable from the intentionally empty-body command. The parse failure leaves body as {}, the route selects the default cap, omits the single-market target, queries all matching markets, and can submit one on-chain transaction per validated market.

This is a fail-open scope expansion in an administrative write endpoint: invalid input can expand the operation from the intended request to a batch affecting all eligible markets. This report does not claim an authentication bypass. The issue is reachable only after the existing administrative authentication and crank-key configuration checks succeed.

Affected code

Security and correctness invariant

A request that fails parsing or runtime validation must fail closed. It must never broaden an administrative operation from a specific or invalid request to an all-target batch operation.

The route violates this invariant because the parse-error state and the intentional all-market state are represented by the same value: {}.

Root cause

The vulnerable control flow is:

let body: { slabAddress?: string; maxChangeE2bps?: number } = {};
try {
  body = await req.json();
} catch {
  // empty body is valid — means "all admin-oracle markets"
}

The catch block does not establish that the body is actually empty. It also catches malformed JSON, truncated JSON, unsupported encodings surfaced by the parser, and any other exception from req.json(). After the exception, execution continues with body === {}.

The next decisions compound the problem:

  1. body.maxChangeE2bps is absent, so the route assigns DEFAULT_MAX_CHANGE_E2BPS (1_000n).
  2. body.slabAddress is absent, so the route selects the all-market branch.
  3. The route queries the markets table for rows whose oracle_authority matches the crank public key.
  4. It validates candidate accounts on-chain, retaining at most 50 addresses.
  5. It sends a separate SetOraclePriceCap transaction for every retained address.

The on-chain ownership checks are valuable, but they validate which accounts are eligible; they do not validate that the caller intended an all-market operation. Consequently, they do not mitigate the scope-confusion bug.

There are two additional runtime-validation symptoms. JSON null is valid and therefore does not enter the parser catch. The static TypeScript annotation is erased at runtime, so the next access to body.maxChangeE2bps throws a TypeError. JSON arrays also bypass object-shape validation; an empty array has neither property and therefore selects the same default-cap/all-market branch as {}. This independently confirms that the route is relying on a compile-time annotation instead of validating the runtime JSON shape.

Preconditions and reachability

All of the following are required for the batch-write impact:

  1. The caller supplies a valid x-admin-secret accepted by checkAdminSecret.
  2. CRANK_KEYPAIR is configured and parses successfully.
  3. The request contains malformed/truncated non-empty JSON.
  4. The database returns at least one market whose oracle_authority matches the crank public key.
  5. At least one returned slab is a valid public key and is owned by the configured Percolator program.

These preconditions limit attacker reachability, but they do not eliminate operational risk. An internal client bug, a partially written request body, an automation error, or transport truncation can satisfy the malformed-body condition. Administrative endpoints should be especially strict because an input failure must not be interpreted as a broader write instruction.

Deterministic PoC

The following safe local harness uses the same WHATWG Request.json() primitive and mirrors the route's parse/default/target decisions. It does not connect to Solana, Supabase, or the production application.

async function parseCurrentRouteStyle(rawBody) {
  let body = {};
  let parseRejected = false;

  try {
    body = await new Request("http://local.test/api/oracle/set-price-cap", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: rawBody,
    }).json();
  } catch {
    parseRejected = true;
  }

  let outcome;
  try {
    const maxChangeE2bps =
      body.maxChangeE2bps == null ? 1000n : BigInt(body.maxChangeE2bps);
    const target = body.slabAddress
      ? "single-market"
      : "all-admin-oracle-markets";
    outcome = { maxChangeE2bps: maxChangeE2bps.toString(), target };
  } catch (error) {
    outcome = { throws: error.constructor.name };
  }

  return { rawBody, parseRejected, body, outcome };
}

(async () => {
  for (const raw of ["", "{", "null", "[]", "{}", '{"slabAddress":"S"}']) {
    console.log(JSON.stringify(await parseCurrentRouteStyle(raw)));
  }
})();

Observed output on the audited snapshot's runtime environment:

{"rawBody":"","parseRejected":true,"body":{},"outcome":{"maxChangeE2bps":"1000","target":"all-admin-oracle-markets"}}
{"rawBody":"{","parseRejected":true,"body":{},"outcome":{"maxChangeE2bps":"1000","target":"all-admin-oracle-markets"}}
{"rawBody":"null","parseRejected":false,"body":null,"outcome":{"throws":"TypeError"}}
{"rawBody":"[]","parseRejected":false,"body":[],"outcome":{"maxChangeE2bps":"1000","target":"all-admin-oracle-markets"}}
{"rawBody":"{}","parseRejected":false,"body":{},"outcome":{"maxChangeE2bps":"1000","target":"all-admin-oracle-markets"}}
{"rawBody":"{\"slabAddress\":\"S\"}","parseRejected":false,"body":{"slabAddress":"S"},"outcome":{"maxChangeE2bps":"1000","target":"single-market"}}

The complete source-to-sink path is therefore:

authenticated malformed request
  -> Request.json() rejects
  -> catch suppresses the parse error
  -> body remains {}
  -> default maxChangeE2bps = 1_000n
  -> slabAddress is absent
  -> query all matching admin-oracle markets
  -> validate up to 50 slabs
  -> sendAndConfirmTransaction once per retained slab

Actual behavior

  • Malformed non-empty JSON is accepted as the semantic equivalent of an intentional empty-body all-market command.
  • The default cap is selected even though no valid request document supplied that value.
  • Up to 50 eligible markets can be processed in sequence.
  • A valid JSON null body reaches an unhandled property access and produces a server error instead of a validation response.
  • A valid JSON array such as [] receives the default cap and enters the all-market branch rather than returning 400.
  • The successful response does not reveal that the original JSON document failed to parse.

Expected behavior

  • Malformed JSON must return 400 Bad Request before any database query, account lookup, signing, or transaction submission.
  • Parsed JSON must be validated as a non-null, non-array object.
  • An all-market administrative operation must require an explicit, positively validated intent marker that cannot be produced by a parse failure.
  • Invalid runtime shapes such as null, arrays, strings, and numbers must return a stable 400 response.

Impact

Integrity impact

An authenticated malformed request can overwrite previously configured circuit-breaker values with the route's default value across every eligible market. Markets with intentionally different caps may therefore receive the wrong configuration.

Operational impact

The transaction loop is sequential and records per-market success or failure. If some transactions land before a later transaction fails, the system can enter a partially updated state. Recovery requires determining which transactions landed and restoring the intended cap for each affected market.

Availability and cost impact

One malformed request can initiate database work, an on-chain multi-account lookup, and up to 50 signed transaction attempts. This is bounded by MAX_SLAB_BATCH, but it is still materially more work than rejecting an invalid request at the parser boundary.

Severity rationale

Severity is Low after conservative recalibration:

Factor Assessment
Impact Can change an on-chain circuit-breaker parameter on multiple eligible markets, but the fallback value is the route's deliberately protective default rather than an attacker-selected arbitrary value
Likelihood Requires a valid administrative secret, a configured crank signer, eligible markets, and an authenticated request whose body is malformed or has the wrong runtime shape
Reach Bounded to at most 50 markets whose database authority matches the crank and whose accounts pass the on-chain ownership check
Detectability The route logs discovered markets and each transaction result, and returns per-market results to the caller
Recovery Requires identifying landed transactions and reapplying intended caps; inconvenient but normally reversible by the authorized operator

The issue is therefore a real fail-open administrative input bug, but its trigger is uncommon and privileged. There is no authentication bypass, crank-key compromise, arbitrary account targeting, or unprivileged attack path in this report. PR #2494 independently characterizes invalid input on this endpoint as an admin-only malformed-request problem rather than an attack; the present bug has broader scope because it can reach multiple markets, but that historical calibration supports Low rather than Medium.

Recommended fix

The safest contract is to remove “empty body means all” and require explicit scope:

{ "slabAddress": "...", "maxChangeE2bps": "1000" }

or:

{ "all": true, "maxChangeE2bps": "1000" }

Recommended implementation properties:

  1. Parse the body inside a dedicated error boundary and return 400 for every parse failure.
  2. Validate the parsed value as typeof value === "object", value !== null, and !Array.isArray(value).
  3. Validate an explicit discriminated scope: exactly one of slabAddress or all: true.
  4. Reject {}, null, arrays, primitive JSON values, and unknown contradictory scope fields.
  5. Retain the existing administrative authentication, u64 validation, batch-size cap, and on-chain ownership validation.
  6. Do not query Supabase or construct transactions until the full request object has passed validation.

If backward compatibility absolutely requires an empty body, read await req.text() first. Only a truly empty or whitespace-only string should enter that legacy branch; a non-empty string that fails JSON.parse must return 400. Even then, explicit { "all": true } is preferable because it makes destructive scope reviewable in logs and client code.

Suggested regression tests

  • Valid auth + malformed JSON ("{") returns 400; Supabase and sendAndConfirmTransaction are not called.
  • Valid auth + truncated JSON returns 400 and performs no side effects.
  • Valid auth + JSON null returns 400.
  • Valid auth + JSON array returns 400.
  • Valid auth + primitive string/number/boolean returns 400.
  • Empty object returns 400 when explicit scope is adopted.
  • { "all": true } is the only payload that enters the all-market branch.
  • { "slabAddress": valid } processes exactly one slab.
  • Supplying both all and slabAddress returns 400.
  • Invalid maxChangeE2bps returns 400 before target discovery.
  • A batch with a later transaction failure reports partial results without obscuring which markets were changed.

Existing test gap

The current oracle-set-price-cap.test.ts coverage verifies authentication failures and a non-integer maxChangeE2bps. It does not cover malformed JSON, valid non-object JSON, or an explicit distinction between single-market and all-market intent. The separate u64-bound test addresses numeric range handling, not parse-failure scope expansion.

Duplicate analysis

Searches were performed against open issues, closed issues, pull requests, and commits using exact and broad variants including set-price-cap, malformed JSON, invalid JSON, empty body, and all admin-oracle markets.

Related work is materially different:

  • Issue #2078 is a closed, repository-wide input-validation audit that marked body parsing and this route as complete; it does not identify malformed/non-object JSON selecting the all-market branch. The new PoC demonstrates a boundary that the checklist missed, so the audit conclusion is context rather than a duplicate finding.
  • Issue #1946 covers negative or unbounded maxChangeE2bps validation.
  • Issue #2231 covers on-chain ownership validation for slab addresses sourced from Supabase.
  • PR #2494 bounds the numeric maxChangeE2bps path to u64.
  • PR #1694 introduced/hardened the price-cap endpoint and its authentication/price safeguards.

None of those items covers the parse-error-to-all-market control-flow transition described here. No matching issue or PR was found for malformed/invalid JSON being interpreted as an all-market operation as of 2026-08-12.

Validation limitations

The repository snapshot did not contain installed dependencies, so the complete Next.js/Vitest route suite was not executed and no dependency installation was performed during this read-only audit. Validation instead used:

  • exact source inspection at the pinned commit;
  • source-to-sink control-flow tracing;
  • inspection of existing tests and their boundary coverage;
  • a deterministic local parser/control-flow harness;
  • GitHub issue, PR, and commit deduplication searches.

The missing dependency environment does not weaken the central conclusion: the parse exception is visibly suppressed, the fallback state is visibly {}, and that state visibly selects the existing all-market transaction path.

Final verdict

Confirmed, High confidence, Low severity. This is a valid new correctness issue in the audited commit. It is not an authentication bypass or direct attacker primitive, but it can convert invalid authenticated input into a bounded multi-market administrative write.

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