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:
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) {