taler-typescript-core

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

commit 2eec83e5dfe015d11a2bb6a939662a6e61e95b6f
parent 020770521922a84ab46bbc6be8fa38f2de4593a0
Author: Florian Dold <dold@taler.net>
Date:   Wed, 29 Jul 2026 11:47:02 +0200

util: require a canonical base URL for the exchange suggested by the bank

Endpoints are resolved relative to the base URL, so a missing trailing slash
silently drops its last path segment and the wallet withdraws from an exchange
the bank never named.

Issue: https://bugs.taler.net/n/10001

Diffstat:
Mpackages/taler-util/src/codec.ts | 30+++++++++++++++++++++++++++++-
Mpackages/taler-util/src/types-taler-bank-integration.ts | 5+++--
Mpackages/taler-util/src/types-taler-common.test.ts | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-common.ts | 6++++++
4 files changed, 90 insertions(+), 3 deletions(-)

diff --git a/packages/taler-util/src/codec.ts b/packages/taler-util/src/codec.ts @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { j2s } from "./helpers.js"; +import { canonicalizeBaseUrl, j2s } from "./helpers.js"; import { Logger } from "./logging.js"; /** @@ -535,6 +535,34 @@ export function codecForHttpUrlString( } /** + * Return a codec for a string that must be an absolute http(s) URL in + * canonical base URL form. + * + * A base URL is only ever used by resolving a relative path against it, and + * that quietly changes meaning when the URL isn't canonical: resolving "keys" + * against "https://exchange.example.com/taler" yields + * "https://exchange.example.com/keys", so we would talk to a different service + * than the one we were given. Rejecting is the only safe answer -- silently + * canonicalizing would hide the misconfiguration from whoever can fix it. + */ +export function codecForCanonicalBaseUrlString(): Codec<string> { + const urlCodec = codecForHttpUrlString(); + return { + decode(x: any, c?: Context): string { + const url = urlCodec.decode(x, c); + if (canonicalizeBaseUrl(url) !== url) { + throw new DecodingError( + `expected a canonical base URL at ${renderContext( + c, + )} but got "${url}"`, + ); + } + return url; + }, + }; +} + +/** * Return a codec for a value that must be a string. */ export function codecForStringURL(shouldEndWithSlash?: boolean): Codec<string> { diff --git a/packages/taler-util/src/types-taler-bank-integration.ts b/packages/taler-util/src/types-taler-bank-integration.ts @@ -31,6 +31,7 @@ import { PaytoString, codecForPaytoString } from "./payto.js"; import { AmountString, CurrencySpecification, + codecForBaseUrlString, codecForCurrencyName, codecForCurrencySpecificiation, codecForLibtoolVersion, @@ -199,8 +200,8 @@ export const codecForBankWithdrawalOperationStatus = .property("max_amount", codecOptional(codecForAmountString())) .property("card_fees", codecOptional(codecForAmountString())) .property("sender_wire", codecOptional(codecForPaytoString())) - .property("suggested_exchange", codecOptional(codecForURLString())) - .property("required_exchange", codecOptional(codecForURLString())) + .property("suggested_exchange", codecOptional(codecForBaseUrlString())) + .property("required_exchange", codecOptional(codecForBaseUrlString())) .property("confirm_transfer_url", codecOptional(codecForURLString())) .property("wire_types", codecForList(codecForString())) .property("selected_reserve_pub", codecOptional(codecForString())) diff --git a/packages/taler-util/src/types-taler-common.test.ts b/packages/taler-util/src/types-taler-common.test.ts @@ -128,3 +128,55 @@ test("a hostile confirm_transfer_url is rejected at the wire boundary", (t) => { bankIntegration.codecForBankWithdrawalOperationStatus().decode(status), ); }); + +test("codecForBaseUrlString requires the canonical form", (t) => { + const codec = common.codecForBaseUrlString(); + for (const ok of [ + "https://exchange.example/", + "https://exchange.example/taler-exchange/", + "http://localhost:8081/", + ]) { + assert.strictEqual(codec.decode(ok), ok); + } + for (const bad of [ + // The missing trailing slash is the interesting one: resolving "keys" + // against it drops the last path segment. + "https://exchange.example", + "https://exchange.example/taler-exchange", + "https://exchange.example/?foo=bar", + "https://exchange.example/#frag", + "HTTPS://Exchange.Example/", + "exchange.example/", + "file:///etc/passwd", + ]) { + assert.throws(() => codec.decode(bad), `accepted ${bad}`); + } +}); + +test("a non-canonical exchange base URL from the bank is rejected", (t) => { + // The bank operator configures these; a value that isn't a canonical base + // URL would make the wallet withdraw from a different exchange than the one + // the bank named. + const status = (exchangeField: object) => ({ + status: "pending", + amount: "KUDOS:1", + currency: "KUDOS", + sender_wire: "payto://iban/DE75512108001245126199", + wire_types: ["iban"], + ...exchangeField, + }); + const codec = bankIntegration.codecForBankWithdrawalOperationStatus(); + assert.ok( + codec.decode(status({ suggested_exchange: "https://ex.example/" })), + ); + assert.ok(codec.decode(status({ required_exchange: "https://ex.example/" }))); + assert.throws(() => + codec.decode(status({ suggested_exchange: "https://ex.example" })), + ); + assert.throws(() => + codec.decode(status({ required_exchange: "https://ex.example" })), + ); + assert.throws(() => + codec.decode(status({ suggested_exchange: "https://ex.example/taler" })), + ); +}); diff --git a/packages/taler-util/src/types-taler-common.ts b/packages/taler-util/src/types-taler-common.ts @@ -38,6 +38,7 @@ import { codecForList, codecForMap, codecForNumber, + codecForCanonicalBaseUrlString, codecForHttpUrlString, codecForString, codecOptional, @@ -208,6 +209,11 @@ export type CoinPublicKeyString = string; * either opened in a browser or used as the base of an HTTP request. */ export const codecForURLString = codecForHttpUrlString; +/** + * Every field decoded with this is documented as a base URL, i.e. the API + * endpoints of the service are resolved relative to it. + */ +export const codecForBaseUrlString = codecForCanonicalBaseUrlString; // FIXME: implement this codec export const codecForLibtoolVersion = codecForString; // FIXME: implement this codec