taler-typescript-core

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

commit 4a1ecd2b95f478e545097f986664245aaae82a21
parent 2e0a6ec7fa3f2e0c1eafbb634b6b70244d5bca8a
Author: Florian Dold <dold@taler.net>
Date:   Wed,  9 Sep 2026 12:27:06 +0200

wallet-cli: add readonly networking mode

Allow explicitly reviewed protocol queries with
TALER_WALLET_OFFLINE=readonly while retaining full blocking with 1.

Diffstat:
Mpackages/taler-util/src/http-client/bank-conversion.ts | 4++++
Mpackages/taler-util/src/http-client/bank-integration.ts | 2++
Mpackages/taler-util/src/http-client/donau-client.ts | 3+++
Mpackages/taler-util/src/http-client/exchange-client.ts | 38++++++++++++++++++++++++++------------
Mpackages/taler-util/src/http-client/mailbox.ts | 1+
Mpackages/taler-util/src/http-client/merchant.ts | 4++++
Mpackages/taler-util/src/http-client/taldir.ts | 1+
Mpackages/taler-util/src/http-common.ts | 10++++++++++
Mpackages/taler-wallet-cli/README.md | 51+++++++++++++++++++++++++++++++++++++++++++--------
Mpackages/taler-wallet-cli/src/index.ts | 47++++++-----------------------------------------
Apackages/taler-wallet-cli/src/wallet-http.test.ts | 350+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-cli/src/wallet-http.ts | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/requests.ts | 1+
13 files changed, 550 insertions(+), 61 deletions(-)

