Skip to content
Open
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
18 changes: 18 additions & 0 deletions app/api/execute/[...slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
import { buildProtocolFunctionArgs } from "../_lib/protocol-function-args";
import { checkRateLimit } from "../_lib/rate-limit";
import { parseNativeValueWei } from "../_lib/reserved-value";
import { refuseSimulateBody, rejectSimulateQuery } from "../_lib/simulate-flag";
import { checkAndReserveExecution } from "../_lib/spending-cap";
import type { ExecuteResponse } from "../_lib/types";
import { requireWallet } from "../_lib/wallet-check";
Expand Down Expand Up @@ -377,6 +378,13 @@ export async function POST(
);
}

// #2004: ?simulate= is refused on every /api/execute/* route rather than
// silently ignored.
const simulateQuery = rejectSimulateQuery(request);
if (simulateQuery) {
return simulateQuery;
}

const scopeError = requireScope(apiKeyCtx.scope, SCOPE_MCP_WRITE, {
organizationId: apiKeyCtx.organizationId,
credentialId: apiKeyCtx.apiKeyId,
Expand Down Expand Up @@ -411,6 +419,16 @@ export async function POST(
);
}

// #2004 (the severe half): this route has no dry-run support, and a body
// `simulate` used to fall through as an unknown field while the protocol
// action broadcast for real (issue #1929). Refuse it loudly, before the
// idempotency key is reserved so a refused request consumes no execution
// and leaves no lock to release.
const simulateBody = refuseSimulateBody(body);
if (simulateBody) {
return simulateBody;
}

const idem = await beginIdempotentFromRequest({
request,
organizationId: apiKeyCtx.organizationId,
Expand Down
9 changes: 9 additions & 0 deletions app/api/execute/[executionId]/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { requireScope } from "@/lib/middleware/require-scope";
import { applyRateLimitHeaders } from "@/lib/rate-limit-headers";
import { validateApiKey } from "../../_lib/auth";
import { checkRateLimit } from "../../_lib/rate-limit";
import { rejectSimulateQuery } from "../../_lib/simulate-flag";
import type { ExecutionStatusResponse } from "../../_lib/types";

// Seconds a client should wait before polling status again while the execution
Expand Down Expand Up @@ -39,6 +40,14 @@ export async function GET(
return scopeError;
}

// #2004: ?simulate= is refused rather than ignored on every /api/execute/*
// route. This endpoint is read-only, so a dry run has nothing to mean here
// -- there is exactly one shape of status request.
const simulateQuery = rejectSimulateQuery(request);
if (simulateQuery) {
return simulateQuery;
}

const rateLimit = checkRateLimit(apiKeyCtx.apiKeyId);
if (!rateLimit.allowed) {
return applyRateLimitHeaders(
Expand Down
71 changes: 71 additions & 0 deletions app/api/execute/_lib/simulate-flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,21 @@
* supported: an execute endpoint must have exactly one shape per
* input, and silently falling through to "spend real funds" because
* a caller mistyped `"true"` instead of `true` is unsafe.
*
* #2004: the same principle extended to the flag's *place*. Every
* /api/execute/* route rejects a `simulate` query parameter with 400
* (rejectSimulateQuery), and a route with no dry-run support at all
* rejects a body `simulate` with 400 (refuseSimulateBody) instead of
* dropping the field and broadcasting for real. A route either honours
* a dry run or refuses one -- it never accepts the flag and
* broadcasts. tests/integration/execute-simulate-invariant.test.ts
* enumerates the routes from the filesystem and fails when a route
* has not declared (and wired) its side.
*/

import { NextResponse } from "next/server";
import { HttpStatus } from "@/lib/http-status";

type ParsedSimulateFlag =
| { ok: true; simulate: boolean }
| { ok: false; error: string };
Expand All @@ -33,3 +46,61 @@ export function parseSimulateFlag(
}
return { ok: true, simulate: value };
}

// The only routes that honour a body dry run. Refusal messages name them so a
// caller holding the flag in the wrong place knows where it belongs. Keep in
// sync with the routes that call parseSimulateFlag.
const DRY_RUN_ROUTES =
"/api/execute/transfer, /api/execute/contract-call, and /api/execute/check-and-execute";

function simulateUnsupportedResponse(error: string): NextResponse {
return NextResponse.json(
{ error, field: "simulate", code: "unsupported_param" },
{ status: HttpStatus.BAD_REQUEST }
);
}

/**
* Reject a `simulate` query parameter on any /api/execute/* route (#2004).
*
* The query string was never honoured, but silently ignoring it made "I asked
* for a dry run" and "spend real funds" indistinguishable on the wire -- the
* same hazard as a flag of the wrong type, just in the wrong place. Any
* `simulate` key is now a 400: `true`, `false`, an empty value, or a
* mistyped string. Other query parameters are untouched.
*
* Returns the 400 response, or null when no `simulate` query parameter is
* present -- the proceed signal, matching the `NextResponse | null` guard
* convention used by requireScope and requireWallet.
*/
export function rejectSimulateQuery(request: Request): NextResponse | null {
if (!new URL(request.url).searchParams.has("simulate")) {
return null;
}
return simulateUnsupportedResponse(
`\`simulate\` is not accepted as a query parameter. Pass it in the JSON body instead; a body dry run is honoured by ${DRY_RUN_ROUTES} and refused by every other /api/execute/* route.`
);
}

/**
* Refuse a `simulate` field in the body of a route with no dry-run support
* (#2004, the refusal branch of item 1).
*
* On these routes the flag used to fall through as an unknown body field and
* the route broadcast for real -- the exact "accepted the flag and
* broadcast" shape the simulate invariant forbids. Any value is refused
* (including the string "true"): a route with no dry-run support has no
* correct flag shape to accept.
*
* Same `NextResponse | null` convention as rejectSimulateQuery. Call it
* before the idempotency key is reserved so a refused request consumes no
* execution and leaves no lock to release.
*/
export function refuseSimulateBody(body: unknown): NextResponse | null {
if (typeof body !== "object" || body === null || !("simulate" in body)) {
return null;
}
return simulateUnsupportedResponse(
`\`simulate\` is not supported on this route: it executes for real and cannot dry-run. Dry runs are honoured by ${DRY_RUN_ROUTES}.`
);
}
9 changes: 8 additions & 1 deletion app/api/execute/check-and-execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
withRejectedSignerOverride,
} from "../_lib/execution-service";
import { checkRateLimit } from "../_lib/rate-limit";
import { parseSimulateFlag } from "../_lib/simulate-flag";
import { parseSimulateFlag, rejectSimulateQuery } from "../_lib/simulate-flag";
import { checkAndReserveExecution } from "../_lib/spending-cap";
import { validateCheckAndExecuteInput } from "../_lib/validate";
import { requireWallet } from "../_lib/wallet-check";
Expand Down Expand Up @@ -342,6 +342,13 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}

// #2004: ?simulate= is refused on every /api/execute/* route rather than
// silently ignored. This route honours the flag only in the body.
const simulateQuery = rejectSimulateQuery(request);
if (simulateQuery) {
return simulateQuery;
}

// Parsed before the scope gate because the required scope depends on
// whether this is a dry run.
let body: Record<string, unknown>;
Expand Down
12 changes: 11 additions & 1 deletion app/api/execute/contract-call/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
} from "../_lib/execution-service";
import { checkRateLimit } from "../_lib/rate-limit";
import { parseNativeValueWei } from "../_lib/reserved-value";
import { parseSimulateFlag } from "../_lib/simulate-flag";
import { parseSimulateFlag, rejectSimulateQuery } from "../_lib/simulate-flag";
import { checkAndReserveExecution } from "../_lib/spending-cap";
import type { ExecuteResponse } from "../_lib/types";
import { validateContractCallInput } from "../_lib/validate";
Expand Down Expand Up @@ -268,6 +268,16 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}

