commit 80e1b2669fc32fedb13673da5238f7d26badfb77
parent 53c79a44c78f35068f3895765bff47e90646b4d9
Author: Florian Dold <dold@taler.net>
Date: Fri, 31 Jul 2026 11:54:16 +0200
wallet-core: complete exchange base URLs over the network
Issue: https://bugs.taler.net/n/10777
Diffstat:
5 files changed, 823 insertions(+), 11 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-exchange-management.ts b/packages/taler-harness/src/integrationtests/test-exchange-management.ts
@@ -107,10 +107,11 @@ export async function runExchangeManagementTest(
});
});
console.log(j2s(err));
- // Tries to complete to https://, but we use http in the local test.
+ // Tries to complete to https://, but we use http in the local test. The
+ // completion validates /config, so it fails before the exchange is added.
t.assertDeepEqual(
- (err.errorDetail as any).innerError.requestUrl,
- "https://localhost:8081/keys",
+ (err.errorDetail as any).requestUrl,
+ "https://localhost:8081/config",
);
}
}
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -4358,12 +4358,15 @@ export const codecForImportDbFromFileRequest =
export interface CompleteBaseUrlRequest {
url: string;
+
+ progressToken?: string;
}
export const codecForCompleteBaseUrlRequest =
(): Codec<CompleteBaseUrlRequest> =>
buildCodecForObject<CompleteBaseUrlRequest>()
.property("url", codecForString())
+ .property("progressToken", codecOptional(codecForString()))
.build("CompleteBaseUrlRequest");
export type CompleteBaseUrlResult =
@@ -4384,6 +4387,14 @@ export type CompleteBaseUrlResult =
status: "bad-syntax" | "bad-network" | "bad-exchange";
/** Error details in case status is not "ok" */
error: TalerErrorDetail;
+
+ /**
+ * Base URLs of exchanges known to the wallet whose host looks like what
+ * the user meant to type, most likely first.
+ *
+ * Absent when the wallet does not know anything similar.
+ */
+ suggestions?: string[];
};
export interface SetDonauRequest {
diff --git a/packages/taler-wallet-core/src/exchange-base-url.test.ts b/packages/taler-wallet-core/src/exchange-base-url.test.ts
@@ -0,0 +1,242 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import assert from "node:assert";
+import { test } from "node:test";
+import {
+ cleanUpExchangeBaseUrl,
+ exchangeBaseUrlCandidates,
+ findExchangeSuggestions,
+ levenshteinDistance,
+ stringSimilarity,
+} from "./exchange-base-url.js";
+
+/**
+ * Clean up @a input with HTTPS enforced, and return the base URL.
+ */
+function cleanStrict(input: string): string | undefined {
+ const res = cleanUpExchangeBaseUrl(input, { allowHttp: false });
+ return res.status === "ok" ? res.baseUrl : undefined;
+}
+
+/**
+ * Clean up @a input with plain HTTP allowed, and return the base URL.
+ */
+function cleanRelaxed(input: string): string | undefined {
+ const res = cleanUpExchangeBaseUrl(input, { allowHttp: true });
+ return res.status === "ok" ? res.baseUrl : undefined;
+}
+
+test("cleanup adds the scheme and the trailing slash", (t) => {
+ assert.strictEqual(
+ cleanStrict("exchange.example.com"),
+ "https://exchange.example.com/",
+ );
+ assert.strictEqual(cleanStrict("example.com"), "https://example.com/");
+ assert.strictEqual(
+ cleanStrict("https://exchange.example.com/"),
+ "https://exchange.example.com/",
+ );
+ // Starts with the letters "http" but still needs a scheme.
+ assert.strictEqual(cleanStrict("httpbin.org"), "https://httpbin.org/");
+});
+
+test("cleanup keeps a path below the root", (t) => {
+ assert.strictEqual(
+ cleanStrict("alice.example.com/exchange"),
+ "https://alice.example.com/exchange/",
+ );
+});
+
+test("cleanup trims surrounding whitespace", (t) => {
+ assert.strictEqual(
+ cleanStrict(" exchange.example.com \n"),
+ "https://exchange.example.com/",
+ );
+});
+
+test("cleanup lowercases the host", (t) => {
+ assert.strictEqual(
+ cleanStrict("Exchange.EXAMPLE.com"),
+ "https://exchange.example.com/",
+ );
+});
+
+test("cleanup strips query and fragment", (t) => {
+ assert.strictEqual(
+ cleanStrict("https://exchange.example.com/?foo=bar#frag"),
+ "https://exchange.example.com/",
+ );
+});
+
+test("cleanup rejects a non-HTTPS scheme", (t) => {
+ const res = cleanUpExchangeBaseUrl("http://exchange.example.com", {
+ allowHttp: false,
+ });
+ assert.strictEqual(res.status, "bad-syntax");
+ const ftp = cleanUpExchangeBaseUrl("ftp://exchange.example.com", {
+ allowHttp: false,
+ });
+ assert.strictEqual(ftp.status, "bad-syntax");
+});
+
+test("cleanup rejects a non-HTTP scheme even when http is allowed", (t) => {
+ const res = cleanUpExchangeBaseUrl("ftp://exchange.example.com", {
+ allowHttp: true,
+ });
+ assert.strictEqual(res.status, "bad-syntax");
+});
+
+test("cleanup rejects empty and unparsable input", (t) => {
+ assert.strictEqual(
+ cleanUpExchangeBaseUrl(" ", { allowHttp: false }).status,
+ "bad-syntax",
+ );
+ assert.strictEqual(
+ cleanUpExchangeBaseUrl("https://", { allowHttp: false }).status,
+ "bad-syntax",
+ );
+ assert.strictEqual(
+ cleanUpExchangeBaseUrl("exchange example.com", { allowHttp: false }).status,
+ "bad-syntax",
+ );
+});
+
+test("cleanup rejects credentials in the URL", (t) => {
+ const res = cleanUpExchangeBaseUrl(
+ "https://exchange.example.com@evil.example.com/",
+ { allowHttp: false },
+ );
+ assert.strictEqual(res.status, "bad-syntax");
+});
+
+test("cleanup drops the port when HTTPS is enforced", (t) => {
+ assert.strictEqual(
+ cleanStrict("https://exchange.example.com:8081/"),
+ "https://exchange.example.com/",
+ );
+});
+
+test("cleanup keeps http and the port for local development", (t) => {
+ assert.strictEqual(
+ cleanRelaxed("http://localhost:8081/"),
+ "http://localhost:8081/",
+ );
+ assert.strictEqual(cleanRelaxed("localhost:8081"), "https://localhost:8081/");
+});
+
+test("a bare domain gets an exchange subdomain candidate", (t) => {
+ assert.deepStrictEqual(exchangeBaseUrlCandidates("https://example.com/"), [
+ "https://example.com/",
+ "https://exchange.example.com/",
+ ]);
+});
+
+test("a host that is already an exchange subdomain is used directly", (t) => {
+ assert.deepStrictEqual(
+ exchangeBaseUrlCandidates("https://exchange.example.com/"),
+ ["https://exchange.example.com/"],
+ );
+});
+
+test("no subdomain candidate where it could not name anything", (t) => {
+ // A URL with a path.
+ assert.deepStrictEqual(
+ exchangeBaseUrlCandidates("https://example.com/exchange/"),
+ ["https://example.com/exchange/"],
+ );
+ // A host without a dot.
+ assert.deepStrictEqual(exchangeBaseUrlCandidates("http://localhost:8081/"), [
+ "http://localhost:8081/",
+ ]);
+ // IP literals.
+ assert.deepStrictEqual(exchangeBaseUrlCandidates("https://192.0.2.1/"), [
+ "https://192.0.2.1/",
+ ]);
+ assert.deepStrictEqual(exchangeBaseUrlCandidates("https://[2001:db8::1]/"), [
+ "https://[2001:db8::1]/",
+ ]);
+});
+
+test("levenshtein distance counts single-character edits", (t) => {
+ assert.strictEqual(levenshteinDistance("", ""), 0);
+ assert.strictEqual(levenshteinDistance("", "abc"), 3);
+ assert.strictEqual(levenshteinDistance("abc", ""), 3);
+ assert.strictEqual(levenshteinDistance("abc", "abc"), 0);
+ assert.strictEqual(levenshteinDistance("kitten", "sitting"), 3);
+ assert.strictEqual(levenshteinDistance("flaw", "lawn"), 2);
+});
+
+test("similarity is symmetric and bounded", (t) => {
+ assert.strictEqual(stringSimilarity("", ""), 1);
+ assert.strictEqual(stringSimilarity("abc", "abc"), 1);
+ assert.strictEqual(stringSimilarity("abcd", "wxyz"), 0);
+ assert.strictEqual(
+ stringSimilarity("taler-ops.ch", "taler-opss.ch"),
+ stringSimilarity("taler-opss.ch", "taler-ops.ch"),
+ );
+});
+
+test("a typo is suggested a known exchange", (t) => {
+ const known = [
+ "https://exchange.demo.taler.net/",
+ "https://exchange.taler-ops.ch/",
+ ];
+ assert.deepStrictEqual(
+ findExchangeSuggestions("https://exchange.taler-opss.ch/", known),
+ ["https://exchange.taler-ops.ch/"],
+ );
+});
+
+test("a bare domain is suggested its exchange subdomain", (t) => {
+ const known = ["https://exchange.taler-ops.ch/"];
+ assert.deepStrictEqual(
+ findExchangeSuggestions("https://taler-ops.ch/", known),
+ ["https://exchange.taler-ops.ch/"],
+ );
+});
+
+test("an unrelated host gets no suggestion", (t) => {
+ const known = [
+ "https://exchange.demo.taler.net/",
+ "https://exchange.taler-ops.ch/",
+ ];
+ assert.deepStrictEqual(
+ findExchangeSuggestions("https://totally-different.example/", known),
+ [],
+ );
+});
+
+test("the input itself is never suggested back", (t) => {
+ const known = ["https://exchange.taler-ops.ch/"];
+ assert.deepStrictEqual(
+ findExchangeSuggestions("https://exchange.taler-ops.ch/", known),
+ [],
+ );
+});
+
+test("part matches sort ahead of fuzzy matches", (t) => {
+ const known = [
+ // Only similar enough to clear the threshold.
+ "https://taler-opsx.ch/",
+ // Contains the searched-for host.
+ "https://exchange.taler-ops.ch/",
+ ];
+ assert.deepStrictEqual(
+ findExchangeSuggestions("https://taler-ops.ch/", known),
+ ["https://exchange.taler-ops.ch/", "https://taler-opsx.ch/"],
+ );
+});
diff --git a/packages/taler-wallet-core/src/exchange-base-url.ts b/packages/taler-wallet-core/src/exchange-base-url.ts
@@ -0,0 +1,329 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Network-free part of turning user-provided input into a canonical exchange
+ * base URL: cleanup, candidate generation and fuzzy matching against the
+ * exchanges the wallet already knows.
+ *
+ * The network probe on top of this lives in requests.ts.
+ */
+
+/**
+ * Imports.
+ */
+import { URL } from "@gnu-taler/taler-util";
+
+/**
+ * Characters that may legally occur in the URL a user typed.
+ *
+ * Anything outside this set is rejected before parsing, so that a stray
+ * character does not silently end up percent-encoded into a host name.
+ */
+const urlCharRegex = /^[a-zA-Z0-9\-_.~!*'();:@&=+$,/?%#[\]]+$/;
+
+/**
+ * Highest fraction of the longer hostname that may differ for two hostnames
+ * to still be considered a possible typo of one another.
+ */
+const defaultSimilarityThreshold = 0.7;
+
+/**
+ * Parse @a url, or return undefined if it is not a URL at all.
+ *
+ * @param url string to parse
+ * @returns the parsed URL, or undefined
+ */
+// The return type is inferred on purpose: taler-util exports the URL
+// constructor as a value, but not the interface it produces, so there is no
+// name to write here.
+function tryParseUrl(url: string) {
+ try {
+ return new URL(url);
+ } catch {
+ return undefined;
+ }
+}
+
+export interface ExchangeBaseUrlCleanupOptions {
+ /**
+ * Accept "http://" input instead of rejecting it, and keep an explicit port
+ * number in the result.
+ *
+ * Both relaxations exist for the same reason: a wallet configured for local
+ * development or for the test harness talks to exchanges that are reachable
+ * only as "http://localhost:<port>/".
+ */
+ allowHttp: boolean;
+}
+
+export type ExchangeBaseUrlCleanupResult =
+ | {
+ status: "ok";
+ /** Cleaned-up base URL, with a scheme and a trailing slash. */
+ baseUrl: string;
+ }
+ | {
+ status: "bad-syntax";
+ /** Human-readable reason, for the error detail of the API response. */
+ detail: string;
+ };
+
+/**
+ * Bring user-provided input into the shape of a base URL, without contacting
+ * anyone: add the default scheme, enforce HTTPS, drop the port, query and
+ * fragment, and make sure the path ends in a slash.
+ *
+ * The path is deliberately kept: an exchange may be hosted below the root of
+ * its host, and dropping the path would make such an exchange impossible to
+ * reach through this API.
+ *
+ * @param input raw string as typed by the user
+ * @param options relaxations for local development
+ * @returns the cleaned-up base URL, or why the input cannot be one
+ */
+export function cleanUpExchangeBaseUrl(
+ input: string,
+ options: ExchangeBaseUrlCleanupOptions,
+): ExchangeBaseUrlCleanupResult {
+ const trimmed = input.trim();
+
+ if (trimmed === "") {
+ return { status: "bad-syntax", detail: "empty URL" };
+ }
+
+ if (!urlCharRegex.test(trimmed)) {
+ return {
+ status: "bad-syntax",
+ detail: "URL contains characters that are not allowed in a URL",
+ };
+ }
+
+ // Test for a scheme, not for the letters "http": "httpbin.org" starts with
+ // them but still needs one prepended.
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
+ ? trimmed
+ : `https://${trimmed}`;
+
+ const parsed = tryParseUrl(withScheme);
+ if (parsed == null) {
+ return { status: "bad-syntax", detail: "URL cannot be parsed" };
+ }
+
+ if (parsed.protocol !== "https:") {
+ if (parsed.protocol !== "http:" || !options.allowHttp) {
+ return {
+ status: "bad-syntax",
+ detail: `scheme "${parsed.protocol}" is not supported, expected "https:"`,
+ };
+ }
+ }
+
+ if (parsed.hostname === "") {
+ return { status: "bad-syntax", detail: "URL has no host" };
+ }
+
+ // Credentials in a base URL are never meaningful for an exchange, and
+ // "https://exchange.example.com@evil.example.com/" reads to a human as if it
+ // pointed at the first of the two hosts.
+ if (parsed.username !== "" || parsed.password !== "") {
+ return {
+ status: "bad-syntax",
+ detail: "URL must not contain credentials",
+ };
+ }
+
+ // Assembled from the parsed components instead of mutating the parsed URL:
+ // the setters of our URL implementation do not round-trip (assigning
+ // hostname turns a path of "/" into "//"), and building the string drops
+ // query and fragment without a further step.
+ //
+ // The parser leaves the case of the host alone, but a base URL that differs
+ // from another only in case is the same base URL.
+ const host = parsed.hostname.toLowerCase();
+ const port = options.allowHttp ? parsed.port : "";
+ const authority = port === "" ? host : `${host}:${port}`;
+ const path = parsed.pathname.endsWith("/")
+ ? parsed.pathname
+ : `${parsed.pathname}/`;
+
+ return { status: "ok", baseUrl: `${parsed.protocol}//${authority}${path}` };
+}
+
+/**
+ * True if @a hostname is an IPv4 or IPv6 literal rather than a domain name.
+ */
+function isIpLiteral(hostname: string): boolean {
+ // The URL parser wraps IPv6 literals in square brackets.
+ if (hostname.startsWith("[")) {
+ return true;
+ }
+ return /^[0-9.]+$/.test(hostname);
+}
+
+/**
+ * List the base URLs to probe for a cleaned-up input, most likely first.
+ *
+ * A bare domain gets "exchange.<domain>" as a second candidate, since that is
+ * where an exchange is conventionally deployed. The extra candidate only makes
+ * sense for a domain name at the root of its host: prefixing a subdomain onto
+ * an IP literal, onto a name without a dot, or onto a URL that already carries
+ * a path would not name anything.
+ *
+ * @param baseUrl cleaned-up base URL from {@link cleanUpExchangeBaseUrl}
+ * @returns candidate base URLs, without duplicates
+ */
+export function exchangeBaseUrlCandidates(baseUrl: string): string[] {
+ const candidates = [baseUrl];
+ const parsed = new URL(baseUrl);
+
+ if (
+ parsed.pathname === "/" &&
+ !parsed.hostname.startsWith("exchange.") &&
+ parsed.hostname.includes(".") &&
+ !isIpLiteral(parsed.hostname)
+ ) {
+ // Assembled rather than assigned through the hostname setter, which
+ // re-parses and would leave the path doubled up.
+ candidates.push(`${parsed.protocol}//exchange.${parsed.host}/`);
+ }
+
+ return candidates;
+}
+
+/**
+ * Levenshtein edit distance between @a a and @a b.
+ *
+ * @param a first string
+ * @param b second string
+ * @returns number of single-character edits that turn @a a into @a b
+ */
+export function levenshteinDistance(a: string, b: string): number {
+ if (a === b) {
+ return 0;
+ }
+ if (a.length === 0) {
+ return b.length;
+ }
+ if (b.length === 0) {
+ return a.length;
+ }
+
+ // Only the previous row of the edit matrix is ever read, so keep just that.
+ let prevRow = new Array<number>(b.length + 1);
+ let curRow = new Array<number>(b.length + 1);
+ for (let j = 0; j <= b.length; j++) {
+ prevRow[j] = j;
+ }
+
+ for (let i = 1; i <= a.length; i++) {
+ curRow[0] = i;
+ for (let j = 1; j <= b.length; j++) {
+ const substitutionCost = a[i - 1] === b[j - 1] ? 0 : 1;
+ curRow[j] = Math.min(
+ curRow[j - 1] + 1,
+ prevRow[j] + 1,
+ prevRow[j - 1] + substitutionCost,
+ );
+ }
+ const tmp = prevRow;
+ prevRow = curRow;
+ curRow = tmp;
+ }
+
+ return prevRow[b.length];
+}
+
+/**
+ * Similarity of two strings, as one minus the share of the longer string that
+ * would have to be edited to obtain the other.
+ *
+ * @param a first string
+ * @param b second string
+ * @returns a value in [0,1], where 1 means the strings are equal
+ */
+export function stringSimilarity(a: string, b: string): number {
+ const longest = Math.max(a.length, b.length);
+ if (longest === 0) {
+ return 1;
+ }
+ return 1 - levenshteinDistance(a, b) / longest;
+}
+
+export interface ExchangeSuggestionOptions {
+ /**
+ * Minimum similarity of two hostnames for one to be suggested for the other.
+ */
+ threshold?: number;
+}
+
+/**
+ * Pick the base URLs among @a knownBaseUrls whose host looks like what the
+ * user meant to type, so that a failed completion can still offer a way
+ * forward.
+ *
+ * A known host matches when one hostname contains the other — typing
+ * "taler-ops.ch" for "exchange.taler-ops.ch" is a completion, not a typo — or
+ * when the two hostnames are similar enough to be a typo of one another.
+ *
+ * @param baseUrl cleaned-up base URL the user asked for
+ * @param knownBaseUrls base URLs of the exchanges the wallet knows
+ * @param options matching options
+ * @returns matching entries of @a knownBaseUrls, most similar first
+ */
+export function findExchangeSuggestions(
+ baseUrl: string,
+ knownBaseUrls: string[],
+ options: ExchangeSuggestionOptions = {},
+): string[] {
+ const threshold = options.threshold ?? defaultSimilarityThreshold;
+
+ const wanted = tryParseUrl(baseUrl);
+ if (wanted == null) {
+ return [];
+ }
+ const wantedHost = wanted.hostname;
+
+ const scored: { baseUrl: string; similarity: number }[] = [];
+ const seen = new Set<string>();
+
+ for (const knownBaseUrl of knownBaseUrls) {
+ if (knownBaseUrl === baseUrl || seen.has(knownBaseUrl)) {
+ continue;
+ }
+ const known = tryParseUrl(knownBaseUrl);
+ if (known == null) {
+ continue;
+ }
+ const knownHost = known.hostname;
+ const similarity = stringSimilarity(wantedHost, knownHost);
+ const partMatch =
+ knownHost.includes(wantedHost) || wantedHost.includes(knownHost);
+ if (!partMatch && similarity < threshold) {
+ continue;
+ }
+ seen.add(knownBaseUrl);
+ // A part match is a stronger signal than an edit distance that happens to
+ // clear the threshold, so it sorts ahead of one.
+ scored.push({
+ baseUrl: knownBaseUrl,
+ similarity: partMatch ? 1 : similarity,
+ });
+ }
+
+ scored.sort((a, b) => b.similarity - a.similarity);
+ return scored.map((x) => x.baseUrl);
+}
diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts
@@ -84,6 +84,7 @@ import {
GetQrCodesForPaytoResponse,
HintNetworkAvailabilityRequest,
HostPortPath,
+ HttpStatusCode,
ImportDbFromFileRequest,
ImportDbRequest,
InitRequest,
@@ -119,6 +120,8 @@ import {
TalerBankIntegrationHttpClient,
TalerError,
TalerErrorCode,
+ TalerErrorDetail,
+ TalerExchangeHttpClient,
TalerProtocolTimestamp,
TalerUriAction,
TalerUris,
@@ -133,6 +136,7 @@ import {
TestingWaitExchangeReadyRequest,
TransactionType,
TransactionsResponse,
+ URL,
UpdateExchangeEntryRequest,
ValidateIbanRequest,
ValidateIbanResponse,
@@ -141,6 +145,7 @@ import {
WireTypeDetails,
WithdrawTestBalanceRequest,
canonicalizeBaseUrl,
+ carefullyParseConfig,
checkDbInvariant,
codecForAbortTransaction,
codecForAcceptBankIntegratedWithdrawalRequest,
@@ -177,6 +182,7 @@ import {
codecForDeleteSubscriptionRequest,
codecForDeleteTransactionRequest,
codecForEmptyObject,
+ codecForExchangeConfig,
codecForExportDbToFileRequest,
codecForFailTransactionRequest,
codecForForceRefreshRequest,
@@ -263,6 +269,7 @@ import {
setGlobalLogLevelFromString,
validateIban,
} from "@gnu-taler/taler-util";
+import { HttpResponse } from "@gnu-taler/taler-util/http";
import { getBalanceDetail, getBalances } from "./balance.js";
import {
getMaxDepositAmount,
@@ -292,6 +299,11 @@ import {
handleSetDonau,
} from "./donau.js";
import {
+ cleanUpExchangeBaseUrl,
+ exchangeBaseUrlCandidates,
+ findExchangeSuggestions,
+} from "./exchange-base-url.js";
+import {
acceptExchangeTermsOfService,
deleteEphemeralExchanges,
deleteExchange,
@@ -789,28 +801,245 @@ async function handleRecoverStoredBackup(
return {};
}
-const urlCharRegex = /^[a-zA-Z0-9\-_.~!*'();:@&=+$,/?%#[\]]+$/;
+/**
+ * Highest number of HTTP redirects followed while probing one candidate base
+ * URL, so that a redirect loop cannot keep the completion running forever.
+ */
+const maxCompletionRedirects = 5;
+
+type ExchangeProbeResult =
+ | {
+ status: "ok";
+ /** Base URL that the validated /config response was served under. */
+ baseUrl: string;
+ }
+ | {
+ status: "bad-network" | "bad-exchange";
+ error: TalerErrorDetail;
+ };
+
+/**
+ * True for the status codes that carry a "location" header we should follow.
+ */
+function isRedirectStatus(status: number): boolean {
+ switch (status) {
+ case HttpStatusCode.MovedPermanently:
+ case HttpStatusCode.Found:
+ case HttpStatusCode.SeeOther:
+ case HttpStatusCode.TemporaryRedirect:
+ case HttpStatusCode.PermanentRedirect:
+ return true;
+ default:
+ return false;
+ }
+}
+
+/**
+ * Decide whether a failed request never reached a server, or reached one that
+ * answered with something an exchange would not answer.
+ */
+function classifyProbeError(
+ error: TalerErrorDetail,
+): "bad-network" | "bad-exchange" {
+ switch (error.code) {
+ case TalerErrorCode.WALLET_NETWORK_ERROR:
+ case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT:
+ case TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED:
+ return "bad-network";
+ default:
+ return "bad-exchange";
+ }
+}
+
+/**
+ * Check whether an exchange is served under @a baseUrl by requesting its
+ * /config and validating the response.
+ *
+ * Redirects are followed here instead of by the HTTP layer, because the base
+ * URL to report back is the one the final response came from, and the HTTP
+ * layer does not expose it. The qtart runtime follows redirects natively and
+ * ignores the "manual" setting, so on that platform a redirected exchange
+ * validates but is reported under the base URL we asked for.
+ *
+ * @param wex wallet execution context
+ * @param baseUrl candidate base URL, already cleaned up
+ * @param allowHttp whether a plain-HTTP redirect target is acceptable
+ * @returns the base URL that answered, or why the candidate was rejected
+ */
+async function probeExchangeBaseUrl(
+ wex: WalletExecutionContext,
+ baseUrl: string,
+ allowHttp: boolean,
+): Promise<ExchangeProbeResult> {
+ let configUrl = new URL("config", baseUrl).href;
+
+ for (let redirectCount = 0; ; redirectCount++) {
+ let resp: HttpResponse;
+ try {
+ resp = await wex.http.fetch(configUrl, {
+ redirect: "manual",
+ cancellationToken: wex.cancellationToken,
+ });
+ } catch (e) {
+ const error = getErrorDetailFromException(e);
+ return { status: classifyProbeError(error), error };
+ }
+
+ if (isRedirectStatus(resp.status)) {
+ const location = resp.headers.get("location");
+ if (location == null) {
+ return {
+ status: "bad-exchange",
+ error: {
+ code: TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ requestUrl: configUrl,
+ httpStatusCode: resp.status,
+ detail: "redirect without a location header",
+ },
+ };
+ }
+ if (redirectCount >= maxCompletionRedirects) {
+ return {
+ status: "bad-exchange",
+ error: {
+ code: TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ requestUrl: configUrl,
+ httpStatusCode: resp.status,
+ detail: `too many redirects (more than ${maxCompletionRedirects})`,
+ },
+ };
+ }
+ let target: string;
+ try {
+ target = new URL(location, configUrl).href;
+ } catch (e) {
+ return {
+ status: "bad-exchange",
+ error: {
+ code: TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ requestUrl: configUrl,
+ httpStatusCode: resp.status,
+ detail: `redirect to an unparsable location (${location})`,
+ },
+ };
+ }
+ const targetProtocol = new URL(target).protocol;
+ if (
+ targetProtocol !== "https:" &&
+ !(targetProtocol === "http:" && allowHttp)
+ ) {
+ return {
+ status: "bad-exchange",
+ error: {
+ code: TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ requestUrl: configUrl,
+ httpStatusCode: resp.status,
+ detail: `redirect to a non-HTTPS location (${target})`,
+ },
+ };
+ }
+ configUrl = target;
+ continue;
+ }
+
+ try {
+ await carefullyParseConfig(
+ "taler-exchange",
+ TalerExchangeHttpClient.SUPPORTED_EXCHANGE_PROTOCOL_VERSION,
+ resp,
+ codecForExchangeConfig(),
+ );
+ } catch (e) {
+ return {
+ status: "bad-exchange",
+ error: getErrorDetailFromException(e),
+ };
+ }
+
+ // We can only vouch for a base URL whose /config we actually read, so a
+ // server that redirects /config somewhere else entirely gets rejected
+ // rather than reported under a base URL we never validated.
+ const parsedConfigUrl = new URL(configUrl);
+ if (!parsedConfigUrl.pathname.endsWith("/config")) {
+ return {
+ status: "bad-exchange",
+ error: {
+ code: TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ requestUrl: configUrl,
+ httpStatusCode: resp.status,
+ detail: "config was served under a path that is not <base>/config",
+ },
+ };
+ }
+
+ return { status: "ok", baseUrl: new URL(".", configUrl).href };
+ }
+}
export async function handleCompleteExchangeBaseUrl(
wex: WalletExecutionContext,
req: CompleteBaseUrlRequest,
): Promise<CompleteBaseUrlResult> {
- const trimmedUrl = req.url.trim();
+ const allowHttp = wex.ws.config.features.allowHttp;
- if (!urlCharRegex.test(trimmedUrl)) {
+ const cleanup = cleanUpExchangeBaseUrl(req.url, { allowHttp });
+ if (cleanup.status !== "ok") {
return {
status: "bad-syntax",
error: {
code: TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ detail: cleanup.detail,
},
};
}
- // FIXME: Do completion via network.
- return {
- completion: canonicalizeBaseUrl(trimmedUrl),
- status: "ok",
- };
+ // Probing every candidate can take as long as the network takes to time
+ // out, so a client that passes a progress token gets the "delayed"/
+ // "stalled" phases and can cancel. There is deliberately no retry loop
+ // around the probe: an exchange that cannot be reached is the answer this
+ // request exists to give, not a failure to retry until it goes away.
+ return await runWithMaybeProgressContext(
+ wex,
+ "completeExchangeBaseUrl",
+ req.progressToken,
+ async () => {
+ const candidates = exchangeBaseUrlCandidates(cleanup.baseUrl);
+ let lastFailure: Extract<
+ ExchangeProbeResult,
+ { status: "bad-network" | "bad-exchange" }
+ > = {
+ status: "bad-network",
+ error: {
+ code: TalerErrorCode.WALLET_NETWORK_ERROR,
+ detail: "no candidate base URL could be probed",
+ },
+ };
+
+ for (const candidate of candidates) {
+ const probeResult = await probeExchangeBaseUrl(
+ wex,
+ candidate,
+ allowHttp,
+ );
+ if (probeResult.status === "ok") {
+ return { status: "ok", completion: probeResult.baseUrl };
+ }
+ lastFailure = probeResult;
+ }
+
+ const knownExchanges = await listExchanges(wex, {});
+ const suggestions = findExchangeSuggestions(
+ cleanup.baseUrl,
+ knownExchanges.exchanges.map((x) => x.exchangeBaseUrl),
+ );
+
+ return {
+ status: lastFailure.status,
+ error: lastFailure.error,
+ ...(suggestions.length > 0 ? { suggestions } : {}),
+ };
+ },
+ );
}
async function handleSetWalletRunConfig(