Skip to content

fix(oracle): fail closed on a malformed set-price-cap body (GH#2509) - #2511

Merged
dcccrypto merged 1 commit into
playgroundfrom
fix/2509-set-price-cap-fail-closed
Aug 12, 2026
Merged

fix(oracle): fail closed on a malformed set-price-cap body (GH#2509)#2511
dcccrypto merged 1 commit into
playgroundfrom
fix/2509-set-price-cap-fail-closed

Conversation

@dcccrypto

@dcccrypto dcccrypto commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Closes #2509.

The bug

POST /api/oracle/set-price-cap treats an empty body as "apply to every admin-oracle market". It implemented that by initialising body = {} and swallowing every exception from req.json():

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

Any parse failure leaves body as {} — the exact state an empty body produces. So a truncated or malformed payload is indistinguishable from the deliberate all-market command: body.slabAddress is undefined, the route takes the else branch at :155, selects every market whose oracle_authority matches the crank pubkey (bounded only by MAX_SLAB_BATCH = 50), and submits one signed transaction per validated market.

Fail-open scope expansion on an administrative write path: the worse the input, the broader the operation.

Verified against playground@41bb1304 before writing anything — the swallow at :101-105 and the if (body.slabAddress) … else { all markets } at :155 are both exactly as #2509 describes.

To be clear about severity, as the issue is: this is not an auth bypass. It is reachable only after the admin-secret and crank-key checks pass. It is an authenticated operator's malformed request silently becoming a batch.

The fix

Read the raw text once and branch on whether the caller actually sent anything, so "no body" and "bad body" stop being the same state:

input behaviour
empty / whitespace-only unchanged — still the all-markets command
non-empty, fails JSON.parse 400
non-empty, parses to a non-object 400

That third case is the one a try/catch alone does not cover, and it is why the fix is not simply "return 400 in the catch". [], "a string", 123 and null all parse successfully, and every field read on them yields undefined — reaching the same all-market branch by a different route. typeof null === "object", so null is excluded explicitly.

Tests

They exercise the real route through the existing mocked harness in oracle-set-price-cap.test.ts, rather than modelling the logic in the test.

One of them needs its evidence explained, and the comment in the test says so: the harness cannot reach a response on the all-market branch, because that path calls new PublicKey(config.programId) and this file's PublicKey mock is an arrow function, so it throws is not a constructor. That throw is raised below the parse gate — so reaching it proves the empty body was accepted and dispatched. Rejection at the gate would return 400 and never touch PublicKey. I would rather state that than write an assertion whose meaning is not obvious.

Mutation-tested

round result
control 8 passed
restore the swallowed parse (the original bug) 2 failed
drop the non-object guard only 1 failed
control 8 passed

The second row is the one worth noting: it isolates the non-object guard, so the test proves that half is load-bearing rather than decorative.

Verification

pnpm exec vitest run     302 files, 3053 passed, 17 skipped
pnpm exec tsc --noEmit   exit 0

Full suite green on playground with no pre-existing failures.

Summary by CodeRabbit

  • Bug Fixes
    • Improved validation for price-cap update requests.
    • Malformed JSON, arrays, primitive values, and null now return a clear HTTP 400 response.
    • Empty or whitespace-only request bodies continue to use the all-market operation.
    • Numeric strings are now accepted for maximum change values.
    • Invalid requests no longer trigger transaction signing.

`POST /api/oracle/set-price-cap` treats an EMPTY body as "apply to every
admin-oracle market". It implemented that by initialising `body = {}` and
swallowing every exception from `req.json()`:

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

So any parse failure left `body` as `{}` -- the exact state an empty body
produces. A truncated or malformed payload was therefore indistinguishable from
the deliberate all-market command: `body.slabAddress` is undefined, the route
takes the `else` branch at :155, selects every market whose `oracle_authority`
matches the crank pubkey (bounded only by MAX_SLAB_BATCH = 50), and submits one
signed transaction per validated market.

That is fail-OPEN scope expansion on an administrative write path -- the worse
the input, the broader the operation. It is reachable only after the admin
secret and crank-key checks pass, so this is not an auth bypass; it is an
authenticated operator's malformed request silently becoming a batch.

The fix reads the raw text once and branches on whether the caller actually
sent anything, so "no body" and "bad body" stop being the same state:

  - empty / whitespace-only body -> unchanged, still the all-markets command
  - non-empty body that fails JSON.parse -> 400
  - non-empty body that parses to a NON-OBJECT -> 400

The third case matters and a try/catch alone does not cover it: `[]`, `"str"`,
`123` and `null` all parse successfully, and every field read on them yields
`undefined`, which reaches the same all-market branch. `typeof null === "object"`,
so null is excluded explicitly.

Tests exercise the real route through the existing mocked harness rather than
modelling the logic. One of them needs its evidence explained, and says so in a
comment: the harness cannot reach a response on the all-market branch, because
that path calls `new PublicKey(config.programId)` and the file's PublicKey mock
is an arrow function, so it throws "is not a constructor". That throw is raised
BELOW the parse gate, so reaching it proves the empty body was accepted and
dispatched -- rejection at the gate would return 400 and never touch PublicKey.

Mutation-tested, control either side:

  control                                 8 passed
  restore the swallowed parse             2 failed
  drop the non-object guard only          1 failed
  control                                 8 passed

Verified: 302 files, 3053 passed, 17 skipped; tsc --noEmit exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
percolator-launch Ready Ready Preview Aug 12, 2026 11:41am
percolator-mainnet Ready Ready Preview Aug 12, 2026 11:41am
percolator-playground Ready Ready Preview Aug 12, 2026 11:41am

Request Review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The POST handler now validates raw request bodies. Malformed and non-object JSON returns 400 without transaction signing. Empty or whitespace-only bodies retain the all-markets behavior. Regression tests cover omitted, empty, whitespace-only, malformed, and non-object payloads.

Changes

Set-price-cap validation

Layer / File(s) Summary
Raw body validation and regression coverage
app/app/api/oracle/set-price-cap/route.ts, app/__tests__/api/oracle-set-price-cap.test.ts
The handler rejects malformed JSON, arrays, primitives, and null with HTTP 400. The accepted object allows maxChangeE2bps as a number or string. Tests verify invalid payloads do not trigger transaction signing and that empty-body requests retain all-markets behavior.

Estimated code review effort: 3 (Moderate) | ~15–30 minutes

Suggested reviewers: 0x-squidsol

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR rejects malformed and non-object JSON, but it does not demonstrate explicit scope validation or rejection of {} as required by [#2509]. Add runtime validation for explicit slabAddress or {"all":true} scope, and reject {} and invalid fields before side effects.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: rejecting malformed set-price-cap request bodies.
Out of Scope Changes check ✅ Passed The route changes and regression tests address malformed-body handling and related request validation required by [#2509].
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/2509-set-price-cap-fail-closed

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/app/api/oracle/set-price-cap/route.ts`:
- Around line 114-115: The setPriceCap route must validate the raw request body
before invoking loadCrankKeypair(), so malformed authenticated requests return
400 even when CRANK_KEYPAIR is missing; update
app/app/api/oracle/set-price-cap/route.ts at lines 114-115 accordingly. Add a
malformed-body test without CRANK_KEYPAIR in
app/__tests__/api/oracle-set-price-cap.test.ts lines 117-128 and assert a 400
response.
- Line 140: Validate the parsed request object's allowed fields and types before
assigning it to body in the set-price-cap route; reject null, false, empty, or
otherwise invalid slabAddress values with HTTP 400 instead of selecting the
all-markets scope, and ensure invalid maxChangeE2bps or unknown fields are
handled per the existing contract. In app/app/api/oracle/set-price-cap/route.ts
lines 140-140, update the request validation; in
app/__tests__/api/oracle-set-price-cap.test.ts lines 130-140, add invalid
object-field cases asserting 400 responses and no transaction signing.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 702d597c-e2b4-4260-91e3-229f3a190cfa

📥 Commits

Reviewing files that changed from the base of the PR and between 41bb130 and 89abc09.

📒 Files selected for processing (2)
  • app/__tests__/api/oracle-set-price-cap.test.ts
  • app/app/api/oracle/set-price-cap/route.ts

Comment on lines +114 to +115
const rawBody = await req.text();
if (rawBody.trim() !== "") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate the body before loading operational configuration.

loadCrankKeypair() runs before Line 114. If CRANK_KEYPAIR is absent, an authenticated malformed request returns 503 instead of the required 400. Move keypair loading after request-body validation.

  • app/app/api/oracle/set-price-cap/route.ts#L114-L115: Complete raw-body validation before loading the crank keypair.
  • app/__tests__/api/oracle-set-price-cap.test.ts#L117-L128: Add a malformed-body case with no CRANK_KEYPAIR and assert 400.
📍 Affects 2 files
  • app/app/api/oracle/set-price-cap/route.ts#L114-L115 (this comment)
  • app/__tests__/api/oracle-set-price-cap.test.ts#L117-L128
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/app/api/oracle/set-price-cap/route.ts` around lines 114 - 115, The
setPriceCap route must validate the raw request body before invoking
loadCrankKeypair(), so malformed authenticated requests return 400 even when
CRANK_KEYPAIR is missing; update app/app/api/oracle/set-price-cap/route.ts at
lines 114-115 accordingly. Add a malformed-body test without CRANK_KEYPAIR in
app/__tests__/api/oracle-set-price-cap.test.ts lines 117-128 and assert a 400
response.

{ status: 400 },
);
}
body = parsed as { slabAddress?: string; maxChangeE2bps?: number | string };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate object fields before selecting operation scope.

Line 140 accepts any JSON object. Payloads such as {"slabAddress":null}, {"slabAddress":false}, or {"slabAddress":""} make body.slabAddress falsey and select the all-markets branch. This preserves the fail-open scope expansion for invalid object fields.

  • app/app/api/oracle/set-price-cap/route.ts#L140-L140: Validate allowed fields and field types before assigning body. Reject invalid or falsey-present slabAddress values with 400.
  • app/__tests__/api/oracle-set-price-cap.test.ts#L130-L140: Add invalid object-field cases and assert 400 with no transaction signing.
📍 Affects 2 files
  • app/app/api/oracle/set-price-cap/route.ts#L140-L140 (this comment)
  • app/__tests__/api/oracle-set-price-cap.test.ts#L130-L140
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/app/api/oracle/set-price-cap/route.ts` at line 140, Validate the parsed
request object's allowed fields and types before assigning it to body in the
set-price-cap route; reject null, false, empty, or otherwise invalid slabAddress
values with HTTP 400 instead of selecting the all-markets scope, and ensure
invalid maxChangeE2bps or unknown fields are handled per the existing contract.
In app/app/api/oracle/set-price-cap/route.ts lines 140-140, update the request
validation; in app/__tests__/api/oracle-set-price-cap.test.ts lines 130-140, add
invalid object-field cases asserting 400 responses and no transaction signing.

@dcccrypto

Copy link
Copy Markdown
Owner Author

Review: looks good, merge

The bug is real and the fix is precisely scoped.

The defect

body was initialized to {} and every req.json() exception was swallowed, so a truncated or malformed payload landed in exactly the same state as the documented empty-body command — which selects every admin-oracle market and can submit one signed transaction per market. Fail-open scope expansion on an administrative write path: the worse the input, the broader the operation.

The fix

Reads the body once via req.text() and branches on whether anything was actually sent, so "no body" and "bad body" are finally distinguishable. Two details I checked specifically:

  • null is excluded explicitlytypeof null === "object", so a bare typeof parsed !== "object" guard would have let it through to the same all-market path
  • Array.isArray — an array also yields undefined for every field read downstream, same failure mode

Both handled, both commented with the reason.

Verified rather than assumed

I reverted the route to playground's version and re-ran the new tests:

test without the fix
returns 400 for malformed JSON FAILS
returns 400 for a non-object body FAILS
empty body still routed to all-markets passes both ways ✓

That third one matters most — it guards against over-correcting and breaking the documented empty-body contract.

  • Merges clean onto playground
  • tsc --noEmit: 0 errors
  • Suite: 3050 → 3053 passed, 0 failed (+3, no regressions)

No changes requested.

@dcccrypto
dcccrypto merged commit a277ae7 into playground Aug 12, 2026
15 checks passed
@dcccrypto
dcccrypto deleted the fix/2509-set-price-cap-fail-closed branch August 12, 2026 23:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant