commit 296a8e08182006e2fca9fab05c5db79b66576584
parent 112e39a988b663a56d13f63d4c6d60ed53b22264
Author: Antoine A <>
Date: Thu, 22 May 2025 11:56:20 +0200
common: validate BaseURL
Diffstat:
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}