taler-typescript-core

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

commit 73127e67bf2bf796981c49571ef37877b094cc46
parent 9bff5113ba72a2d2c3032c73246686c13905e7eb
Author: Florian Dold <dold@taler.net>
Date:   Tue, 21 Jul 2026 01:46:06 +0200

util: encode taler URI path components and fix transfer-result round-trip

stringify interpolated components such as orderId, sessionId and alias
into the path raw, so a value containing "/", "?" or "#" broke the URI.

Diffstat:
Mpackages/taler-util/src/taleruri.ts | 104++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
1 file changed, 74 insertions(+), 30 deletions(-)

diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts @@ -292,34 +292,41 @@ export namespace TalerUris { */ switch (p.type) { case TalerUriAction.Withdraw: - return `/${asHost(p.bankIntegrationApiBaseUrl)}${p.withdrawalOperationId}`; + return `/${asHost(p.bankIntegrationApiBaseUrl)}${encodeUriSegment( + p.withdrawalOperationId, + )}`; case TalerUriAction.Pay: - return `/${asHost(p.merchantBaseUrl)}${p.orderId}/${p.sessionId}`; + return `/${asHost(p.merchantBaseUrl)}${encodeUriSegment( + p.orderId, + )}/${encodeUriSegment(p.sessionId)}`; case TalerUriAction.Refund: // refund should end with a / - return `/${asHost(p.merchantBaseUrl)}${p.orderId}/`; + return `/${asHost(p.merchantBaseUrl)}${encodeUriSegment(p.orderId)}/`; case TalerUriAction.PayTemplate: - return `/${asHost(p.merchantBaseUrl)}${p.templateId}`; + return `/${asHost(p.merchantBaseUrl)}${encodeUriSegment(p.templateId)}`; case TalerUriAction.PayPush: - return `/${asHost(p.exchangeBaseUrl)}${p.contractPriv}`; + return `/${asHost(p.exchangeBaseUrl)}${encodeUriSegment(p.contractPriv)}`; case TalerUriAction.PayPull: - return `/${asHost(p.exchangeBaseUrl)}${p.contractPriv}`; + return `/${asHost(p.exchangeBaseUrl)}${encodeUriSegment(p.contractPriv)}`; case TalerUriAction.AddExchange: return `/${asHost(p.exchangeBaseUrl)}`; case TalerUriAction.WithdrawExchange: return `/${asHost(p.exchangeBaseUrl)}`; case TalerUriAction.Restore: - return `/${p.walletRootPriv}/${p.providers + return `/${encodeUriSegment(p.walletRootPriv)}/${p.providers .map((d) => encodeURIComponent(d)) .join(",")}`; case TalerUriAction.DevExperiment: - return `/${p.devExperimentId}`; + return `/${encodeUriSegment(p.devExperimentId)}`; case TalerUriAction.WithdrawalTransferResult: - return `/`; + // No path component; the data is carried in query parameters. + return ``; case TalerUriAction.AddContact: - return `/${p.aliasType}/${p.alias}/${asHost( - p.mailboxBaseUri as HostPortPath, - )}/${p.mailboxIdentity}`; + return `/${encodeUriSegment(p.aliasType)}/${encodeUriSegment( + p.alias, + )}/${asHost(p.mailboxBaseUri as HostPortPath)}/${encodeUriSegment( + p.mailboxIdentity, + )}`; default: assertUnreachable(p); } @@ -451,8 +458,13 @@ export namespace TalerUris { }); } - const targetPath = path.slice(firstSlashPos + 1); - if (firstSlashPos === -1 || !targetPath) { + const targetPath = firstSlashPos === -1 ? "" : path.slice(firstSlashPos + 1); + // withdrawal-transfer-result carries all its data in query parameters and + // so legitimately has no path. + if ( + uriType !== TalerUriAction.WithdrawalTransferResult && + (firstSlashPos === -1 || !targetPath) + ) { return Result.errorWithDetail(TalerUriParseError.INCOMPLETE, { uriType, }); @@ -553,7 +565,7 @@ function parseWithdraw( } // get operation id - const operationId = cs[cs.length - 1]; + const operationId = decodeUriSegment(cs[cs.length - 1]); // get external confirmation const externalConfirmation = !params["external-confirmation"] ? undefined @@ -596,9 +608,9 @@ function parsePay( } // get order - const orderId = cs[cs.length - 2]; + const orderId = decodeUriSegment(cs[cs.length - 2]); // get session - const sessionId = cs[cs.length - 1]; + const sessionId = decodeUriSegment(cs[cs.length - 1]); return Result.of<TalerUri>({ type: TalerUriAction.Pay, @@ -639,7 +651,7 @@ function parsePayPush( } // get contract priv - const contractPriv = cs[cs.length - 1]; // FIXME: validate private key + const contractPriv = decodeUriSegment(cs[cs.length - 1]); // FIXME: validate private key if (!opts.ignoreComponentError && !contractPriv) { return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { @@ -682,7 +694,7 @@ function parsePayPull( }); } // get contract priv - const contractPriv = cs[cs.length - 1]; // FIXME: validate private key + const contractPriv = decodeUriSegment(cs[cs.length - 1]); // FIXME: validate private key if (!opts.ignoreComponentError && !contractPriv) { return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { uriType, @@ -732,7 +744,7 @@ function parseRefund( } // get order id - const orderId = cs[cs.length - 2]; + const orderId = decodeUriSegment(cs[cs.length - 2]); return Result.of({ type: TalerUriAction.Refund, merchantBaseUrl: merchant ?? (cs[0] as HostPortPath), @@ -767,7 +779,7 @@ function parsePayTemplate( error: merchant, }); } - const templateId = cs[cs.length - 1]; + const templateId = decodeUriSegment(cs[cs.length - 1]); const amountParam = params["amount"]; let amount: AmountString | undefined; @@ -812,7 +824,7 @@ function parseRestore( }); } - const walletPriv = cs[0]; // FIXME: validate private key + const walletPriv = decodeUriSegment(cs[0]); // FIXME: validate private key const providers: Array<HostPortPath> = []; // const providers = new Array<HostPortPath>(); for (const name of cs[1].split(",")) { @@ -871,7 +883,7 @@ function parseDevExperiment( }); } - const devExperimentId = cs[0]; + const devExperimentId = decodeUriSegment(cs[0]); return Result.of({ type: TalerUriAction.DevExperiment, @@ -944,16 +956,17 @@ function parseWithdrawExchange( function parseWithdrawalTransferResult( _scheme: "http" | "https", uriType: TalerUriAction.WithdrawalTransferResult, - cs: string[], + _cs: string[], params: Record<string, string>, _opts: TalerUris.PaytoParseOptions = {}, ): TalerUris.ParseResult { - if (cs.length === 0) { - return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { + const ref = params["ref"]; + if (!ref) { + return Result.errorWithDetail(TalerUriParseError.INVALID_PARAMETER, { uriType, + name: "ref", }); } - const ref = params["ref"]; const status = params["status"] !== "aborted" && params["status"] !== "success" ? undefined @@ -1012,8 +1025,8 @@ function parseAddContact( }); } - const aliasType = cs[0]; - const alias = cs[1]; + const aliasType = decodeUriSegment(cs[0]); + const alias = decodeUriSegment(cs[1]); const mailboxBaseUri = Paytos.parseHostPortPath2( cs[2], @@ -1027,7 +1040,7 @@ function parseAddContact( error: mailboxBaseUri, }); } - const mailboxIdentity = cs[cs.length - 1]; + const mailboxIdentity = decodeUriSegment(cs[cs.length - 1]); return Result.of({ type: TalerUriAction.AddContact, @@ -1143,6 +1156,37 @@ function encodeRFC3986URIComponent(str: string): string { const rfc3986 = encodeRFC3986URIComponent; /** + * Encode a value as a single taler:// URI path segment. + * + * "." and ".." are resolved away by the URL parser, even when percent-encoded, + * so a component equal to either cannot be represented and is rejected rather + * than silently erasing the preceding path. + */ +function encodeUriSegment(s: string): string { + if (s === "." || s === "..") { + throw Error(`cannot represent taler:// path segment ${JSON.stringify(s)}`); + } + // Only the characters that would break the positional/query parsing are + // escaped, so that components without them keep their on-the-wire form. + return s.replace( + /[%/?#]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +/** + * Decode a single taler:// URI path segment. Falls back to the raw value for + * input that is not valid percent-encoding, so parsing never throws on it. + */ +function decodeUriSegment(s: string): string { + try { + return decodeURIComponent(s); + } catch (e) { + return s; + } +} + +/** * * https://www.rfc-editor.org/rfc/rfc3986 */