// #2004: ?simulate= is refused on every /api/execute/* route rather than
// silently ignored. This route honours the flag only in the body; the
// old "query string must NOT be honoured" position is retired deliberately
// -- a family where transfer rejects and this route ignores is the worst
// of the three uniform answers.
const simulateQuery = rejectSimulateQuery(request);
if (simulateQuery) {
return simulateQuery;
}

// Parsed before the scope gate because the required scope depends on
// whether this is a dry run.
let body: Record<string, unknown>;
Expand Down
18 changes: 18 additions & 0 deletions app/api/execute/node/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
type TransactionResult,
transactionRetryOptions,
} from "../_lib/retry";
import { refuseSimulateBody, rejectSimulateQuery } from "../_lib/simulate-flag";
import { checkAndReserveExecution } from "../_lib/spending-cap";
import type { NodeExecuteRequest, RetryConfig } from "../_lib/types";
import { requireWallet } from "../_lib/wallet-check";
Expand Down Expand Up @@ -504,6 +505,13 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}

// #2004: ?simulate= is refused on every /api/execute/* route rather than
// silently ignored.
const simulateQuery = rejectSimulateQuery(request);
if (simulateQuery) {
return simulateQuery;
}

const scopeError = requireScope(apiKeyCtx.scope, SCOPE_MCP_WRITE, {
organizationId: apiKeyCtx.organizationId,
credentialId: apiKeyCtx.apiKeyId,
Expand Down Expand Up @@ -543,6 +551,16 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}

// #2004: this route has no dry-run support. A top-level `simulate` used to
// be dropped by validateRequest's fixed whitelist and the step broadcast
// for real -- the same accept-and-broadcast defect as the protocol route,
// reached through a different mechanism. Refuse it loudly, before the
// whitelist and before the idempotency key is reserved.
const simulateBody = refuseSimulateBody(body);
if (simulateBody) {
return simulateBody;
}

const validation = validateRequest(body);
if (!validation.valid) {
return NextResponse.json(
Expand Down
9 changes: 9 additions & 0 deletions app/api/execute/swap/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { NextResponse } from "next/server";
import { SCOPE_MCP_WRITE } from "@/lib/mcp/oauth-scopes";
import { requireScope } from "@/lib/middleware/require-scope";
import { validateApiKey } from "../_lib/auth";
import { rejectSimulateQuery } from "../_lib/simulate-flag";

export async function POST(request: Request): Promise<NextResponse> {
const apiKeyCtx = await validateApiKey(request);
Expand All @@ -24,5 +25,13 @@ export async function POST(request: Request): Promise<NextResponse> {
return scopeError;
}

// #2004: ?simulate= is refused rather than ignored on every /api/execute/*
// route, including this stub. The body is never read here, so there is no
// body flag to refuse -- the 501 already refuses everything.
const simulateQuery = rejectSimulateQuery(request);
if (simulateQuery) {
return simulateQuery;
}

return NextResponse.json({ message: "Coming soon" }, { status: 501 });
}
11 changes: 10 additions & 1 deletion app/api/execute/transfer/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
parseNativeValueLamports,
parseNativeValueWei,
} from "../_lib/reserved-value";
import { parseSimulateFlag } from "../_lib/simulate-flag";
import { parseSimulateFlag, rejectSimulateQuery } from "../_lib/simulate-flag";
import { checkAndReserveExecution } from "../_lib/spending-cap";
import type { ExecuteResponse } from "../_lib/types";
import { validateTokenFields, validateTransferInput } from "../_lib/validate";
Expand All @@ -52,6 +52,15 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}

// 1.5 #2004: ?simulate= is refused on every /api/execute/* route rather
// than silently ignored. This route honours the flag only in the body;
// a query flag used to fall through to a real broadcast with no
// acknowledgement that a dry run had been asked for.
const simulateQuery = rejectSimulateQuery(request);
if (simulateQuery) {
return simulateQuery;
}

// Parsed before the scope gate because the required scope depends on
// whether this is a dry run.
let body: Record<string, unknown>;
Expand Down
Loading
Loading