taler-typescript-core

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

commit 68011403fc3dbf5292514d5a0c87b5f4e3da8a71
parent 3b80d29905596e3cc4a103f136e16800c6d62af5
Author: Florian Dold <dold@taler.net>
Date:   Mon, 20 Jul 2026 20:43:44 +0200

util: escape user-supplied URL path segments

Path parameters were interpolated into templates passed to new URL(), which
resolves structure rather than escaping it, so "/" injects segments and ".."
eliminates the one before it.  One of 113 call sites escaped its parameter.

Also fix donau getSeed fetching /keys, bank-wire getTransferStatus decoding a
single status with the list codec, taldir and mailbox discarding the base
path, addPaginationParams always sending limit=-5, and bank-integration
always sending old_state, which defeats long polling.

Diffstat:
Mpackages/taler-util/src/bank-api-client.ts | 9+++++----
Mpackages/taler-util/src/http-client/bank-core.ts | 41+++++++++++++++++++++--------------------
Mpackages/taler-util/src/http-client/bank-integration.ts | 19+++++++++++--------
Mpackages/taler-util/src/http-client/bank-wire.ts | 6++++--
Mpackages/taler-util/src/http-client/challenger.ts | 9+++++----
Mpackages/taler-util/src/http-client/donau-client.ts | 16+++++++++-------
Mpackages/taler-util/src/http-client/exchange-client.ts | 25+++++++++++++------------
Mpackages/taler-util/src/http-client/mailbox.ts | 9+++++----
Mpackages/taler-util/src/http-client/merchant.ts | 117++++++++++++++++++++++++++++++++++++++++---------------------------------------
Apackages/taler-util/src/http-client/path-escaping.test.ts | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-client/taldir.ts | 9+++++----
Mpackages/taler-util/src/http-client/utils.ts | 24+++++++++++++++++-------
12 files changed, 255 insertions(+), 130 deletions(-)

