From 570292bedccff102eaf9d8ed0e72ba7534fe4c90 Mon Sep 17 00:00:00 2001 From: rayasa07 <264205419+rayasa07@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:23:21 +0100 Subject: [PATCH] fix(transfer): reject a malformed MPP challenge request parameter The request parameter of an MPP challenge went straight to JSON.parse, so a challenge whose request was not base64url-encoded JSON escaped parseMppChallenge as an uncaught SyntaxError and exited 1 under E_RUNTIME, while every other invalid-challenge check raises E_USAGE and exits 2. A request that decoded to valid JSON that was not an object was also accepted, degrading to an empty record and failing later on a missing currency. --- .../reject-malformed-mpp-challenge-request.md | 5 ++ src/commands/transfer.ts | 20 ++++- test/transfer.test.ts | 74 ++++++++++++++++++- 3 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 .changelog/reject-malformed-mpp-challenge-request.md diff --git a/.changelog/reject-malformed-mpp-challenge-request.md b/.changelog/reject-malformed-mpp-challenge-request.md new file mode 100644 index 0000000..196a70e --- /dev/null +++ b/.changelog/reject-malformed-mpp-challenge-request.md @@ -0,0 +1,5 @@ +--- +wallet-cli: patch +--- + +Fixed `tempo wallet transfer --credits` to report a malformed `request` parameter in an MPP challenge as `E_USAGE`. The parameter was passed straight to `JSON.parse`, so a challenge whose `request` was not base64url-encoded JSON escaped as an uncaught `SyntaxError` and exited 1 under `E_RUNTIME` instead of the exit code 2 every other invalid-challenge check uses. A `request` that decodes to valid JSON that is not an object is rejected the same way, rather than degrading to an empty request and failing later on a missing currency. diff --git a/src/commands/transfer.ts b/src/commands/transfer.ts index 13d0278..67e85d9 100644 --- a/src/commands/transfer.ts +++ b/src/commands/transfer.ts @@ -6,7 +6,7 @@ import { Actions } from "viem/tempo"; import { version } from "../shared/constants.js"; import { networkError, usageError } from "../shared/errors.js"; import { appUrl, chainId, tokenAddress, tokenDecimals, tokenSymbol } from "../shared/network.js"; -import { decodeBase64UrlJson, getRecord, stringValue } from "../shared/utils.js"; +import { getRecord, stringValue } from "../shared/utils.js"; import { createProvider } from "../provider.js"; import { loadWalletState } from "../wallet/store.js"; @@ -293,10 +293,13 @@ function parseMppChallenge(input: string) { throw usageError("Invalid configuration: invalid MPP challenge: Expected 'Payment' scheme."); const params = parseAuthParams(header.slice(paymentIndex + "Payment".length)); - const request = params.request ? decodeBase64UrlJson(params.request) : null; - if (!request) + if (!params.request) throw usageError("Invalid configuration: invalid MPP challenge: Missing request parameter."); + const request = mppChallengeRequest(params.request); + if (!request) + throw usageError("Invalid configuration: invalid MPP challenge: Malformed request parameter."); + return { id: params.id ?? "", realm: params.realm ?? "", @@ -307,6 +310,17 @@ function parseMppChallenge(input: string) { }; } +function mppChallengeRequest(value: string) { + let decoded: unknown; + try { + decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); + } catch { + return null; + } + if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) return null; + return decoded as Record; +} + function mppHeaderValue(input: string) { const trimmed = input.trim(); for (const line of trimmed.split(/\r?\n/)) { diff --git a/test/transfer.test.ts b/test/transfer.test.ts index 267208f..0703cb5 100644 --- a/test/transfer.test.ts +++ b/test/transfer.test.ts @@ -21,7 +21,13 @@ function buildMppChallenge(amount: string, overrides: Record = ...overrides, }; const encoded = Buffer.from(JSON.stringify(request), "utf8").toString("base64url"); - return `Payment realm="example", method="tempo", intent="charge", id="abc123", request="${encoded}"`; + return buildMppChallengeWithRequest(`request="${encoded}"`); +} + +function buildMppChallengeWithRequest(requestParam: string) { + return `Payment realm="example", method="tempo", intent="charge", id="abc123"${ + requestParam ? `, ${requestParam}` : "" + }`; } describe("transferTokens", () => { @@ -182,6 +188,72 @@ describe("transferCredits", () => { ); }); + it("throws E_USAGE for an MPP challenge request parameter that is not valid JSON", async () => { + await useTempHome(); + await writeWalletState(walletState()); + + const encoded = Buffer.from("not-json", "utf8").toString("base64url"); + const error = await transferCredits({ + options: { + "dry-run": true, + "mpp-challenge": buildMppChallengeWithRequest(`request="${encoded}"`), + }, + }).catch((err: unknown) => err); + + expectUsageError( + error, + "Invalid configuration: invalid MPP challenge: Malformed request parameter.", + ); + }); + + it("throws E_USAGE for an MPP challenge request parameter that is not base64url", async () => { + await useTempHome(); + await writeWalletState(walletState()); + + const error = await transferCredits({ + options: { "dry-run": true, "mpp-challenge": buildMppChallengeWithRequest('request="!!!!"') }, + }).catch((err: unknown) => err); + + expectUsageError( + error, + "Invalid configuration: invalid MPP challenge: Malformed request parameter.", + ); + }); + + it("throws E_USAGE for an MPP challenge request parameter that is not a JSON object", async () => { + await useTempHome(); + await writeWalletState(walletState()); + + for (const payload of ["null", "[]", "5", '"amount"']) { + const encoded = Buffer.from(payload, "utf8").toString("base64url"); + const error = await transferCredits({ + options: { + "dry-run": true, + "mpp-challenge": buildMppChallengeWithRequest(`request="${encoded}"`), + }, + }).catch((err: unknown) => err); + + expectUsageError( + error, + "Invalid configuration: invalid MPP challenge: Malformed request parameter.", + ); + } + }); + + it("throws E_USAGE for an MPP challenge with no request parameter", async () => { + await useTempHome(); + await writeWalletState(walletState()); + + const error = await transferCredits({ + options: { "dry-run": true, "mpp-challenge": buildMppChallengeWithRequest("") }, + }).catch((err: unknown) => err); + + expectUsageError( + error, + "Invalid configuration: invalid MPP challenge: Missing request parameter.", + ); + }); + it("throws E_USAGE for a non-zero ETH value", async () => { await useTempHome(); await writeWalletState(walletState());