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