diff --git a/packages/taler-util/src/bank-api-client.ts b/packages/taler-util/src/bank-api-client.ts @@ -47,6 +47,7 @@ import { readSuccessResponseJsonOrThrow, } from "@gnu-taler/taler-util/http"; import { authHeaders } from "./http-client/utils.js"; +import { pathSegment } from "./http-client/utils.js"; const logger = new Logger("bank-api-client.ts"); @@ -126,7 +127,7 @@ export class TalerCorebankApiClient { async getAccountBalance( username: string, ): Promise<BankAccountBalanceResponse> { - const url = new URL(`accounts/${username}`, this.baseUrl); + const url = new URL(`accounts/${pathSegment(username)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { headers: this.makeAuthHeader(), }); @@ -158,7 +159,7 @@ export class TalerCorebankApiClient { } async getTransactions(username: string): Promise<void> { - const reqUrl = new URL(`accounts/${username}/transactions`, this.baseUrl); + const reqUrl = new URL(`accounts/${pathSegment(username)}/transactions`, this.baseUrl); const resp = await this.httpLib.fetch(reqUrl.href, { method: "GET", headers: { @@ -230,7 +231,7 @@ export class TalerCorebankApiClient { ); } // FIXME: Corebank should directly return this info! - const infoUrl = new URL(`accounts/${username}`, this.baseUrl); + const infoUrl = new URL(`accounts/${pathSegment(username)}`, this.baseUrl); const infoResp = await this.httpLib.fetch(infoUrl.href, { headers: authHeaders({ type: "basic", @@ -258,7 +259,7 @@ export class TalerCorebankApiClient { user: string, amount: string | undefined, ): Promise<WithdrawalOperationInfo> { - const url = new URL(`accounts/${user}/withdrawals`, this.baseUrl); + const url = new URL(`accounts/${pathSegment(user)}/withdrawals`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", body: { diff --git a/packages/taler-util/src/http-client/bank-core.ts b/packages/taler-util/src/http-client/bank-core.ts @@ -95,6 +95,7 @@ import { authHeaders, makeBearerTokenAuthHeader, nullEvictor, + pathSegment, } from "./utils.js"; const logger = new Logger("bank-core.ts"); @@ -168,7 +169,7 @@ export class TalerCoreBankHttpClient { body: TokenRequest, params: { challengeIds?: string[] } = {}, ) { - const url = new URL(`accounts/${username}/token`, this.baseUrl); + const url = new URL(`accounts/${pathSegment(username)}/token`, this.baseUrl); this.checkUsernameAuthMatch(username, auth); @@ -214,7 +215,7 @@ export class TalerCoreBankHttpClient { * https://docs.taler.net/core/api-corebank.html#delete--accounts-$USERNAME-token */ async deleteAccessToken(user: string, token: AccessToken) { - const url = new URL(`accounts/${user}/token`, this.baseUrl); + const url = new URL(`accounts/${pathSegment(user)}/token`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "DELETE", headers: { @@ -243,7 +244,7 @@ export class TalerCoreBankHttpClient { user: string, pagination?: PaginationParams, ): Promise<OperationOk<TokenInfos>> { - const url = new URL(`accounts/${user}/token`, this.baseUrl); + const url = new URL(`accounts/${pathSegment(user)}/token`, this.baseUrl); addPaginationParams(url, pagination); const resp = await this.httpLib.fetch(url.href, { method: "GET", @@ -372,7 +373,7 @@ export class TalerCoreBankHttpClient { auth: UserAndToken, params: { challengeIds?: string[] } = {}, ) { - const url = new URL(`accounts/${auth.username}`, this.baseUrl); + const url = new URL(`accounts/${pathSegment(auth.username)}`, this.baseUrl); const headers: Record<string, string> = {}; headers.Authorization = makeBearerTokenAuthHeader(auth.token); @@ -424,7 +425,7 @@ export class TalerCoreBankHttpClient { body: AccountReconfiguration, params: { challengeIds?: string[] } = {}, ) { - const url = new URL(`accounts/${auth.username}`, this.baseUrl); + const url = new URL(`accounts/${pathSegment(auth.username)}`, this.baseUrl); const headers: Record<string, string> = {}; headers.Authorization = makeBearerTokenAuthHeader(auth.token); @@ -492,7 +493,7 @@ export class TalerCoreBankHttpClient { body: AccountPasswordChange, params: { challengeIds?: string[] } = {}, ) { - const url = new URL(`accounts/${auth.username}/auth`, this.baseUrl); + const url = new URL(`accounts/${pathSegment(auth.username)}/auth`, this.baseUrl); const headers: Record<string, string> = {}; headers.Authorization = makeBearerTokenAuthHeader(auth.token); @@ -609,7 +610,7 @@ export class TalerCoreBankHttpClient { * */ async getAccount(auth: UserAndToken) { - const url = new URL(`accounts/${auth.username}`, this.baseUrl); + const url = new URL(`accounts/${pathSegment(auth.username)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "GET", headers: { @@ -640,7 +641,7 @@ export class TalerCoreBankHttpClient { auth: UserAndToken, params?: PaginationParams & LongPollParams, ) { - const url = new URL(`accounts/${auth.username}/transactions`, this.baseUrl); + const url = new URL(`accounts/${pathSegment(auth.username)}/transactions`, this.baseUrl); addPaginationParams(url, params); addLongPollingParam(url, params); const resp = await this.httpLib.fetch(url.href, { @@ -702,7 +703,7 @@ export class TalerCoreBankHttpClient { body: CreateTransactionRequest, params: { challengeIds?: string[] } = {}, ) { - const url = new URL(`accounts/${auth.username}/transactions`, this.baseUrl); + const url = new URL(`accounts/${pathSegment(auth.username)}/transactions`, this.baseUrl); const headers: Record<string, string> = {}; headers.Authorization = makeBearerTokenAuthHeader(auth.token); if (params.challengeIds && params.challengeIds.length > 0) { @@ -765,7 +766,7 @@ export class TalerCoreBankHttpClient { auth: UserAndToken, body: BankAccountCreateWithdrawalRequest, ) { - const url = new URL(`accounts/${auth.username}/withdrawals`, this.baseUrl); + const url = new URL(`accounts/${pathSegment(auth.username)}/withdrawals`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", headers: { @@ -910,7 +911,7 @@ export class TalerCoreBankHttpClient { old_state?: WithdrawalOperationStatusFlag; } & LongPollParams, ) { - const url = new URL(`withdrawals/${wid}`, this.baseUrl); + const url = new URL(`withdrawals/${pathSegment(wid)}`, this.baseUrl); addLongPollingParam(url, params); if (params?.old_state) { url.searchParams.set("old_state", params.old_state); @@ -944,7 +945,7 @@ export class TalerCoreBankHttpClient { body: CashoutRequest, params: { challengeIds?: string[] } = {}, ) { - const url = new URL(`accounts/${auth.username}/cashouts`, this.baseUrl); + const url = new URL(`accounts/${pathSegment(auth.username)}/cashouts`, this.baseUrl); const headers: Record<string, string> = {}; headers.Authorization = makeBearerTokenAuthHeader(auth.token); if (params.challengeIds && params.challengeIds.length > 0) { @@ -1040,7 +1041,7 @@ export class TalerCoreBankHttpClient { * */ async getAccountCashouts(auth: UserAndToken, pagination?: PaginationParams) { - const url = new URL(`accounts/${auth.username}/cashouts`, this.baseUrl); + const url = new URL(`accounts/${pathSegment(auth.username)}/cashouts`, this.baseUrl); addPaginationParams(url, pagination); const resp = await this.httpLib.fetch(url.href, { method: "GET", @@ -1144,7 +1145,7 @@ export class TalerCoreBankHttpClient { cid: number, body: ConversionRateClassInput, ) { - const url = new URL(`conversion-rate-classes/${cid}`, this.baseUrl); + const url = new URL(`conversion-rate-classes/${pathSegment(cid)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "PATCH", headers: { @@ -1185,7 +1186,7 @@ export class TalerCoreBankHttpClient { * */ async deleteConversionRateClass(auth: AccessToken, cid: number) { - const url = new URL(`conversion-rate-classes/${cid}`, this.baseUrl); + const url = new URL(`conversion-rate-classes/${pathSegment(cid)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "DELETE", @@ -1226,7 +1227,7 @@ export class TalerCoreBankHttpClient { * */ async getConversionRateClass(auth: AccessToken, cid: number) { - const url = new URL(`conversion-rate-classes/${cid}`, this.baseUrl); + const url = new URL(`conversion-rate-classes/${pathSegment(cid)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "GET", headers: { @@ -1295,7 +1296,7 @@ export class TalerCoreBankHttpClient { */ async sendChallenge(username: string, cid: string) { - const url = new URL(`accounts/${username}/challenge/${cid}`, this.baseUrl); + const url = new URL(`accounts/${pathSegment(username)}/challenge/${pathSegment(cid)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", }); @@ -1441,7 +1442,7 @@ export class TalerCoreBankHttpClient { * */ getWireGatewayAPI(username: string): URL { - return new URL(`accounts/${username}/taler-wire-gateway/`, this.baseUrl); + return new URL(`accounts/${pathSegment(username)}/taler-wire-gateway/`, this.baseUrl); } /** @@ -1449,7 +1450,7 @@ export class TalerCoreBankHttpClient { * */ getRevenueAPI(username: string): URL { - return new URL(`accounts/${username}/taler-revenue/`, this.baseUrl); + return new URL(`accounts/${pathSegment(username)}/taler-revenue/`, this.baseUrl); } /** @@ -1457,7 +1458,7 @@ export class TalerCoreBankHttpClient { * */ getConversionInfoAPIForUser(username: string): URL { - return new URL(`accounts/${username}/conversion-info/`, this.baseUrl); + return new URL(`accounts/${pathSegment(username)}/conversion-info/`, this.baseUrl); } /** diff --git a/packages/taler-util/src/http-client/bank-integration.ts b/packages/taler-util/src/http-client/bank-integration.ts @@ -44,7 +44,9 @@ import { } from "../types-taler-bank-integration.js"; import { codecForIntegrationBankConfig } from "../types-taler-corebank.js"; import { codecForTalerErrorDetail } from "../types-taler-wallet.js"; -import { addLongPollingParam } from "./utils.js"; +import { addLongPollingParam, + pathSegment, +} from "./utils.js"; export type TalerBankIntegrationResultByMethod< prop extends keyof TalerBankIntegrationHttpClient, @@ -121,13 +123,14 @@ export class TalerBankIntegrationHttpClient { | OperationOk<BankWithdrawalOperationStatus> | OperationFail<HttpStatusCode.NotFound> > { - const url = new URL(`withdrawal-operation/${woid}`, this.baseUrl); + const url = new URL(`withdrawal-operation/${pathSegment(woid)}`, this.baseUrl); addLongPollingParam(url, params); if (params) { - url.searchParams.set( - "old_state", - !params.old_state ? "pending" : params.old_state, - ); + // old_state is the long-poll trigger: the server returns as soon as + // the state differs. Defaulting it defeats a caller's timeout. + if (params.old_state !== undefined) { + url.searchParams.set("old_state", params.old_state); + } } const resp = await this.httpLib.fetch(url.href, { method: "GET", @@ -163,7 +166,7 @@ export class TalerBankIntegrationHttpClient { | OperationFail<TalerErrorCode.BANK_AMOUNT_DIFFERS> | OperationFail<TalerErrorCode.BANK_UNALLOWED_DEBIT> > { - const url = new URL(`withdrawal-operation/${woid}`, this.baseUrl); + const url = new URL(`withdrawal-operation/${pathSegment(woid)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", cancellationToken: this.cancellationToken, @@ -216,7 +219,7 @@ export class TalerBankIntegrationHttpClient { | OperationOk<undefined> | OperationFail<HttpStatusCode.Conflict> > { - const url = new URL(`withdrawal-operation/${woid}/abort`, this.baseUrl); + const url = new URL(`withdrawal-operation/${pathSegment(woid)}/abort`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", cancellationToken: this.cancellationToken, diff --git a/packages/taler-util/src/http-client/bank-wire.ts b/packages/taler-util/src/http-client/bank-wire.ts @@ -28,6 +28,7 @@ import { import { codecForAddIncomingResponse, codecForBankWireTransferList, + codecForBankWireTransferStatus, codecForIncomingHistory, codecForOutgoingHistory, codecForTransferResponse, @@ -37,6 +38,7 @@ import { addPaginationParams, authHeaders, BasicOrTokenAuth, + pathSegment, } from "./utils.js"; import { @@ -185,14 +187,14 @@ export class TalerWireGatewayHttpClient { * */ async getTransferStatus(req: { rowId?: number; auth?: BasicOrTokenAuth }) { - const url = new URL(`transfers/${req.rowId}`, this.baseUrl); + const url = new URL(`transfers/${pathSegment(String(req.rowId))}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "GET", headers: authHeaders(req.auth), }); switch (resp.status) { case HttpStatusCode.Ok: - return opSuccessFromHttp(resp, codecForBankWireTransferList()); + return opSuccessFromHttp(resp, codecForBankWireTransferStatus()); case HttpStatusCode.BadRequest: case HttpStatusCode.Unauthorized: case HttpStatusCode.NotFound: diff --git a/packages/taler-util/src/http-client/challenger.ts b/packages/taler-util/src/http-client/challenger.ts @@ -48,6 +48,7 @@ import { CacheEvictor, makeBearerTokenAuthHeader, nullEvictor, + pathSegment, } from "./utils.js"; export type ChallengerResultByMethod<prop extends keyof ChallengerHttpClient> = @@ -108,7 +109,7 @@ export class ChallengerHttpClient { * */ async setup(clientId: string, token: AccessToken, body?: object) { - const url = new URL(`setup/${clientId}`, this.baseUrl); + const url = new URL(`setup/${pathSegment(clientId)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", body, @@ -138,7 +139,7 @@ export class ChallengerHttpClient { redirectUri: string, state: string | undefined, ) { - const url = new URL(`authorize/${nonce}`, this.baseUrl); + const url = new URL(`authorize/${pathSegment(nonce)}`, this.baseUrl); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", clientId); url.searchParams.set("redirect_uri", redirectUri); @@ -173,7 +174,7 @@ export class ChallengerHttpClient { * */ async challenge(nonce: string, body: Record<string, string>) { - const url = new URL(`challenge/${nonce}`, this.baseUrl); + const url = new URL(`challenge/${pathSegment(nonce)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", @@ -208,7 +209,7 @@ export class ChallengerHttpClient { * */ async solve(nonce: string, body: Record<string, string>) { - const url = new URL(`solve/${nonce}`, this.baseUrl); + const url = new URL(`solve/${pathSegment(nonce)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", body: new URLSearchParams(Object.entries(body)).toString(), diff --git a/packages/taler-util/src/http-client/donau-client.ts b/packages/taler-util/src/http-client/donau-client.ts @@ -46,7 +46,9 @@ import { IssueReceiptsRequest, SubmitDonationReceiptsRequest, } from "../types-donau.js"; -import { makeBearerTokenAuthHeader } from "./utils.js"; +import { makeBearerTokenAuthHeader, + pathSegment, +} from "./utils.js"; /** * Client library for the GNU Taler donau service. @@ -96,7 +98,7 @@ export class DonauHttpClient { * */ async getSeed() { - const url = new URL(`keys`, this.baseUrl); + const url = new URL(`seed`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "GET", }); @@ -170,7 +172,7 @@ export class DonauHttpClient { * @returns */ async issueReceipts(charityId: number, body: IssueReceiptsRequest) { - const url = new URL(`batch-issue/${charityId}`, this.baseUrl); + const url = new URL(`batch-issue/${pathSegment(charityId)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", body, @@ -226,7 +228,7 @@ export class DonauHttpClient { * @returns */ async getDonationStatement(year: number, hash: string) { - const url = new URL(`donation-statement/${year}/${hash}`, this.baseUrl); + const url = new URL(`donation-statement/${pathSegment(year)}/${pathSegment(hash)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "GET", }); @@ -278,7 +280,7 @@ export class DonauHttpClient { token: AccessToken, id: string, ): Promise<OperationFail<HttpStatusCode.NotFound> | OperationOk<Charity>> { - const url = new URL(`charities/${id}`, this.baseUrl); + const url = new URL(`charities/${pathSegment(id)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "GET", headers: { @@ -330,7 +332,7 @@ export class DonauHttpClient { * @returns */ async updateCharity(token: AccessToken, id: number, body: CharityRequest) { - const url = new URL(`charities/${id}`, this.baseUrl); + const url = new URL(`charities/${pathSegment(id)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "PATCH", body, @@ -356,7 +358,7 @@ export class DonauHttpClient { * @returns */ async deleteCharity(token: AccessToken, id: number) { - const url = new URL(`charities/${id}`, this.baseUrl); + const url = new URL(`charities/${pathSegment(id)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "DELETE", headers: { diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts @@ -113,6 +113,7 @@ import { addLongPollingParam, addPaginationParams, nullEvictor, + pathSegment, } from "./utils.js"; import { @@ -649,7 +650,7 @@ export class TalerExchangeHttpClient { > > { const { paytoHash, accountPub, accountSig, longpoll, awaitAuth } = args; - const url = new URL(`kyc-check/${paytoHash}`, this.baseUrl); + const url = new URL(`kyc-check/${pathSegment(paytoHash)}`, this.baseUrl); if (awaitAuth !== undefined) { url.searchParams.set("await_auth", awaitAuth ? "YES" : "NO"); } @@ -707,7 +708,7 @@ export class TalerExchangeHttpClient { > > { const { paytoHash, accountSig, longpoll, awaitAuth } = args; - const url = new URL(`kyc-check/${paytoHash}`, this.baseUrl); + const url = new URL(`kyc-check/${pathSegment(paytoHash)}`, this.baseUrl); if (awaitAuth !== undefined) { url.searchParams.set("await_auth", awaitAuth ? "YES" : "NO"); } @@ -791,7 +792,7 @@ export class TalerExchangeHttpClient { etag: string | undefined, params: LongPollParams = {}, ) { - const url = new URL(`kyc-info/${token}`, this.baseUrl); + const url = new URL(`kyc-info/${pathSegment(token)}`, this.baseUrl); addLongPollingParam(url, params); @@ -1046,7 +1047,7 @@ export class TalerExchangeHttpClient { investigation?: boolean; } = {}, ) { - const url = new URL(`aml/${auth.id}/accounts`, this.baseUrl); + const url = new URL(`aml/${pathSegment(auth.id)}/accounts`, this.baseUrl); addPaginationParams(url, params); if (params.investigation !== undefined) { @@ -1097,7 +1098,7 @@ export class TalerExchangeHttpClient { investigation?: boolean; } = {}, ) { - const url = new URL(`aml/${auth.id}/accounts`, this.baseUrl); + const url = new URL(`aml/${pathSegment(auth.id)}/accounts`, this.baseUrl); /** * These are documents so it should bring all the rows @@ -1165,7 +1166,7 @@ export class TalerExchangeHttpClient { > | OperationOk<AmlDecisionsResponse> > { - const url = new URL(`aml/${auth.id}/decisions`, this.baseUrl); + const url = new URL(`aml/${pathSegment(auth.id)}/decisions`, this.baseUrl); addPaginationParams(url, params); if (params.account !== undefined) { @@ -1213,7 +1214,7 @@ export class TalerExchangeHttpClient { active?: boolean; } = {}, ): Promise<OperationOk<LegitimizationMeasuresList>> { - const url = new URL(`aml/${officer.id}/legitimizations`, this.baseUrl); + const url = new URL(`aml/${pathSegment(officer.id)}/legitimizations`, this.baseUrl); addPaginationParams(url, params); if (params.account !== undefined) { @@ -1259,7 +1260,7 @@ export class TalerExchangeHttpClient { | HttpStatusCode.Conflict > > { - const url = new URL(`aml/${auth.id}/attributes/${account}`, this.baseUrl); + const url = new URL(`aml/${pathSegment(auth.id)}/attributes/${pathSegment(account)}`, this.baseUrl); addPaginationParams(url, params); const resp = await this.fetch(url, { @@ -1302,7 +1303,7 @@ export class TalerExchangeHttpClient { | HttpStatusCode.NotImplemented > > { - const url = new URL(`aml/${auth.id}/attributes/${account}`, this.baseUrl); + const url = new URL(`aml/${pathSegment(auth.id)}/attributes/${pathSegment(account)}`, this.baseUrl); addPaginationParams(url, params); const resp = await this.fetch(url, { @@ -1388,7 +1389,7 @@ export class TalerExchangeHttpClient { | HttpStatusCode.Conflict > > { - const url = new URL(`aml/${auth.id}/transfers-credit`, this.baseUrl); + const url = new URL(`aml/${pathSegment(auth.id)}/transfers-credit`, this.baseUrl); addPaginationParams(url, params); @@ -1439,7 +1440,7 @@ export class TalerExchangeHttpClient { | HttpStatusCode.Conflict > > { - const url = new URL(`aml/${auth.id}/transfers-debit`, this.baseUrl); + const url = new URL(`aml/${pathSegment(auth.id)}/transfers-debit`, this.baseUrl); addPaginationParams(url, params); @@ -1490,7 +1491,7 @@ export class TalerExchangeHttpClient { | HttpStatusCode.Conflict > > { - const url = new URL(`aml/${auth.id}/transfers-kycauth`, this.baseUrl); + const url = new URL(`aml/${pathSegment(auth.id)}/transfers-kycauth`, this.baseUrl); addPaginationParams(url, params); diff --git a/packages/taler-util/src/http-client/mailbox.ts b/packages/taler-util/src/http-client/mailbox.ts @@ -48,6 +48,7 @@ import { HttpRequestLibrary, createPlatformHttpLib, } from "@gnu-taler/taler-util/http"; +import { pathSegment } from "./utils.js"; export type TalerMailboxInstanceResultByMethod< prop extends keyof TalerMailboxInstanceHttpClient, @@ -100,7 +101,7 @@ export class TalerMailboxInstanceHttpClient { | OperationOk<TalerMailboxApi.TalerMailboxConfigResponse> | OperationFail<HttpStatusCode.NotFound> > { - const url = new URL(`/config`, this.baseUrl); + const url = new URL(`config`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "GET", }); @@ -133,7 +134,7 @@ export class TalerMailboxInstanceHttpClient { | OperationFail<HttpStatusCode.Forbidden> > { const { h_address: hAddress, body } = args; - const url = new URL(`${hAddress.toUpperCase()}`, this.baseUrl); + const url = new URL(`${pathSegment(hAddress.toUpperCase())}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", @@ -173,7 +174,7 @@ export class TalerMailboxInstanceHttpClient { | OperationFail<HttpStatusCode.TooManyRequests> > { const { hMailbox: hMailbox } = args; - const url = new URL(`${hMailbox.toUpperCase()}`, this.baseUrl); + const url = new URL(`${pathSegment(hMailbox.toUpperCase())}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "GET", @@ -265,7 +266,7 @@ export class TalerMailboxInstanceHttpClient { | OperationFail<HttpStatusCode.NotFound> | OperationFail<HttpStatusCode.TooManyRequests> > { - const url = new URL(`info/${hMailbox.toUpperCase()}`, this.baseUrl); + const url = new URL(`info/${pathSegment(hMailbox.toUpperCase())}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "GET", diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -115,6 +115,7 @@ import { authHeaders, makeBearerTokenAuthHeader, nullEvictor, + pathSegment, } from "./utils.js"; export type TalerMerchantInstanceResultByMethod< @@ -368,7 +369,7 @@ export class TalerMerchantInstanceHttpClient { * */ async deleteAccessToken(token: AccessToken, serial: number) { - const url = new URL(`private/tokens/${String(serial)}`, this.baseUrl); + const url = new URL(`private/tokens/${pathSegment(String(serial))}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { headers.Authorization = makeBearerTokenAuthHeader(token); @@ -411,7 +412,7 @@ export class TalerMerchantInstanceHttpClient { | OperationFail<TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_NOT_FOUND> > { const { orderId, body } = args; - const url = new URL(`orders/${orderId}/claim`, this.baseUrl); + const url = new URL(`orders/${pathSegment(orderId)}/claim`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", @@ -447,7 +448,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-orders-$ORDER_ID-pay */ async makePayment(orderId: string, body: TalerMerchantApi.PayRequest) { - const url = new URL(`orders/${orderId}/pay`, this.baseUrl); + const url = new URL(`orders/${pathSegment(orderId)}/pay`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", @@ -500,7 +501,7 @@ export class TalerMerchantInstanceHttpClient { orderId: string, params: TalerMerchantApi.PaymentStatusRequestParams = {}, ) { - const url = new URL(`orders/${orderId}`, this.baseUrl); + const url = new URL(`orders/${pathSegment(orderId)}`, this.baseUrl); if (params.allowRefundedForRepurchase !== undefined) { url.searchParams.set( @@ -569,7 +570,7 @@ export class TalerMerchantInstanceHttpClient { { paid: boolean } & TalerMerchantApi.GetSessionStatusPaidResponse > > { - const url = new URL(`sessions/${sessionId}`, this.baseUrl); + const url = new URL(`sessions/${pathSegment(sessionId)}`, this.baseUrl); if (fulfillmentUrl !== undefined) { url.searchParams.set("fulfillment_url", fulfillmentUrl); @@ -615,7 +616,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#demonstrating-payment */ async demostratePayment(orderId: string, body: TalerMerchantApi.PaidRequest) { - const url = new URL(`orders/${orderId}/paid`, this.baseUrl); + const url = new URL(`orders/${pathSegment(orderId)}/paid`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", @@ -647,7 +648,7 @@ export class TalerMerchantInstanceHttpClient { orderId: string, body: TalerMerchantApi.AbortRequest, ) { - const url = new URL(`orders/${orderId}/abort`, this.baseUrl); + const url = new URL(`orders/${pathSegment(orderId)}/abort`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", @@ -679,7 +680,7 @@ export class TalerMerchantInstanceHttpClient { orderId: string, body: TalerMerchantApi.WalletRefundRequest, ) { - const url = new URL(`orders/${orderId}/refund`, this.baseUrl); + const url = new URL(`orders/${pathSegment(orderId)}/refund`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", @@ -1080,7 +1081,7 @@ export class TalerMerchantInstanceHttpClient { wireAccount: string, body: TalerMerchantApi.AccountPatchDetails, ) { - const url = new URL(`private/accounts/${wireAccount}`, this.baseUrl); + const url = new URL(`private/accounts/${pathSegment(wireAccount)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -1140,7 +1141,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-accounts-$H_WIRE */ async getBankAccountDetails(token: AccessToken, wireAccount: string) { - const url = new URL(`private/accounts/${wireAccount}`, this.baseUrl); + const url = new URL(`private/accounts/${pathSegment(wireAccount)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -1173,7 +1174,7 @@ export class TalerMerchantInstanceHttpClient { challengeIds?: string[]; } = {}, ) { - const url = new URL(`private/accounts/${wireAccount}`, this.baseUrl); + const url = new URL(`private/accounts/${pathSegment(wireAccount)}`, this.baseUrl); const headers: Record<string, string> = {}; if (params.challengeIds && params.challengeIds.length > 0) { @@ -1246,7 +1247,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-categories-$CATEGORY_ID */ async getCategoryDetails(token: AccessToken, cId: string) { - const url = new URL(`private/categories/${cId}`, this.baseUrl); + const url = new URL(`private/categories/${pathSegment(cId)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -1314,7 +1315,7 @@ export class TalerMerchantInstanceHttpClient { cid: string, body: TalerMerchantApi.CategoryCreateRequest, ) { - const url = new URL(`private/categories/${cid}`, this.baseUrl); + const url = new URL(`private/categories/${pathSegment(cid)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -1348,7 +1349,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCE]-private-categories-$CATEGORY_ID */ async deleteCategory(token: AccessToken, cId: string) { - const url = new URL(`private/categories/${cId}`, this.baseUrl); + const url = new URL(`private/categories/${pathSegment(cId)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -1435,7 +1436,7 @@ export class TalerMerchantInstanceHttpClient { productId: string, body: TalerMerchantApi.ProductPatchDetailRequest, ) { - const url = new URL(`private/products/${productId}`, this.baseUrl); + const url = new URL(`private/products/${pathSegment(productId)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -1543,7 +1544,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-products-$PRODUCT_ID */ async getProductDetails(token: AccessToken, productId: string) { - const url = new URL(`private/products/${productId}`, this.baseUrl); + const url = new URL(`private/products/${pathSegment(productId)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -1574,7 +1575,7 @@ export class TalerMerchantInstanceHttpClient { productId: string, body: TalerMerchantApi.LockRequest, ) { - const url = new URL(`private/products/${productId}/lock`, this.baseUrl); + const url = new URL(`private/products/${pathSegment(productId)}/lock`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -1614,7 +1615,7 @@ export class TalerMerchantInstanceHttpClient { force?: boolean; } = {}, ) { - const url = new URL(`private/products/${productId}`, this.baseUrl); + const url = new URL(`private/products/${pathSegment(productId)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -1847,7 +1848,7 @@ export class TalerMerchantInstanceHttpClient { orderId: string, params: TalerMerchantApi.GetOrderRequestParams = {}, ) { - const url = new URL(`private/orders/${orderId}`, this.baseUrl); + const url = new URL(`private/orders/${pathSegment(orderId)}`, this.baseUrl); if (params.allowRefundedForRepurchase !== undefined) { url.searchParams.set( @@ -1912,7 +1913,7 @@ export class TalerMerchantInstanceHttpClient { orderId: string, body: TalerMerchantApi.ForgetRequest, ) { - const url = new URL(`private/orders/${orderId}/forget`, this.baseUrl); + const url = new URL(`private/orders/${pathSegment(orderId)}/forget`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -1954,7 +1955,7 @@ export class TalerMerchantInstanceHttpClient { orderId: string, force: boolean = false, ) { - const url = new URL(`private/orders/${orderId}`, this.baseUrl); + const url = new URL(`private/orders/${pathSegment(orderId)}`, this.baseUrl); if (force) { url.searchParams.set("force", "yes"); } @@ -1998,7 +1999,7 @@ export class TalerMerchantInstanceHttpClient { orderId: string, body: TalerMerchantApi.RefundRequest, ) { - const url = new URL(`private/orders/${orderId}/refund`, this.baseUrl); + const url = new URL(`private/orders/${pathSegment(orderId)}/refund`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -2177,7 +2178,7 @@ export class TalerMerchantInstanceHttpClient { token: AccessToken, serial_wid: number, ) { - const url = new URL(`private/incoming/${String(serial_wid)}`, this.baseUrl); + const url = new URL(`private/incoming/${pathSegment(String(serial_wid))}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -2205,7 +2206,7 @@ export class TalerMerchantInstanceHttpClient { // * @deprecated // */ // async deleteWireTransfer(token: AccessToken, transferId: string) { - // const url = new URL(`private/transfers/${transferId}`, this.baseUrl); + // const url = new URL(`private/transfers/${pathSegment(transferId)}`, this.baseUrl); // const headers: Record<string, string> = {}; // if (token) { @@ -2281,7 +2282,7 @@ export class TalerMerchantInstanceHttpClient { deviceId: string, body: TalerMerchantApi.OtpDevicePatchDetails, ) { - const url = new URL(`private/otp-devices/${deviceId}`, this.baseUrl); + const url = new URL(`private/otp-devices/${pathSegment(deviceId)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -2346,7 +2347,7 @@ export class TalerMerchantInstanceHttpClient { deviceId: string, params: TalerMerchantApi.GetOtpDeviceRequestParams = {}, ) { - const url = new URL(`private/otp-devices/${deviceId}`, this.baseUrl); + const url = new URL(`private/otp-devices/${pathSegment(deviceId)}`, this.baseUrl); if (params.faketime) { url.searchParams.set("faketime", String(params.faketime)); @@ -2379,7 +2380,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCE]-private-otp-devices-$DEVICE_ID */ async deleteOtpDevice(token: AccessToken, deviceId: string) { - const url = new URL(`private/otp-devices/${deviceId}`, this.baseUrl); + const url = new URL(`private/otp-devices/${pathSegment(deviceId)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -2453,7 +2454,7 @@ export class TalerMerchantInstanceHttpClient { templateId: string, body: TalerMerchantApi.TemplatePatchDetails, ) { - const url = new URL(`private/templates/${templateId}`, this.baseUrl); + const url = new URL(`private/templates/${pathSegment(templateId)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -2514,7 +2515,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-templates-$TEMPLATE_ID */ async getTemplateDetails(token: AccessToken, templateId: string) { - const url = new URL(`private/templates/${templateId}`, this.baseUrl); + const url = new URL(`private/templates/${pathSegment(templateId)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -2540,7 +2541,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCE]-private-templates-$TEMPLATE_ID */ async deleteTemplate(token: AccessToken, templateId: string) { - const url = new URL(`private/templates/${templateId}`, this.baseUrl); + const url = new URL(`private/templates/${pathSegment(templateId)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -2570,7 +2571,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-templates-$TEMPLATE_ID */ async useTemplateGetInfo(templateId: string) { - const url = new URL(`templates/${templateId}`, this.baseUrl); + const url = new URL(`templates/${pathSegment(templateId)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "GET", @@ -2592,7 +2593,7 @@ export class TalerMerchantInstanceHttpClient { templateId: string, body: TalerMerchantApi.UsingTemplateDetailsRequest, ) { - const url = new URL(`templates/${templateId}`, this.baseUrl); + const url = new URL(`templates/${pathSegment(templateId)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", @@ -2649,7 +2650,7 @@ export class TalerMerchantInstanceHttpClient { webhookId: string, body: TalerMerchantApi.WebhookPatchDetails, ) { - const url = new URL(`private/webhooks/${webhookId}`, this.baseUrl); + const url = new URL(`private/webhooks/${pathSegment(webhookId)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -2710,7 +2711,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCES]-private-webhooks-$WEBHOOK_ID */ async getWebhookDetails(token: AccessToken, webhookId: string) { - const url = new URL(`private/webhooks/${webhookId}`, this.baseUrl); + const url = new URL(`private/webhooks/${pathSegment(webhookId)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -2736,7 +2737,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCES]-private-webhooks-$WEBHOOK_ID */ async deleteWebhook(token: AccessToken, webhookId: string) { - const url = new URL(`private/webhooks/${webhookId}`, this.baseUrl); + const url = new URL(`private/webhooks/${pathSegment(webhookId)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -2816,7 +2817,7 @@ export class TalerMerchantInstanceHttpClient { | OperationFail<HttpStatusCode.NotFound> | OperationFail<HttpStatusCode.Unauthorized> > { - const url = new URL(`private/tokenfamilies/${tokenSlug}`, this.baseUrl); + const url = new URL(`private/tokenfamilies/${pathSegment(tokenSlug)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -2880,7 +2881,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCES]-private-tokenfamilies-$TOKEN_FAMILY_SLUG */ async getTokenFamilyDetails(token: AccessToken, tokenSlug: string) { - const url = new URL(`private/tokenfamilies/${tokenSlug}`, this.baseUrl); + const url = new URL(`private/tokenfamilies/${pathSegment(tokenSlug)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -2907,7 +2908,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCES]-private-tokenfamilies-$TOKEN_FAMILY_SLUG */ async deleteTokenFamily(token: AccessToken, tokenSlug: string) { - const url = new URL(`private/tokenfamilies/${tokenSlug}`, this.baseUrl); + const url = new URL(`private/tokenfamilies/${pathSegment(tokenSlug)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -2937,7 +2938,7 @@ export class TalerMerchantInstanceHttpClient { * */ async sendChallenge(cid: string) { - const url = new URL(`challenge/${cid}`, this.baseUrl); + const url = new URL(`challenge/${pathSegment(cid)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", // FIXME: this should be removed @@ -2998,7 +2999,7 @@ export class TalerMerchantInstanceHttpClient { * */ async confirmChallenge(cid: string, body: ChallengeSolveRequest) { - const url = new URL(`challenge/${cid}/confirm`, this.baseUrl); + const url = new URL(`challenge/${pathSegment(cid)}/confirm`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", body, @@ -3129,7 +3130,7 @@ export class TalerMerchantInstanceHttpClient { id: string, body: TalerMerchantApi.ReportGenerationRequest, ) { - const url = new URL(`reports/${id}`, this.baseUrl); + const url = new URL(`reports/${pathSegment(id)}`, this.baseUrl); const headers: Record<string, string> = {}; const resp = await this.httpLib.fetch(url.href, { @@ -3190,7 +3191,7 @@ export class TalerMerchantInstanceHttpClient { id: string, body: TalerMerchantApi.ReportAddRequest, ) { - const url = new URL(`private/reports/${id}`, this.baseUrl); + const url = new URL(`private/reports/${pathSegment(id)}`, this.baseUrl); const headers: Record<string, string> = {}; headers.Authorization = makeBearerTokenAuthHeader(token); @@ -3248,7 +3249,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCES]-private-reports-$REPORT_SERIAL */ async getScheduledReportDetails(token: AccessToken, id: string) { - const url = new URL(`private/reports/${id}`, this.baseUrl); + const url = new URL(`private/reports/${pathSegment(id)}`, this.baseUrl); const headers: Record<string, string> = {}; headers.Authorization = makeBearerTokenAuthHeader(token); @@ -3273,7 +3274,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCES]-private-reports-$REPORT_SERIAL */ async deleteScheduledReport(token: AccessToken, serial: string) { - const url = new URL(`private/reports/${serial}`, this.baseUrl); + const url = new URL(`private/reports/${pathSegment(serial)}`, this.baseUrl); const headers: Record<string, string> = {}; headers.Authorization = makeBearerTokenAuthHeader(token); @@ -3343,7 +3344,7 @@ export class TalerMerchantInstanceHttpClient { id: string, body: TalerMerchantApi.PotModifyRequest, ) { - const url = new URL(`private/pots/${id}`, this.baseUrl); + const url = new URL(`private/pots/${pathSegment(id)}`, this.baseUrl); const headers: Record<string, string> = {}; headers.Authorization = makeBearerTokenAuthHeader(token); @@ -3400,7 +3401,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCES]-private-pots-$POT_SERIAL */ async getMoneyPotDetails(token: AccessToken, id: string) { - const url = new URL(`private/pots/${id}`, this.baseUrl); + const url = new URL(`private/pots/${pathSegment(id)}`, this.baseUrl); const headers: Record<string, string> = {}; headers.Authorization = makeBearerTokenAuthHeader(token); @@ -3425,7 +3426,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCES]-private-pots-$POT_SERIAL */ async deleteMoneyPot(token: AccessToken, serial: string) { - const url = new URL(`private/pots/${serial}`, this.baseUrl); + const url = new URL(`private/pots/${pathSegment(serial)}`, this.baseUrl); const headers: Record<string, string> = {}; headers.Authorization = makeBearerTokenAuthHeader(token); @@ -3498,7 +3499,7 @@ export class TalerMerchantInstanceHttpClient { | OperationFail<HttpStatusCode.Unauthorized> | OperationFail<HttpStatusCode.Conflict> > { - const url = new URL(`private/groups/${id}`, this.baseUrl); + const url = new URL(`private/groups/${pathSegment(id)}`, this.baseUrl); const headers: Record<string, string> = {}; headers.Authorization = makeBearerTokenAuthHeader(token); @@ -3555,7 +3556,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCES]-private-groups-$GROUP_SERIAL */ async deleteProductGroup(token: AccessToken, serial: string) { - const url = new URL(`private/groups/${serial}`, this.baseUrl); + const url = new URL(`private/groups/${pathSegment(serial)}`, this.baseUrl); const headers: Record<string, string> = {}; headers.Authorization = makeBearerTokenAuthHeader(token); @@ -3616,7 +3617,7 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp } getSubInstanceAPI(instanceId: string): string { - return new URL(`instances/${instanceId}/`, this.baseUrl).href; + return new URL(`instances/${pathSegment(instanceId)}/`, this.baseUrl).href; } // @@ -3784,7 +3785,7 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp instanceId: string, body: TalerMerchantApi.InstanceReconfigurationMessage, ) { - const url = new URL(`management/instances/${instanceId}`, this.baseUrl); + const url = new URL(`management/instances/${pathSegment(instanceId)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -3841,7 +3842,7 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp * */ async getInstanceDetails(token: AccessToken, instanceId: string) { - const url = new URL(`management/instances/${instanceId}`, this.baseUrl); + const url = new URL(`management/instances/${pathSegment(instanceId)}`, this.baseUrl); const headers: Record<string, string> = {}; if (token) { @@ -3872,7 +3873,7 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp instanceId: string, params: { purge?: boolean; challengeIds?: string[] } = {}, ) { - const url = new URL(`management/instances/${instanceId}`, this.baseUrl); + const url = new URL(`management/instances/${pathSegment(instanceId)}`, this.baseUrl); if (params.purge !== undefined) { url.searchParams.set("purge", params.purge ? "YES" : "NO"); @@ -3922,7 +3923,7 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp instanceId: string, params: TalerMerchantApi.GetKycStatusRequestParams, ) { - const url = new URL(`management/instances/${instanceId}/kyc`, this.baseUrl); + const url = new URL(`management/instances/${pathSegment(instanceId)}/kyc`, this.baseUrl); if (params.wireHash) { url.searchParams.set("h_wire", params.wireHash); @@ -3967,7 +3968,7 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp statSlug: string, params: TalerMerchantApi.GetStatisticsRequestParams = {}, ) { - const url = new URL(`private/statistics-counter/${statSlug}`, this.baseUrl); + const url = new URL(`private/statistics-counter/${pathSegment(statSlug)}`, this.baseUrl); if (params.by) { url.searchParams.set("by", params.by); @@ -4005,7 +4006,7 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp statSlug: string, params: TalerMerchantApi.GetStatisticsRequestParams = {}, ) { - const url = new URL(`private/statistics-amount/${statSlug}`, this.baseUrl); + const url = new URL(`private/statistics-amount/${pathSegment(statSlug)}`, this.baseUrl); if (params.by) { url.searchParams.set("by", params.by); @@ -4051,7 +4052,7 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp | OperationOk<TalerMerchantApi.MerchantStatisticsReportResponse> | OperationFail<HttpStatusCode.NotImplemented> > { - const url = new URL(`private/statistics-report/${name}`, this.baseUrl); + const url = new URL(`private/statistics-report/${pathSegment(name)}`, this.baseUrl); if (params.count !== undefined) { url.searchParams.set("count", String(params.count)); @@ -4100,7 +4101,7 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp | OperationFail<HttpStatusCode.Unauthorized> | OperationFail<HttpStatusCode.NotImplemented> > { - const url = new URL(`private/statistics-report/${name}`, this.baseUrl); + const url = new URL(`private/statistics-report/${pathSegment(name)}`, this.baseUrl); if (params.count !== undefined) { url.searchParams.set("count", String(params.count)); diff --git a/packages/taler-util/src/http-client/path-escaping.test.ts b/packages/taler-util/src/http-client/path-escaping.test.ts @@ -0,0 +1,101 @@ +/* + 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 { test } from "node:test"; +import assert from "node:assert"; +import { + HttpRequestLibrary, + HttpRequestOptions, + HttpResponse, + HeadersImpl, +} from "../http-common.js"; +import { TalerCoreBankHttpClient } from "./bank-core.js"; +import { TalerMerchantInstanceHttpClient } from "./merchant.js"; +import { pathSegment } from "./utils.js"; + +/** + * Records the URL of the first request and answers with an empty 204. + */ +class RecordingHttpLib implements HttpRequestLibrary { + lastUrl: string | undefined; + async fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> { + this.lastUrl ??= url; + return { + requestUrl: url, + requestMethod: opt?.method ?? "GET", + status: 204, + headers: new HeadersImpl(), + async json() { + return {}; + }, + async text() { + return ""; + }, + async bytes() { + return new Uint8Array(); + }, + }; + } +} + +test("pathSegment escapes separators and traversal", (t) => { + assert.strictEqual(pathSegment("alice"), "alice"); + assert.strictEqual(pathSegment(".."), ".."); + assert.strictEqual(pathSegment("a/b"), "a%2Fb"); + assert.strictEqual(pathSegment("../admin"), "..%2Fadmin"); + assert.strictEqual(pathSegment("a?b"), "a%3Fb"); + assert.strictEqual(pathSegment("a#b"), "a%23b"); + // Numbers are common path parameters and must survive. + assert.strictEqual(pathSegment(42), "42"); +}); + +test("a hostile username cannot escape its path segment", async (t) => { + // new URL("accounts/" + "../admin" + "/transactions", base) resolves the + // dot segment and eliminates "accounts/" entirely. + const lib = new RecordingHttpLib(); + const client = new TalerCoreBankHttpClient( + "https://bank.example.com/api/", + lib, + ); + await client + .getTransactions({ username: "../admin", token: "tok" as any }) + .catch(() => undefined); + assert.ok(lib.lastUrl !== undefined, "no request was made"); + assert.ok( + lib.lastUrl!.startsWith("https://bank.example.com/api/accounts/"), + `escaped its segment: ${lib.lastUrl}`, + ); + assert.ok( + !lib.lastUrl!.includes("/admin/"), + `traversal survived: ${lib.lastUrl}`, + ); +}); + +test("a hostile order id cannot inject extra path segments", async (t) => { + const lib = new RecordingHttpLib(); + const client = new TalerMerchantInstanceHttpClient( + "https://merchant.example.com/", + lib, + ); + await client + .getOrderDetails("secret-token" as any, "a/b/../../evil") + .catch(() => undefined); + assert.ok(lib.lastUrl !== undefined, "no request was made"); + assert.ok( + !lib.lastUrl!.includes("/evil"), + `injection survived: ${lib.lastUrl}`, + ); +}); diff --git a/packages/taler-util/src/http-client/taldir.ts b/packages/taler-util/src/http-client/taldir.ts @@ -45,6 +45,7 @@ import { HttpRequestLibrary, createPlatformHttpLib, } from "@gnu-taler/taler-util/http"; +import { pathSegment } from "./utils.js"; export type TalerDirectoryInstanceResultByMethod< prop extends keyof TalerDirectoryInstanceHttpClient, @@ -92,7 +93,7 @@ export class TalerDirectoryInstanceHttpClient { | OperationOk<TalerDirectoryApi.TalerDirectoryConfigResponse> | OperationFail<HttpStatusCode.NotFound> > { - const url = new URL(`/config`, this.baseUrl); + const url = new URL(`config`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "GET", }); @@ -121,7 +122,7 @@ export class TalerDirectoryInstanceHttpClient { | OperationFail<HttpStatusCode.NotFound> > { const { hAlias: hAlias } = args; - const url = new URL(`${hAlias.toUpperCase()}`, this.baseUrl); + const url = new URL(`${pathSegment(hAlias.toUpperCase())}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "GET", @@ -156,7 +157,7 @@ export class TalerDirectoryInstanceHttpClient { | OperationFail<HttpStatusCode.TooManyRequests> > { const { hAlias: hAlias, solution: solution } = args; - const url = new URL(`${hAlias.toUpperCase()}`, this.baseUrl); + const url = new URL(`${pathSegment(hAlias.toUpperCase())}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", @@ -214,7 +215,7 @@ export class TalerDirectoryInstanceHttpClient { targetUri: targetUri, duration: duration, } = args; - const url = new URL(`/register/${aliasType}`, this.baseUrl); + const url = new URL(`register/${pathSegment(aliasType)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", diff --git a/packages/taler-util/src/http-client/utils.ts b/packages/taler-util/src/http-client/utils.ts @@ -30,6 +30,16 @@ import { * @param token * @returns */ +/** + * Escape a value for use as a single URL path segment. + * + * new URL() resolves structure rather than escaping it, so an unescaped "/" + * injects extra segments and ".." eliminates the segment before it. + */ +export function pathSegment(x: string | number): string { + return encodeURIComponent(x); +} + export function makeBearerTokenAuthHeader(token: AccessToken): string { return `Bearer ${token}`; } @@ -72,13 +82,13 @@ export function addPaginationParams(url: URL, pagination?: PaginationParams) { if (pagination.offset) { url.searchParams.set("offset", pagination.offset); } - const order = !pagination || pagination.order === "asc" ? 1 : -1; - const limit = - !pagination || !pagination.limit || pagination.limit === 0 - ? 5 - : Math.abs(pagination.limit); - //always send limit - url.searchParams.set("limit", String(order * limit)); + // Only send a limit when asked for: every field of PaginationParams is + // optional, so a caller setting just a long-poll timeout would otherwise + // override the server's default. + if (pagination.limit !== undefined && pagination.limit !== 0) { + const order = pagination.order === "asc" ? 1 : -1; + url.searchParams.set("limit", String(order * Math.abs(pagination.limit))); + } } export function addLongPollingParam(url: URL, param?: LongPollParams) {