taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 4d22292628163e1268ad1579150722c398d13e35
parent 66835ab57e8c1710532a3cb9f622b9077e902047
Author: Florian Dold <dold@taler.net>
Date:   Mon, 20 Jul 2026 13:58:59 +0200

util: improve (JSON) amount input validation

Diffstat:
Mpackages/taler-util/src/amounts.test.ts | 40+++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-util/src/amounts.ts | 51++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 87 insertions(+), 4 deletions(-)

diff --git a/packages/taler-util/src/amounts.test.ts b/packages/taler-util/src/amounts.test.ts @@ -17,7 +17,12 @@ import { test } from "node:test"; import assert from "node:assert"; -import { Amounts, AmountJson, amountMaxValue } from "./amounts.js"; +import { + Amounts, + AmountJson, + amountMaxValue, + codecForAmountJson, +} from "./amounts.js"; import { AmountString } from "./types-taler-common.js"; const jAmt = ( @@ -126,6 +131,39 @@ test("amount parsing", (t) => { assert.throws(() => Amounts.parseOrThrow("TESTKUDOS:0.999999991")); }); +test("Amounts.parseOrThrow rejects out-of-range or non-integer object fields", (t) => { + // baseline: a well-formed object round-trips fine. + assert.deepStrictEqual(Amounts.parseOrThrow(jAmt(1, 2, "EUR")), { + currency: "EUR", + value: 1, + fraction: 2, + }); + + assert.throws(() => Amounts.parseOrThrow(jAmt(-1, 0, "EUR"))); + assert.throws(() => Amounts.parseOrThrow(jAmt(1.5, 0, "EUR"))); + assert.throws(() => Amounts.parseOrThrow(jAmt(amountMaxValue + 1, 0, "EUR"))); + assert.throws(() => Amounts.parseOrThrow(jAmt(0, -1, "EUR"))); + assert.throws(() => Amounts.parseOrThrow(jAmt(0, 1.5, "EUR"))); + assert.throws(() => Amounts.parseOrThrow(jAmt(0, 100000000, "EUR"))); +}); + +test("codecForAmountJson rejects out-of-range or non-integer fields", (t) => { + const codec = codecForAmountJson(); + assert.deepStrictEqual( + codec.decode({ currency: "EUR", value: 1, fraction: 2 }), + { currency: "EUR", value: 1, fraction: 2 }, + ); + assert.throws(() => + codec.decode({ currency: "EUR", value: -1, fraction: 0 }), + ); + assert.throws(() => + codec.decode({ currency: "EUR", value: 0, fraction: 100000000 }), + ); + assert.throws(() => + codec.decode({ currency: "EUR", value: 1.5, fraction: 0 }), + ); +}); + test("amount stringification", (t) => { assert.strictEqual( Amounts.stringify(jAmt(0, 0, "TESTKUDOS")), diff --git a/packages/taler-util/src/amounts.ts b/packages/taler-util/src/amounts.ts @@ -26,7 +26,6 @@ import { Context, DecodingError, buildCodecForObject, - codecForNumber, codecForString, renderContext, } from "./codec.js"; @@ -135,11 +134,47 @@ export class Amount { ) {} } +function codecForAmountValueNumber(): Codec<number> { + return { + decode(x: any, c?: Context): number { + if ( + typeof x === "number" && + Number.isInteger(x) && + x >= 0 && + x <= amountMaxValue + ) { + return x; + } + throw new DecodingError( + `expected valid amount value at ${renderContext(c)} but got ${JSON.stringify(x)}`, + ); + }, + }; +} + +function codecForAmountFractionNumber(): Codec<number> { + return { + decode(x: any, c?: Context): number { + if ( + typeof x === "number" && + Number.isInteger(x) && + x >= 0 && + x < amountFractionalBase + ) { + return x; + } + throw new DecodingError( + `expected valid amount fraction at ${renderContext(c)} but got ${JSON.stringify(x)}`, + ); + }, + }; +} + export const codecForAmountJson = (): Codec<AmountJson> => buildCodecForObject<AmountJson>() .property("currency", codecForString()) - .property("value", codecForNumber()) - .property("fraction", codecForNumber()) + .property("value", codecForAmountValueNumber()) + .property("fraction", codecForAmountFractionNumber()) .build("AmountJson"); export function codecForAmountString(): Codec<AmountString> { @@ -567,6 +602,16 @@ export class Amounts { if (typeof s.fraction !== "number") { throw Error("invalid amount object"); } + if (!Number.isInteger(s.value) || s.value < 0 || s.value > amountMaxValue) { + throw Error("invalid amount object (value out of range)"); + } + if ( + !Number.isInteger(s.fraction) || + s.fraction < 0 || + s.fraction >= amountFractionalBase + ) { + throw Error("invalid amount object (fraction out of range)"); + } return { currency: s.currency, value: s.value, fraction: s.fraction }; } else if (typeof s === "string") { const res = Amounts.parse(s);