diff --git a/packages/taler-util/src/http-client/bank-conversion.ts b/packages/taler-util/src/http-client/bank-conversion.ts @@ -94,6 +94,7 @@ export class TalerBankConversionHttpClient { async getConfig() { const url = new URL(`config`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { + readOnly: true, method: "GET", cancellationToken: this.cancellationToken, }); @@ -119,6 +120,7 @@ export class TalerBankConversionHttpClient { async getRate(auth: TokenAuth | undefined) { const url = new URL(`rate`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { + readOnly: true, method: "GET", headers: authHeaders(auth), }); @@ -151,6 +153,7 @@ export class TalerBankConversionHttpClient { ); } const resp = await this.httpLib.fetch(url.href, { + readOnly: true, method: "GET", headers: authHeaders(auth), cancellationToken: this.cancellationToken, @@ -203,6 +206,7 @@ export class TalerBankConversionHttpClient { ); } const resp = await this.httpLib.fetch(url.href, { + readOnly: true, method: "GET", headers: authHeaders(auth), }); diff --git a/packages/taler-util/src/http-client/bank-integration.ts b/packages/taler-util/src/http-client/bank-integration.ts @@ -78,6 +78,7 @@ export class TalerBankIntegrationHttpClient { async getConfig() { const url = new URL(`config`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { + readOnly: true, method: "GET", cancellationToken: this.cancellationToken, timeout: this.timeout, @@ -123,6 +124,7 @@ export class TalerBankIntegrationHttpClient { } } const resp = await this.httpLib.fetch(url.href, { + readOnly: true, method: "GET", cancellationToken: this.cancellationToken, timeout: this.timeout, diff --git a/packages/taler-util/src/http-client/donau-client.ts b/packages/taler-util/src/http-client/donau-client.ts @@ -82,6 +82,7 @@ export class DonauHttpClient { async getKeys() { const url = new URL(`keys`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { + readOnly: true, method: "GET", }); switch (resp.status) { @@ -99,6 +100,7 @@ export class DonauHttpClient { async getSeed() { const url = new URL(`seed`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { + readOnly: true, method: "GET", }); switch (resp.status) { @@ -124,6 +126,7 @@ export class DonauHttpClient { > { const url = new URL(`config`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { + readOnly: true, method: "GET", }); switch (resp.status) { diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts @@ -287,7 +287,7 @@ export class TalerExchangeHttpClient { * */ async getSeed() { - const resp = await this.fetch("seed"); + const resp = await this.fetch("seed", { readOnly: true }); switch (resp.status) { case HttpStatusCode.Ok: { const buffer = await resp.bytes(); @@ -305,7 +305,7 @@ export class TalerExchangeHttpClient { * */ async getConfig() { - const resp = await this.fetch("config"); + const resp = await this.fetch("config", { readOnly: true }); switch (resp.status) { case HttpStatusCode.Ok: return carefullyParseConfig( @@ -344,7 +344,7 @@ export class TalerExchangeHttpClient { // bandwidth but never correctness. url.searchParams.set("last_issue_date", String(opts.lastIssueDate)); } - const resp = await this.fetch(url, { headers }); + const resp = await this.fetch(url, { readOnly: true, headers }); switch (resp.status) { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForExchangeKeysResponse()); @@ -366,9 +366,7 @@ export class TalerExchangeHttpClient { Accept: formatAcceptHeader(acceptFormats), "Accept-Language": args.acceptLanguage, }; - const resp = await this.fetch("terms", { - headers, - }); + const resp = await this.fetch("terms", { readOnly: true, headers }); switch (resp.status) { case HttpStatusCode.Ok: return opFixedSuccess(resp, { @@ -396,7 +394,11 @@ export class TalerExchangeHttpClient { | OperationFail<HttpStatusCode.NotFound> | OperationFail<HttpStatusCode.Gone> > { - const resp = await this.fetch(`purses/${pursePub}/merge`, {}, longpoll); + const resp = await this.fetch( + `purses/${pursePub}/merge`, + { readOnly: true }, + longpoll, + ); switch (resp.status) { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForExchangePurseStatus()); @@ -420,7 +422,11 @@ export class TalerExchangeHttpClient { | OperationFail<HttpStatusCode.NotFound> | OperationFail<HttpStatusCode.Gone> > { - const resp = await this.fetch(`purses/${pursePub}/deposit`, {}, longpoll); + const resp = await this.fetch( + `purses/${pursePub}/deposit`, + { readOnly: true }, + longpoll, + ); switch (resp.status) { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForExchangePurseStatus()); @@ -629,7 +635,7 @@ export class TalerExchangeHttpClient { | OperationOk<ExchangeGetContractResponse> | OperationFail<HttpStatusCode.NotFound> > { - const resp = await this.fetch(`contracts/${pursePub}`); + const resp = await this.fetch(`contracts/${pursePub}`, { readOnly: true }); switch (resp.status) { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForExchangeGetContractResponse()); @@ -776,6 +782,7 @@ export class TalerExchangeHttpClient { const resp = await this.fetch( url, { + readOnly: true, headers: { "Account-Owner-Signature": accountSig, "Account-Owner-Pub": accountPub, @@ -1804,7 +1811,7 @@ export class TalerExchangeHttpClient { | OperationFail<HttpStatusCode.NotFound> | OperationFail<HttpStatusCode.NotImplemented> > { - const resp = await this.fetch("terms"); + const resp = await this.fetch("terms", { readOnly: true }); switch (resp.status) { case HttpStatusCode.Ok: { const etag = resp.headers.get("taler-terms-version") || undefined; @@ -1831,7 +1838,11 @@ export class TalerExchangeHttpClient { ): Promise< OperationOk<ReserveStatus> | OperationFail<HttpStatusCode.NotFound> > { - const resp = await this.fetch(`reserves/${reservePub}`, {}, longpoll); + const resp = await this.fetch( + `reserves/${reservePub}`, + { readOnly: true }, + longpoll, + ); switch (resp.status) { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForReserveStatus()); @@ -1903,6 +1914,7 @@ export class TalerExchangeHttpClient { url.searchParams.set("start", String(startOffset)); } const resp = await this.fetch(url, { + readOnly: true, headers: { "Taler-Reserve-History-Signature": signature, }, @@ -2021,6 +2033,7 @@ export class TalerExchangeHttpClient { OperationOk<CoinHistoryResponse> | OperationFail<HttpStatusCode.NotFound> > { const resp = await this.fetch(`coins/${coinPub}/history`, { + readOnly: true, headers: { "Taler-Coin-History-Signature": signature, }, @@ -2113,7 +2126,7 @@ export class TalerExchangeHttpClient { if (longpoll) { url.searchParams.set("lpt", "1"); } - const resp = await this.fetch(url, {}, longpoll); + const resp = await this.fetch(url, { readOnly: true }, longpoll); switch (resp.status) { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForTackTransactionWired()); @@ -2177,6 +2190,7 @@ export class TalerExchangeHttpClient { > { const url = new URL(`blinding-prepare`, this.baseUrl); const resp = await this.fetch(url, { + readOnly: true, method: "POST", body: args.body, }); diff --git a/packages/taler-util/src/http-client/mailbox.ts b/packages/taler-util/src/http-client/mailbox.ts @@ -94,6 +94,7 @@ export class TalerMailboxInstanceHttpClient { > { const url = new URL(`config`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { + readOnly: true, method: "GET", }); switch (resp.status) { diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -228,6 +228,7 @@ export class TalerMerchantInstanceHttpClient { async getConfig() { const url = new URL(`config`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { + readOnly: true, method: "GET", }); switch (resp.status) { @@ -251,6 +252,7 @@ export class TalerMerchantInstanceHttpClient { async listExchanges() { const url = new URL(`exchanges`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { + readOnly: true, method: "GET", }); switch (resp.status) { @@ -571,6 +573,7 @@ export class TalerMerchantInstanceHttpClient { } const resp = await this.httpLib.fetch(url.href, { + readOnly: true, method: "GET", cancellationToken: this.cancellationToken, timeout: this.timeout, @@ -631,6 +634,7 @@ export class TalerMerchantInstanceHttpClient { } const resp = await this.httpLib.fetch(url.href, { + readOnly: true, method: "GET", }); diff --git a/packages/taler-util/src/http-client/taldir.ts b/packages/taler-util/src/http-client/taldir.ts @@ -81,6 +81,7 @@ export class TalerDirectoryInstanceHttpClient { > { const url = new URL(`config`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { + readOnly: true, method: "GET", }); switch (resp.status) { diff --git a/packages/taler-util/src/http-common.ts b/packages/taler-util/src/http-common.ts @@ -123,6 +123,16 @@ export async function awaitNativeRequest<T>( } export interface HttpRequestOptions { + /** + * The caller has reviewed this operation and asserts that it does not change + * server-side application state. Read-only operations must not charge fees + * or trigger other state-changing operations. Ordinary logging and caching + * do not count. Set this at individual protocol request sites, never infer + * it from the HTTP method. + * Restricted HTTP implementations may reject requests without this marker. + * This is local policy metadata, not a header sent to the server. + */ + readOnly?: boolean; method?: "POST" | "PATCH" | "PUT" | "GET" | "DELETE"; headers?: { [name: string]: string | undefined }; diff --git a/packages/taler-wallet-cli/README.md b/packages/taler-wallet-cli/README.md @@ -31,11 +31,46 @@ initialization. `TALER_WALLET_NATIVE_DB` is retired and has no effect. These environment-selection rules apply to the Node.js CLI; Qtart/mobile configuration is unchanged. -## Offline mode - -Set `TALER_WALLET_OFFLINE=1` to make every network request from the local -wallet-core fail before it is sent. This is useful when inspecting or -debugging a wallet database without changing server-side state. Offline mode -does not support `--wallet-connection` (or `TALER_WALLET_CONNECTION`), because -that connects to a separately running wallet-core process that the CLI cannot -constrain. +## Restricted networking + +`TALER_WALLET_OFFLINE` controls requests made by the CLI and its local wallet-core: + +- Unset, empty, or `0`: normal networking. +- `1`: reject every request before it reaches the network. +- `readonly`: allow only protocol operations explicitly reviewed as read-only. + Unmarked requests are blocked, even if they use GET. + +Other values fail with a usage error. For example: + +```sh +TALER_WALLET_OFFLINE=readonly taler-wallet-cli --wallet-db /path/to/wallet.sqlite3 exchanges list +``` + +Readonly mode currently allows: + +- Exchange configuration, keys, seed, terms, reserve status/history, coin + history, purse status, existing P2P contracts, deposit tracking, and KYC + status checks (`kyc-check`). +- Exchange blinding preparation (`POST /blinding-prepare`), which computes + cryptographic inputs without committing a withdrawal or refresh. +- Merchant configuration, exchange lists, payment status, and session/order + lookup. +- Bank integration configuration and withdrawal-operation status; bank + conversion configuration and rate queries. +- Directory, mailbox, and Donau configuration, plus Donau keys and seed. +- Exchange URL discovery through the configuration endpoint. + +All other operations remain blocked, including payments, claims, refunds, +withdrawals, refreshes, registrations, arbitrary Paivana resources, and KYC +initiation/completion. KYC-info requests are blocked because they can create +legitimization measures on the exchange. Readonly mode rejects redirects, +including during exchange URL discovery, since their destinations have not +been reviewed. + +These restrictions prevent server-side application changes through the +allowed protocol operations; ordinary server logging and caching are outside +this guarantee. Local wallet database updates remain possible in both modes. + +Neither restricted mode supports `--wallet-connection` or +`TALER_WALLET_CONNECTION`: those connect to a separately running wallet-core +process whose networking the CLI cannot constrain. diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts @@ -67,11 +67,6 @@ import { readlinePrompt, setUnhandledRejectionHandler, } from "@gnu-taler/taler-util/compat"; -import { - createPlatformHttpLib, - type HttpRequestLibrary, - type HttpRequestOptions, -} from "@gnu-taler/taler-util/http"; import { JsonMessage, runRpcServer } from "@gnu-taler/taler-util/twrpc"; import { createNativeWalletHost2, @@ -95,6 +90,10 @@ import { ContinuationKind, } from "./continuation.js"; import { CLI_ENABLE_VAR, parseEnabledMarks } from "./marks.js"; +import { + createWalletHttpLib, + requireLocalWalletNetworking, +} from "./wallet-http.js"; import { formatTxRef, parseTxRef, TX_REF_SYNTAX } from "./txref.js"; import { CliUsageError, @@ -340,7 +339,7 @@ async function doPaivana( console.log(cookie); return; case "body": { - const http = createPlatformHttpLib({ enableThrottling: false }); + const http = createWalletHttpLib({ enableThrottling: false }); const response = await http.fetch(prepared.paivana.url, { headers: { Cookie: cookie }, redirect: "follow", @@ -609,35 +608,6 @@ function checkEnvFlag(name: string): boolean { return false; } -/** - * HTTP implementation used when TALER_WALLET_OFFLINE=1. - * - * Rejecting at this boundary means the request cannot reach the platform HTTP - * implementation, and thus cannot result in a connection to a server. - */ -class OfflineHttpLib implements HttpRequestLibrary { - async fetch(requestUrl: string, opt?: HttpRequestOptions): Promise<never> { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_NETWORK_ERROR, - { - requestUrl, - requestMethod: opt?.method ?? "GET", - }, - "network requests are disabled by TALER_WALLET_OFFLINE=1", - ); - } -} - -function createWalletHttpLib(args?: { - enableThrottling?: boolean; - requireTls?: boolean; -}): HttpRequestLibrary { - if (checkEnvFlag("TALER_WALLET_OFFLINE")) { - return new OfflineHttpLib(); - } - return createPlatformHttpLib(args); -} - export interface WalletContext { /** * High-level client for making API requests to wallet-core. @@ -784,12 +754,7 @@ async function withWallet<T>( } if (walletSocketPath) { - if (checkEnvFlag("TALER_WALLET_OFFLINE")) { - throw new CliUsageError( - "TALER_WALLET_OFFLINE=1 cannot be used with a remote wallet connection", - "offline mode only supports the wallet-core process started by taler-wallet-cli", - ); - } + requireLocalWalletNetworking(); logger.info("creating remote wallet"); const w = await createRemoteWallet({ name: "wallet", diff --git a/packages/taler-wallet-cli/src/wallet-http.test.ts b/packages/taler-wallet-cli/src/wallet-http.test.ts @@ -0,0 +1,350 @@ +/* + 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 assert from "node:assert/strict"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { test } from "node:test"; +import { + type AccessToken, + CancellationToken, + DenomKeyType, + DonauHttpClient, + Duration, + TalerBankConversionHttpClient, + TalerBankIntegrationHttpClient, + TalerDirectoryInstanceHttpClient, + TalerError, + TalerErrorCode, + TalerExchangeHttpClient, + TalerMailboxInstanceHttpClient, + TalerMerchantInstanceHttpClient, +} from "@gnu-taler/taler-util"; +import { + createPlatformHttpLib, + HeadersImpl, + type HttpRequestLibrary, + type HttpRequestOptions, + type HttpResponse, +} from "@gnu-taler/taler-util/http"; +import { CliUsageError } from "./waitspec.js"; +import { + createWalletHttpLib, + parseWalletNetworkMode, + requireLocalWalletNetworking, + WalletHttpLib, +} from "./wallet-http.js"; + +test("network modes parse strictly and restricted modes reject remote wallets", () => { + for (const value of [undefined, "", "0"]) { + assert.equal(parseWalletNetworkMode(value), "online"); + } + assert.equal(parseWalletNetworkMode("1"), "offline"); + assert.equal(parseWalletNetworkMode("readonly"), "readonly"); + for (const value of ["2", "true", "read-only", "READONLY", " 1"]) { + assert.throws(() => parseWalletNetworkMode(value), CliUsageError); + } + requireLocalWalletNetworking("online"); + for (const mode of ["offline", "readonly"] as const) { + assert.throws(() => requireLocalWalletNetworking(mode), CliUsageError); + } +}); + +function isPolicyError(error: unknown): error is TalerError { + return ( + error instanceof TalerError && + error.errorDetail.code === TalerErrorCode.WALLET_NETWORK_ERROR + ); +} + +test("policy blocks before transport and preserves allowed request options and responses", async () => { + const url = "https://example.com/resource"; + let calls = 0; + let forwarded: HttpRequestOptions | undefined; + const response: HttpResponse = { + requestUrl: url, + requestMethod: "GET", + status: 200, + headers: new HeadersImpl(), + async json() { + return {}; + }, + async text() { + return "ok"; + }, + async bytes() { + return new Uint8Array(); + }, + }; + const transport: HttpRequestLibrary = { + async fetch(requestUrl, opt) { + assert.equal(requestUrl, url); + calls++; + forwarded = opt; + return response; + }, + }; + for (const mode of ["online", "offline", "readonly"] as const) { + const http = new WalletHttpLib(transport, mode); + for (const method of [ + undefined, + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + ] as const) { + for (const readOnly of [undefined, false, true]) { + const opt: HttpRequestOptions = { + method, + readOnly, + headers: { Authorization: "test" }, + body: { query: "test" }, + timeout: Duration.fromSpec({ seconds: 1 }), + cancellationToken: CancellationToken.CONTINUE, + redirect: "follow", + }; + const previousCalls = calls; + if (mode === "offline" || (mode === "readonly" && readOnly !== true)) { + await assert.rejects(http.fetch(url, opt), (error) => { + assert.ok(isPolicyError(error)); + assert.equal(error.errorDetail.requestUrl, url); + assert.equal(error.errorDetail.requestMethod, method ?? "GET"); + assert.match(error.message, /TALER_WALLET_OFFLINE=/); + return true; + }); + assert.equal(calls, previousCalls); + } else { + assert.equal(await http.fetch(url, opt), response); + assert.equal(calls, previousCalls + 1); + assert.deepEqual( + forwarded, + mode === "readonly" ? { ...opt, redirect: "error" } : opt, + ); + assert.equal(opt.redirect, "follow"); + } + } + } + const previousCalls = calls; + if (mode === "online") { + assert.equal(await http.fetch(url), response); + assert.equal(forwarded, undefined); + } else { + await assert.rejects(http.fetch(url), isPolicyError); + assert.equal(calls, previousCalls); + } + } +}); + +test("reviewed protocol operations pass readonly policy, including long polls and a POST query", async () => { + const reachedTransport = new Error("request reached transport"); + let calls = 0; + const http = new WalletHttpLib( + { + async fetch(_url, opt) { + calls++; + assert.equal(opt?.redirect, "error"); + throw reachedTransport; + }, + }, + "readonly", + ); + const base = "https://example.com/prefix/"; + const exchange = new TalerExchangeHttpClient(base, { httpClient: http }); + const merchant = new TalerMerchantInstanceHttpClient(base, http); + const bank = new TalerBankIntegrationHttpClient(base, { httpClient: http }); + const conversion = new TalerBankConversionHttpClient(base, http); + const donau = new DonauHttpClient(base, { httpClient: http }); + const operations: Array<() => Promise<unknown>> = [ + () => exchange.getConfig(), + () => exchange.getKeys(), + () => exchange.getSeed(), + () => exchange.getTermsText(), + () => exchange.getTermsMeta(), + () => exchange.getReserveStatus("reserve"), + () => exchange.getReserveStatus("reserve", true), + () => exchange.getReserveHistory("reserve", "sig", 5), + () => exchange.getCoinHistory("coin", "sig"), + () => exchange.getPurseStatusAtMerge("purse", true), + () => exchange.getPurseStatusAtDeposit("purse"), + () => exchange.getContract("contract"), + () => + exchange.trackDeposit({ + wireHash: "wire", + merchantPub: "merchant", + contractTermsHash: "contract", + coinPub: "coin", + merchantSig: "sig", + }), + () => + exchange.checkKycStatus({ + paytoHash: "payto", + accountPub: "account", + accountSig: "sig", + longpoll: true, + }), + () => + exchange.postBlindingPrepare({ + body: { + cipher: DenomKeyType.ClauseSchnorr, + operation: "withdraw", + seed: "seed", + nks: [], + }, + }), + () => merchant.getConfig(), + () => merchant.listExchanges(), + () => merchant.getPaymentStatus("order"), + () => + merchant.getOrderIdForSessionAndUrl("session", "https://example.com/"), + () => bank.getConfig(), + () => bank.getWithdrawalOperationById("withdrawal"), + () => conversion.getConfig(), + () => conversion.getRate(undefined), + () => conversion.getCashinRate(undefined, {}), + () => conversion.getCashoutRate(undefined, {}), + () => new TalerDirectoryInstanceHttpClient(base, http).getConfig(), + () => new TalerMailboxInstanceHttpClient(base, http).getConfig(), + () => donau.getConfig(), + () => donau.getKeys(), + () => donau.getSeed(), + ]; + for (const operation of operations) { + await assert.rejects(operation(), (error) => error === reachedTransport); + } + assert.equal(calls, operations.length); +}); + +test("readonly blocks payments, refunds, and state-changing KYC GETs", async () => { + let calls = 0; + const http = new WalletHttpLib( + { + async fetch() { + calls++; + throw new Error("unexpected transport"); + }, + }, + "readonly", + ); + const exchange = new TalerExchangeHttpClient("https://example.com/", { + httpClient: http, + }); + const merchant = new TalerMerchantInstanceHttpClient( + "https://example.com/", + http, + ); + await assert.rejects( + merchant.makePayment("order", { coins: [] }), + isPolicyError, + ); + await assert.rejects( + exchange.refundCoin("coin", { + refund_amount: "TESTKUDOS:1", + h_contract_terms: "contract", + rtransaction_id: 1, + merchant_pub: "merchant", + merchant_sig: "sig", + }), + isPolicyError, + ); + await assert.rejects( + exchange.checkKycInfo("token" as AccessToken), + isPolicyError, + ); + await assert.rejects( + exchange.completeExternalKycProcess("provider", "state", "code"), + isPolicyError, + ); + assert.equal(calls, 0); +}); + +test("restricted networking never sends blocked requests or follows redirects", async (t) => { + const requests: string[] = []; + const server = createServer((req, res) => { + requests.push(req.url!); + if (req.url!.startsWith("/redirect/")) { + res.writeHead(Number(req.url!.split("/").pop()), { + location: "/destination", + }); + } + res.end("ok"); + }); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after( + () => + new Promise<void>((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + ); + const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + const platform = createPlatformHttpLib({ enableThrottling: false }); + const readonly = new WalletHttpLib(platform, "readonly"); + await assert.rejects(readonly.fetch(`${base}/blocked`), isPolicyError); + await assert.rejects( + new WalletHttpLib(platform, "offline").fetch(`${base}/blocked`, { + readOnly: true, + }), + isPolicyError, + ); + assert.equal(requests.length, 0); + for (const status of [301, 302, 303, 307, 308]) { + for (const redirect of [undefined, "manual", "follow", "error"] as const) { + await assert.rejects( + readonly.fetch(`${base}/redirect/${status}`, { + readOnly: true, + redirect, + }), + isPolicyError, + ); + } + } + assert.equal(requests.length, 20); + assert.ok(requests.every((path) => path.startsWith("/redirect/"))); + assert.equal( + await (await readonly.fetch(`${base}/allowed`, { readOnly: true })).text(), + "ok", + ); + assert.equal( + await ( + await new WalletHttpLib(platform, "online").fetch(`${base}/redirect/302`) + ).text(), + "ok", + ); + assert.equal(requests.at(-1), "/destination"); +}); + +test("CLI HTTP factory reads the environment and enforces TLS for allowed requests", async (t) => { + const previous = process.env.TALER_WALLET_OFFLINE; + t.after(() => { + if (previous === undefined) delete process.env.TALER_WALLET_OFFLINE; + else process.env.TALER_WALLET_OFFLINE = previous; + }); + process.env.TALER_WALLET_OFFLINE = "invalid"; + assert.throws(() => createWalletHttpLib(), CliUsageError); + for (const mode of ["1", "readonly"]) { + process.env.TALER_WALLET_OFFLINE = mode; + await assert.rejects( + createWalletHttpLib().fetch("http://127.0.0.1:1/paivana"), + isPolicyError, + ); + assert.throws(() => requireLocalWalletNetworking(), CliUsageError); + } + const http = createWalletHttpLib({ requireTls: true }); + await assert.rejects( + http.fetch("http://127.0.0.1:1/config", { readOnly: true }), + /is not possible with protocol http:/, + ); +}); diff --git a/packages/taler-wallet-cli/src/wallet-http.ts b/packages/taler-wallet-cli/src/wallet-http.ts @@ -0,0 +1,99 @@ +/* + 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 { TalerError, TalerErrorCode } from "@gnu-taler/taler-util"; +import { getenv } from "@gnu-taler/taler-util/compat"; +import { + createPlatformHttpLib, + type HttpLibArgs, + type HttpRequestLibrary, + type HttpRequestOptions, + type HttpResponse, +} from "@gnu-taler/taler-util/http"; +import { CliUsageError } from "./waitspec.js"; + +export type WalletNetworkMode = "online" | "offline" | "readonly"; + +export function parseWalletNetworkMode( + value: string | undefined, +): WalletNetworkMode { + switch (value) { + case undefined: + case "": + case "0": + return "online"; + case "1": + return "offline"; + case "readonly": + return "readonly"; + default: + throw new CliUsageError( + `invalid TALER_WALLET_OFFLINE value: ${value}`, + "use 0 for normal networking, 1 for offline, or readonly for reviewed read-only operations", + ); + } +} + +export function requireLocalWalletNetworking( + mode = parseWalletNetworkMode(getenv("TALER_WALLET_OFFLINE")), +): void { + if (mode !== "online") { + throw new CliUsageError( + `TALER_WALLET_OFFLINE=${mode === "offline" ? "1" : mode} cannot be used with a remote wallet connection`, + "restricted networking only supports the wallet-core process started by taler-wallet-cli", + ); + } +} + +/** + * Enforce policy before the platform HTTP implementation can open a connection. + * Even GET requests require explicit review in readonly mode. + */ +export class WalletHttpLib implements HttpRequestLibrary { + constructor( + private readonly underlying: HttpRequestLibrary, + private readonly mode: WalletNetworkMode, + ) {} + + async fetch( + requestUrl: string, + opt?: HttpRequestOptions, + ): Promise<HttpResponse> { + if ( + this.mode === "offline" || + (this.mode === "readonly" && opt?.readOnly !== true) + ) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_NETWORK_ERROR, + { requestUrl, requestMethod: opt?.method ?? "GET" }, + this.mode === "offline" + ? "network requests are disabled by TALER_WALLET_OFFLINE=1" + : "request is not marked read-only and is disabled by TALER_WALLET_OFFLINE=readonly", + ); + } + // A reviewed endpoint may redirect to an unreviewed operation. Override + // manual too, so higher-level redirect loops never receive the redirect. + return this.underlying.fetch( + requestUrl, + this.mode === "readonly" ? { ...opt, redirect: "error" } : opt, + ); + } +} + +export function createWalletHttpLib(args?: HttpLibArgs): HttpRequestLibrary { + const mode = parseWalletNetworkMode(getenv("TALER_WALLET_OFFLINE")); + return new WalletHttpLib(createPlatformHttpLib(args), mode); +} diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts @@ -818,6 +818,7 @@ async function probeExchangeBaseUrl( let resp: HttpResponse; try { resp = await wex.http.fetch(configUrl, { + readOnly: true, redirect: "manual", cancellationToken: wex.cancellationToken, });