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:
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