taler-typescript-core

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

commit 3c416530e3176c11cb71c14e2b47ffd5f0bf148f
parent acb99a89afb91ba4e88f78bbf7533d63a9cb2dd2
Author: Florian Dold <dold@taler.net>
Date:   Mon, 20 Jul 2026 20:43:16 +0200

util: fix payto and taler URI parsing and normalization

Looking up the target type on a plain object literal matched inherited keys,
so "__proto__" reached assertUnreachable and threw out of an API documented to
return a Result.  parseRestore threw likewise on malformed percent-encoding
and on a host it could not parse.

Normalization gave one account two different H_PAYTO values: void duplicated
its target type, an unsupported target serialized as "undefined", the
canonical IBAN was discarded, and toLocaleLowerCase made the result depend on
the host locale.  Also fix the query being split at a second "?" and IBAN
check digits not being required to be numeric.

Diffstat:
Mpackages/taler-util/src/iban.ts | 8+++++++-
Mpackages/taler-util/src/payto.ts | 47++++++++++++++++++++++++++++++++---------------
Mpackages/taler-util/src/taleruri.ts | 34++++++++++++++++++++++++++--------
3 files changed, 65 insertions(+), 24 deletions(-)

diff --git a/packages/taler-util/src/iban.ts b/packages/taler-util/src/iban.ts @@ -181,10 +181,16 @@ export function convertCHF_BBANtoIBAN( export function parseIban( ibanString: string, ): Result<IbanString, ParseIbanError> { - const myIban = ibanString.toUpperCase().replace(/[\s-\._]/g, ""); + const myIban = ibanString.toUpperCase().replace(/[\s\-._]/g, ""); if (myIban.length < 4) { return Result.error(ParseIbanError.TOO_SHORT); } + // ISO 13616: two country letters, two numeric check digits, then the BBAN. + // appendDigit expands A-Z into two digits, so alphabetic check digits would + // otherwise still satisfy mod-97-10. + if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]*$/.test(myIban)) { + return Result.error(ParseIbanError.INVALID_CHARSET); + } if (myIban.length > 34) { return Result.error(ParseIbanError.TOO_LONG); } diff --git a/packages/taler-util/src/payto.ts b/packages/taler-util/src/payto.ts @@ -222,7 +222,10 @@ export namespace Paytos { * @returns */ export function toNormalizedString(p: URI): NormalizedPaytoString { - const url = new URL(`${PAYTO_PREFIX}${p.targetType}/${p.normalizedPath}`); + // An unsupported target type keeps the raw value in `target`, since it is + // by definition not a member of PaytoType. + const target = p.targetType ?? (p as PaytoUnsupported).target; + const url = new URL(`${PAYTO_PREFIX}${target}/${p.normalizedPath}`); return url.href as NormalizedPaytoString; } /** @@ -236,7 +239,8 @@ export namespace Paytos { * @returns */ export function toFullString(p: URI): FullPaytoString { - const url = new URL(`${PAYTO_PREFIX}${p.targetType}/${p.fullPath}`); + const target = p.targetType ?? (p as PaytoUnsupported).target; + const url = new URL(`${PAYTO_PREFIX}${target}/${p.fullPath}`); const paramList = !p.params ? [] : Object.entries(p.params); url.search = createSearchParams(paramList); return url.href as FullPaytoString; @@ -297,7 +301,6 @@ export namespace Paytos { url.hash = ""; return url.href as HostPortPath; } catch (e) { - console.log(e); return undefined; } } @@ -368,7 +371,7 @@ export namespace Paytos { targetType: undefined, target: targetType, params, - normalizedPath: path.toLocaleLowerCase(), + normalizedPath: path.toLowerCase(), fullPath: path, displayName: path, }; @@ -407,7 +410,7 @@ export namespace Paytos { reservePub, segwitAddrs, params, - normalizedPath: address.toLocaleLowerCase(), + normalizedPath: address.toLowerCase(), fullPath: !reservePub ? address : `${address}/${encodeCrock(reservePub)}`, displayName: address, }; @@ -439,7 +442,7 @@ export namespace Paytos { exchange, reservePub, params, - normalizedPath: `${path.toLocaleLowerCase()}${pub}`, + normalizedPath: `${path.toLowerCase()}${pub}`, fullPath: `${path}${pub}`, displayName: `${path}@${pub}`, }; @@ -456,7 +459,7 @@ export namespace Paytos { url, account, params, - normalizedPath: `${path.toLocaleLowerCase()}${account}`, + normalizedPath: `${path.toLowerCase()}${account}`, fullPath: `${path}${account}`, displayName: `${account}@${path}`, }; @@ -474,7 +477,7 @@ export namespace Paytos { exchange, reservePub, params, - normalizedPath: `${path.toLocaleLowerCase()}${pub}`, + normalizedPath: `${path.toLowerCase()}${pub}`, fullPath: `${path}${pub}`, displayName: `${path}@${pub}`, }; @@ -493,7 +496,7 @@ export namespace Paytos { url, account, params, - normalizedPath: `${path.toLocaleLowerCase()}${account}`, + normalizedPath: `${path.toLowerCase()}${account}`, fullPath: `${path}${account}`, displayName: `${account}@${url}`, }; @@ -506,8 +509,8 @@ export namespace Paytos { return { targetType: PaytoType.Void, params, - normalizedPath: `void/${voidPath}`, - fullPath: `void/${voidPath}`, + normalizedPath: voidPath, + fullPath: voidPath, displayName: `void(${voidPath})`, }; } @@ -601,14 +604,21 @@ export namespace Paytos { return Result.error(PaytoParseError.WRONG_PREFIX); } - const [acct, search] = s.slice(PAYTO_PREFIX.length).split("?", 2); + // RFC 3986 section 3.4: "?" is legal inside the query. + const rest = s.slice(PAYTO_PREFIX.length); + const queryPos = rest.indexOf("?"); + const acct = queryPos === -1 ? rest : rest.slice(0, queryPos); + const search = queryPos === -1 ? undefined : rest.slice(queryPos + 1); const firstSlashPos = acct.indexOf("/"); const targetType = ( firstSlashPos === -1 ? acct : acct.slice(0, firstSlashPos) ) as PaytoType; - if (!opts.allowUnsupported && !supportedTargets[targetType]) { + if ( + !opts.allowUnsupported && + !Object.prototype.hasOwnProperty.call(supportedTargets, targetType) + ) { return Result.errorWithDetail(PaytoParseError.UNSUPPORTED, { targetType, }); @@ -648,7 +658,11 @@ export namespace Paytos { } as const); } - return Result.of(createIban(iban as IbanString, bic, params)); + // Use the canonical form, so that H_PAYTO does not depend on how + // the IBAN was written. + const canonicalIban = + ibaRes.tag === "ok" ? Result.unpack(ibaRes) : (iban as IbanString); + return Result.of(createIban(canonicalIban, bic, params)); } case PaytoType.Bitcoin: { if (cs.length !== 1 && cs.length !== 2) { @@ -657,7 +671,7 @@ export namespace Paytos { }); } - const address = cs[0].toLocaleLowerCase(); + const address = cs[0].toLowerCase(); const btRes = BitcoinBech32.decode( address, BitcoinBech32.Encodings.BECH32, @@ -934,6 +948,9 @@ export function addPaytoQueryParams( s: string, params: WellKnownPaytoParams, ): string { + if (!s.startsWith(PAYTO_PREFIX)) { + throw Error("not a payto URI"); + } const [acct, search] = s.slice(PAYTO_PREFIX.length).split("?"); const searchParams = new URLSearchParams(search || ""); for (const [paramKey, paramValue] of Object.entries(params)) { diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts @@ -349,6 +349,9 @@ export namespace TalerUris { export type InvalidTargetPathDetail = | { + uriType: TalerUriAction.Restore; + } + | { uriType: TalerUriAction.Pay; } | { @@ -426,9 +429,11 @@ export namespace TalerUris { const scheme = isHttp ? ("http" as const) : ("https" as const); // get path and search - const [path, search] = s - .slice((isHttp ? TALER_HTTP_PREFIX : TALER_PREFIX).length) - .split("?", 2); + // RFC 3986 section 3.4: "?" is legal inside the query. + const rest = s.slice((isHttp ? TALER_HTTP_PREFIX : TALER_PREFIX).length); + const queryPos = rest.indexOf("?"); + const path = queryPos === -1 ? rest : rest.slice(0, queryPos); + const search = queryPos === -1 ? undefined : rest.slice(queryPos + 1); // check if supported const firstSlashPos = path.indexOf("/"); @@ -440,7 +445,7 @@ export namespace TalerUris { ? (uriTypeUncased.toLowerCase() as TalerUriAction) : uriTypeUncased; - if (!supportedTargets[uriType]) { + if (!Object.prototype.hasOwnProperty.call(supportedTargets, uriType)) { return Result.errorWithDetail(TalerUriParseError.UNSUPPORTED, { uriType, }); @@ -810,8 +815,16 @@ function parseRestore( const walletPriv = cs[0]; // FIXME: validate private key const providers: Array<HostPortPath> = []; // const providers = new Array<HostPortPath>(); - cs[1].split(",").map((name) => { - const url = decodeURIComponent(name); + for (const name of cs[1].split(",")) { + let url: string; + try { + url = decodeURIComponent(name); + } catch (e) { + // Malformed percent-encoding must yield a parse error, not a URIError. + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + uriType: TalerUriAction.Restore as const, + }); + } let isHttp = false; const withoutScheme = url.startsWith("https://") @@ -828,9 +841,14 @@ function parseRestore( const hostname = slashIdx < 0 ? withoutScheme : withoutScheme.slice(0, slashIdx); const path = slashIdx < 0 ? "" : withoutScheme.slice(slashIdx + 1); - const host = Paytos.parseHostPortPath2(hostname, path, thisScheme)!; + const host = Paytos.parseHostPortPath2(hostname, path, thisScheme); + if (host === undefined) { + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + uriType: TalerUriAction.Restore as const, + }); + } providers.push(host); - }); + } return Result.of({ type: TalerUriAction.Restore,