libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 296a8e08182006e2fca9fab05c5db79b66576584
parent 112e39a988b663a56d13f63d4c6d60ed53b22264
Author: Antoine A <>
Date:   Thu, 22 May 2025 11:56:20 +0200

common: validate BaseURL

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Config.kt | 6+++---
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 2+-
Mbank/src/test/kotlin/CoreBankApiTest.kt | 2+-
Mbank/src/test/kotlin/GcTest.kt | 2+-
Mbank/src/test/kotlin/WireGatewayApiTest.kt | 9+++++++++
Mbank/src/test/kotlin/helpers.kt | 6+++---
Mcommon/src/main/kotlin/Subject.kt | 4++--
Mcommon/src/main/kotlin/TalerCommon.kt | 38++++++++++++++++++++++++++++----------
Mcommon/src/main/kotlin/TalerConfig.kt | 3+++
Mcommon/src/main/kotlin/TalerMessage.kt | 2+-
Mcommon/src/main/kotlin/api/server.kt | 10+++-------
Acommon/src/test/kotlin/BaseUrlTest.kt | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt | 2+-
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt | 2+-
Mnexus/src/test/kotlin/DatabaseTest.kt | 14+++++++-------
Mnexus/src/test/kotlin/WireGatewayApiTest.kt | 9+++++++++
16 files changed, 126 insertions(+), 38 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2024 Taler Systems S.A. + * Copyright (C) 2023-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -30,7 +30,7 @@ import java.time.Duration data class BankConfig( private val cfg: TalerConfig, val name: String, - val baseUrl: String?, + val baseUrl: BaseURL?, val regionalCurrency: String, val regionalCurrencySpec: CurrencySpecification, val wireTransferFees: TalerAmount, @@ -146,7 +146,7 @@ private fun TalerConfig.loadBankConfig(): BankConfig = section("libeufin-bank"). maxAmount = amount("max_wire_transfer_amount", regionalCurrency).default(MAX), suggestedWithdrawalExchange = string("suggested_withdrawal_exchange").orNull(), spaPath = path("spa").orNull(), - baseUrl = string("base_url").orNull(), + baseUrl = baseURL("base_url").orNull(), fiatCurrency = fiatCurrency, fiatCurrencySpec = fiatCurrencySpec, tanChannels = tanChannels, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -341,7 +341,7 @@ data class TokenInfos ( data class Config( val currency: String, val currency_specification: CurrencySpecification, - val base_url: String?, + val base_url: BaseURL?, val bank_name: String, val allow_conversion: Boolean, val allow_registrations: Boolean, diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -1354,7 +1354,7 @@ class CoreBankTransactionsApiTest { tx("exchange", "KUDOS:1", "merchant", "") // Warn common to transaction tx("exchange", "KUDOS:1", "merchant", "Malformed") // Warn malformed transaction val wtid = ShortHashCode.rand() - val exchange = ExchangeUrl("http://exchange.example.com/") + val exchange = BaseURL.parse("http://exchange.example.com/") tx("exchange", "KUDOS:1", "merchant", randOutgoingSubject(wtid, exchange)) // Accept outgoing tx("exchange", "KUDOS:1", "merchant", randOutgoingSubject(wtid, exchange)) // Warn wtid reuse assertBalance("merchant", "+KUDOS:3") diff --git a/bank/src/test/kotlin/GcTest.kt b/bank/src/test/kotlin/GcTest.kt @@ -131,7 +131,7 @@ class GcTest { for (time in listOf(now, abort, clean, delete)) { assertIs<TransferResult.Success>( db.exchange.transfer( - TransferRequest(HashCode.rand(), from, ExchangeUrl("http://localhost"), ShortHashCode.rand(), customerPayto), + TransferRequest(HashCode.rand(), from, BaseURL.parse("http://localhost/"), ShortHashCode.rand(), customerPayto), "exchange", time ) ) diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt @@ -119,6 +119,15 @@ class WireGatewayApiTest { "request_uid" to randBase32Crockford(65) } }.assertBadRequest() + + // Bad baseURL + for (bad in sequenceOf("not-a-url", "file://not.http.com/", "no.transport.com/", "https://not.a/base/url")) { + client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + json(valid_req) { + "exchange_base_url" to bad + } + }.assertBadRequest() + } } // GET /accounts/{USERNAME}/taler-wire-gateway/transfers/{ROW_ID} diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2024 Taler Systems S.A. + * Copyright (C) 2023-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -365,4 +365,4 @@ fun assertException(msg: String, lambda: () -> Unit) { fun randBase32Crockford(length: Int) = Base32Crockford.encode(ByteArray(length).rand()) fun randIncomingSubject(reservePub: EddsaPublicKey): String = "$reservePub" -fun randOutgoingSubject(wtid: ShortHashCode, url: ExchangeUrl): String = "$wtid $url" -\ No newline at end of file +fun randOutgoingSubject(wtid: ShortHashCode, url: BaseURL): String = "$wtid $url" +\ No newline at end of file diff --git a/common/src/main/kotlin/Subject.kt b/common/src/main/kotlin/Subject.kt @@ -175,7 +175,7 @@ fun parseIncomingSubject(subject: String): IncomingSubject { } /** Extract the reserve public key from an incoming Taler transaction subject */ -fun parseOutgoingSubject(subject: String): Pair<ShortHashCode, ExchangeUrl> { +fun parseOutgoingSubject(subject: String): Pair<ShortHashCode, BaseURL> { val (wtid, baseUrl) = subject.splitOnce(" ") ?: throw Exception("Malformed outgoing subject") - return Pair(EddsaPublicKey(wtid), ExchangeUrl(baseUrl)) + return Pair(EddsaPublicKey(wtid), BaseURL.parse(baseUrl)) } \ No newline at end of file diff --git a/common/src/main/kotlin/TalerCommon.kt b/common/src/main/kotlin/TalerCommon.kt @@ -80,23 +80,41 @@ data class TalerProtocolTimestamp( } } - -@Serializable(with = ExchangeUrl.Serializer::class) -class ExchangeUrl(raw: String) { - val url: String = URL(raw).toString() +@JvmInline +@Serializable(with = BaseURL.Serializer::class) +value class BaseURL private constructor(val url: String) { + companion object { + fun parse(raw: String): BaseURL { + val url = URL(raw) + if (url.protocol !in setOf("http", "https")) { + throw badRequest("only 'http' and 'https' are accepted for baseURL got '${url.protocol}'") + } else if (url.host.isNullOrBlank()) { + throw badRequest("missing host in baseURL got '${url}'") + } else if (url.query != null) { + throw badRequest("require no query in baseURL got '${url.query}'") + } else if (url.ref != null) { + throw badRequest("require no fragments in baseURL got '${url.ref}'") + } + val encoded = url.toString() + if (!encoded.endsWith('/')) { + throw badRequest("baseURL must end with / got '${encoded}'") + } + return BaseURL(encoded) + } + } override fun toString(): String = url - internal object Serializer : KSerializer<ExchangeUrl> { + internal object Serializer : KSerializer<BaseURL> { override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("ExchangeUrl", PrimitiveKind.STRING) + PrimitiveSerialDescriptor("BaseURL", PrimitiveKind.STRING) - override fun serialize(encoder: Encoder, value: ExchangeUrl) { - encoder.encodeString(value.toString()) + override fun serialize(encoder: Encoder, value: BaseURL) { + encoder.encodeString(value.url) } - override fun deserialize(decoder: Decoder): ExchangeUrl { - return ExchangeUrl(decoder.decodeString()) + override fun deserialize(decoder: Decoder): BaseURL { + return BaseURL.parse(decoder.decodeString()) } } } diff --git a/common/src/main/kotlin/TalerConfig.kt b/common/src/main/kotlin/TalerConfig.kt @@ -458,6 +458,9 @@ class TalerConfigSection internal constructor( /** Access [option] as String */ fun string(option: String) = option(option, "string") { it } + /** Access [option] as BaseURL */ + fun baseURL(option: String) = option(option, "baseURL") { BaseURL.parse(it) } + /** Access [option] as Int */ fun number(option: String) = option(option, "number") { it.toIntOrNull() ?: throw ValueError("'$it' not a valid number") diff --git a/common/src/main/kotlin/TalerMessage.kt b/common/src/main/kotlin/TalerMessage.kt @@ -49,7 +49,7 @@ data class WireGatewayConfig( data class TransferRequest( val request_uid: HashCode, val amount: TalerAmount, - val exchange_base_url: ExchangeUrl, + val exchange_base_url: BaseURL, val wtid: ShortHashCode, val credit_account: Payto ) diff --git a/common/src/main/kotlin/api/server.kt b/common/src/main/kotlin/api/server.kt @@ -204,13 +204,9 @@ fun Application.talerApi(logger: Logger, routes: Routing.() -> Unit) { else -> TalerErrorCode.GENERIC_JSON_INVALID } call.err( - badRequest( - cause.message, - talerErrorCode, - /* Here getting _some_ error message, by giving precedence - * to the root cause, as otherwise JSON details would be lost. */ - rootCause?.message - ), + HttpStatusCode.BadRequest, + rootCause?.message, + talerErrorCode, null ) } diff --git a/common/src/test/kotlin/BaseUrlTest.kt b/common/src/test/kotlin/BaseUrlTest.kt @@ -0,0 +1,52 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin 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 Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import org.junit.Test +import tech.libeufin.common.* +import kotlin.test.* + +class BaseUrlTest { + @Test + fun test() { + for (valid in listOf( + "https://www.example.com/", + "http://localhost:8080/", + "https://api.example.com/v1/", + "https://example.com:3000/path/", + )) { + val parsed = BaseURL.parse(valid) + assertEquals(parsed.url, valid) + } + + for (invalid in listOf( + "https://example.com?param=value", + "https://example.com#section", + "https://not.a/base/url", + "file://not.http.com/", + "not-a-url", + "no.transport.com/", + "://example.com", + "https://", + "", + " ", + )) { + assertFails { BaseURL.parse(invalid) } + } + } +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt @@ -46,7 +46,7 @@ suspend fun registerOutgoingPayment( db: Database, payment: OutgoingPayment ): OutgoingRegistrationResult { - val metadata: Pair<ShortHashCode, ExchangeUrl>? = payment.subject?.let { + val metadata: Pair<ShortHashCode, BaseURL>? = payment.subject?.let { runCatching { parseOutgoingSubject(it) }.getOrNull() } val result = db.payment.registerOutgoing(payment, metadata?.first, metadata?.second) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt @@ -39,7 +39,7 @@ class PaymentDAO(private val db: Database) { suspend fun registerOutgoing( payment: OutgoingPayment, wtid: ShortHashCode?, - baseUrl: ExchangeUrl?, + baseUrl: BaseURL?, ): OutgoingRegistrationResult = db.serializable( """ SELECT out_tx_id, out_initiated, out_found diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt @@ -67,7 +67,7 @@ class OutgoingPaymentsTest { // Register initiated transaction for (subject in sequenceOf( "initiated by nexus", - "${ShortHashCode.rand()} https://exchange.com" + "${ShortHashCode.rand()} https://exchange.com/" )) { val pay = genOutPay(subject) assertIs<PaymentInitiationResult.Success>( @@ -93,7 +93,7 @@ class OutgoingPaymentsTest { // Register unknown for (subject in sequenceOf( "not initiated by nexus", - "${ShortHashCode.rand()} https://exchange.com" + "${ShortHashCode.rand()} https://exchange.com/" )) { val pay = genOutPay(subject) val first = registerOutgoingPayment(db, pay) @@ -108,8 +108,8 @@ class OutgoingPaymentsTest { // Register wtid reuse val wtid = ShortHashCode.rand() for (subject in sequenceOf( - "$wtid https://exchange.com", - "$wtid https://exchange.com" + "$wtid https://exchange.com/", + "$wtid https://exchange.com/" )) { val pay = genOutPay(subject) val first = registerOutgoingPayment(db, pay) @@ -128,9 +128,9 @@ class OutgoingPaymentsTest { val wtid = ShortHashCode.rand() for (subject in sequenceOf( "initiated by nexus", - "${ShortHashCode.rand()} https://exchange.com", - "$wtid https://exchange.com", - "$wtid https://exchange.com" + "${ShortHashCode.rand()} https://exchange.com/", + "$wtid https://exchange.com/", + "$wtid https://exchange.com/" )) { assertIs<PaymentInitiationResult.Success>( db.initiated.create(genInitPay(randEbicsId(), subject=subject)) diff --git a/nexus/src/test/kotlin/WireGatewayApiTest.kt b/nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -113,6 +113,15 @@ class WireGatewayApiTest { "credit_account" to "payto://x-taler-bank/bank.hostname.test/bar" } }.assertBadRequest() + + // Bad baseURL + for (bad in sequenceOf("not-a-url", "file://not.http.com/", "no.transport.com/", "https://not.a/base/url")) { + client.postA("/taler-wire-gateway/transfer") { + json(valid_req) { + "exchange_base_url" to bad + } + }.assertBadRequest() + } } // GET /taler-wire-gateway/transfers/{ROW_ID}