libeufin

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

commit 7aaa18caacf0d10fa287d75e03e4fb322e128437
parent c62a6ea1b16657bf410c10baf52d7aeb5e790134
Author: Antoine A <>
Date:   Mon,  1 Jul 2024 16:02:10 +0200

common: optimize body limit and clean code

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt | 5+++--
Mbank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 6+++---
Mbank/src/test/kotlin/CoreBankApiTest.kt | 12++++++------
Mbank/src/test/kotlin/SecurityTest.kt | 42++++++++++++++++++++++++++++++++++--------
Mbank/src/test/kotlin/bench.kt | 4++--
Mbank/src/test/kotlin/helpers.kt | 2+-
Mbank/src/test/kotlin/routines.kt | 4++--
Mcommon/src/main/kotlin/Constants.kt | 10+++++++---
Mcommon/src/main/kotlin/api/server.kt | 13+++++++++----
Mnexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt | 2+-
Mnexus/src/test/kotlin/helpers.kt | 3++-
Mnexus/src/test/kotlin/routines.kt | 4++--
13 files changed, 73 insertions(+), 36 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt @@ -23,6 +23,7 @@ import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import kotlinx.serialization.json.Json +import tech.libeufin.common.X_CHALLENGE_ID import tech.libeufin.bank.* import tech.libeufin.bank.db.Database import tech.libeufin.bank.db.TanDAO.Challenge @@ -71,7 +72,7 @@ suspend inline fun <reified B> ApplicationCall.receiveChallenge( db: Database, op: Operation ): Pair<B, Challenge?> { - val id = request.headers["X-Challenge-Id"]?.toLongOrNull() + val id = request.headers[X_CHALLENGE_ID]?.toLongOrNull() return if (id != null) { val challenge = db.tan.challenge(id, username, op)!! Pair(Json.decodeFromString(challenge.body), challenge) @@ -87,7 +88,7 @@ suspend fun ApplicationCall.checkChallenge( db: Database, op: Operation ): Challenge? { - val id = request.headers["X-Challenge-Id"]?.toLongOrNull() + val id = request.headers[X_CHALLENGE_ID]?.toLongOrNull() return if (id != null) { db.tan.challenge(id, username, op)!! } else { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt @@ -109,7 +109,7 @@ fun Route.auth(db: Database, scope: TokenScope, allowAdmin: Boolean = false, req * Returns the authenticated customer login. */ private suspend fun ApplicationCall.authenticateBankRequest(db: Database, requiredScope: TokenScope): String { - val header = request.headers["Authorization"] + val header = request.headers[HttpHeaders.Authorization] // Basic auth challenge if (header == null) { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023 Taler Systems S.A. + * Copyright (C) 2023-2024 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 @@ -76,7 +76,7 @@ fun ApplicationRequest.talerWithdrawUri(id: UUID) = url { ) host = "withdraw" appendPathSegments("${origin.serverHost}:${origin.serverPort}") - headers["X-Forward-Prefix"]?.let { + headers[X_FORWARD_PREFIX]?.let { appendPathSegments(it) } appendPathSegments("taler-integration", id.toString()) @@ -88,7 +88,7 @@ fun ApplicationRequest.withdrawConfirmUrl(id: UUID) = url { defaultPort = -1 ) host = "${origin.serverHost}:${origin.serverPort}" - headers["X-Forward-Prefix"]?.let { + headers[X_FORWARD_PREFIX]?.let { appendPathSegments(it) } appendEncodedPathSegments("webui", "#", "operation", id.toString()) diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -98,7 +98,7 @@ class CoreBankTokenApiTest { }.assertOkJson<TokenSuccessResponse> { val token = it.access_token client.post("/accounts/merchant/token") { - headers["Authorization"] = "Bearer $token" + headers[HttpHeaders.Authorization] = "Bearer $token" json { "scope" to toScope } }.assertOk() } @@ -118,7 +118,7 @@ class CoreBankTokenApiTest { }.assertOkJson<TokenSuccessResponse> { val token = it.access_token client.post("/accounts/merchant/token") { - headers["Authorization"] = "Bearer $token" + headers[HttpHeaders.Authorization] = "Bearer $token" json { "scope" to toScope } }.assertForbidden(TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT) } @@ -132,7 +132,7 @@ class CoreBankTokenApiTest { }.assertOkJson<TokenSuccessResponse> { val token = it.access_token client.post("/accounts/merchant/token") { - headers["Authorization"] = "Bearer $token" + headers[HttpHeaders.Authorization] = "Bearer $token" json { "scope" to "readonly" } }.assertUnauthorized() } @@ -186,11 +186,11 @@ class CoreBankTokenApiTest { }.assertOkJson<TokenSuccessResponse>().access_token // Check OK client.delete("/accounts/merchant/token") { - headers["Authorization"] = "Bearer $token" + headers[HttpHeaders.Authorization] = "Bearer $token" }.assertNoContent() // Check token no longer work client.delete("/accounts/merchant/token") { - headers["Authorization"] = "Bearer $token" + headers[HttpHeaders.Authorization] = "Bearer $token" }.assertUnauthorized() // Checking merchant can still be served by basic auth, after token deletion. @@ -590,7 +590,7 @@ class CoreBankAccountsApiTest { // Check account can no longer login client.delete("/accounts/customer/token") { - headers["Authorization"] = "Bearer $token" + headers[HttpHeaders.Authorization] = "Bearer $token" }.assertUnauthorized() client.getA("/accounts/customer/transactions/$tx_id").assertUnauthorized() client.getA("/accounts/customer/cashouts/$cashout_id").assertUnauthorized() diff --git a/bank/src/test/kotlin/SecurityTest.kt b/bank/src/test/kotlin/SecurityTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023 Taler Systems S.A. + * Copyright (C) 2023-2024 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 @@ -19,6 +19,7 @@ import io.ktor.client.request.* import io.ktor.http.* +import io.ktor.http.content.OutputStreamContent import kotlinx.serialization.json.Json import org.junit.Test import tech.libeufin.common.* @@ -30,6 +31,21 @@ inline fun <reified B> HttpRequestBuilder.jsonDeflate(b: B) { setBody(json.toByteArray().inputStream().deflate().readBytes()) } +inline fun <reified B> HttpRequestBuilder.jsonStreamDeflate(b: B) { + val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), b) + headers[HttpHeaders.ContentEncoding] = "deflate" + setBody(OutputStreamContent({ + write(json.toByteArray().inputStream().deflate().readBytes()) + }, ContentType.Application.Json)) +} + +inline fun <reified B> HttpRequestBuilder.jsonStream(b: B) { + val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), b) + setBody(OutputStreamContent({ + write(json.toByteArray()) + }, ContentType.Application.Json)) +} + class SecurityTest { @Test fun bodySizeLimit() = bankSetup { @@ -37,22 +53,32 @@ class SecurityTest { "payto_uri" to "$exchangePayto?message=payout" "amount" to "KUDOS:0.3" } + val too_big = obj(valid_req) { + "payto_uri" to "$exchangePayto?message=payout${"A".repeat(MAX_BODY_LENGTH+1)}" + } + client.postA("/accounts/merchant/transactions") { json(valid_req) }.assertOk() - + // Check body too big client.postA("/accounts/merchant/transactions") { - json(valid_req) { - "payto_uri" to "$exchangePayto?message=payout${"A".repeat(4100)}" - } + json(too_big) }.assertBadRequest() // Check body too big even after compression client.postA("/accounts/merchant/transactions") { - jsonDeflate(obj(valid_req) { - "payto_uri" to "$exchangePayto?message=payout${"A".repeat(4100)}" - }) + jsonDeflate(too_big) + }.assertBadRequest() + + // Check streaming body too big + client.postA("/accounts/merchant/transactions") { + jsonStream(too_big) + }.assertBadRequest() + + // Check streaming body too big even after compression + client.postA("/accounts/merchant/transactions") { + jsonStreamDeflate(too_big) }.assertBadRequest() // Check unknown encoding diff --git a/bank/src/test/kotlin/bench.kt b/bank/src/test/kotlin/bench.kt @@ -241,7 +241,7 @@ class Bench { } measureAction("token_refresh") { client.post("/accounts/customer/token") { - headers["Authorization"] = "Bearer ${tokens[it]}" + headers[HttpHeaders.Authorization] = "Bearer ${tokens[it]}" json { "scope" to "readonly" } }.assertOk() } @@ -250,7 +250,7 @@ class Bench { } measureAction("token_delete") { client.delete("/accounts/customer/token") { - headers["Authorization"] = "Bearer ${tokens[it]}" + headers[HttpHeaders.Authorization] = "Bearer ${tokens[it]}" }.assertNoContent() } diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -342,7 +342,7 @@ suspend fun HttpResponse.assertChallenge( return call.client.request(this.call.request.url) { pwAuth(username) method = call.request.method - headers["X-Challenge-Id"] = "$id" + headers[X_CHALLENGE_ID] = "$id" } } diff --git a/bank/src/test/kotlin/routines.kt b/bank/src/test/kotlin/routines.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023 Taler Systems S.A. + * Copyright (C) 2023-2024 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 @@ -50,7 +50,7 @@ suspend fun ApplicationTestBuilder.authRoutine( // Bad header client.request(path) { this.method = method - headers["Authorization"] = "WTF" + headers[HttpHeaders.Authorization] = "WTF" }.assertBadRequest(TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED) // Unknown account diff --git a/common/src/main/kotlin/Constants.kt b/common/src/main/kotlin/Constants.kt @@ -23,8 +23,12 @@ const val MIN_VERSION: Int = 14 const val SERIALIZATION_RETRY: Int = 10 // Security -const val MAX_BODY_LENGTH: Long = 4 * 1024 // 4kB +const val MAX_BODY_LENGTH: Int = 4 * 1024 // 4kB // API version const val WIRE_GATEWAY_API_VERSION: String = "0:2:0" -const val REVENUE_API_VERSION: String = "0:0:0" -\ No newline at end of file +const val REVENUE_API_VERSION: String = "0:0:0" + +// HTTP headers +const val X_CHALLENGE_ID: String = "X-Challenge-Id" +const val X_FORWARD_PREFIX: String = "X-Forward-Prefix" +\ No newline at end of file diff --git a/common/src/main/kotlin/api/server.kt b/common/src/main/kotlin/api/server.kt @@ -50,9 +50,14 @@ import java.util.zip.Inflater fun bodyLimitPlugin(logger: Logger): ApplicationPlugin<Unit> { return createApplicationPlugin("BodyLimitAndDecompression") { onCallReceive { call -> - // TODO check content length as an optimisation + // Check content length if present and wellformed + val contentLenght = call.request.headers[HttpHeaders.ContentLength]?.toIntOrNull() + if (contentLenght != null && contentLenght > MAX_BODY_LENGTH) + throw badRequest("Body is suspiciously big > ${MAX_BODY_LENGTH}B") + + // Else check while reading and decompressing the body transformBody { body -> - val bytes = ByteArray(MAX_BODY_LENGTH.toInt() + 1) + val bytes = ByteArray(MAX_BODY_LENGTH + 1) var read = 0 when (val encoding = call.request.headers[HttpHeaders.ContentEncoding]) { "deflate" -> { @@ -72,7 +77,7 @@ fun bodyLimitPlugin(logger: Logger): ApplicationPlugin<Unit> { } } if (read > MAX_BODY_LENGTH) - throw badRequest("Decompressed body is suspiciously big > $MAX_BODY_LENGTH B") + throw badRequest("Decompressed body is suspiciously big > ${MAX_BODY_LENGTH}B") } } null -> { @@ -82,7 +87,7 @@ fun bodyLimitPlugin(logger: Logger): ApplicationPlugin<Unit> { if (new == -1) break // Channel is closed read += new if (read > MAX_BODY_LENGTH) - throw badRequest("Body is suspiciously big > $MAX_BODY_LENGTH B") + throw badRequest("Body is suspiciously big > ${MAX_BODY_LENGTH}B") } } else -> throw unsupportedMediaType( diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt @@ -33,7 +33,7 @@ fun Route.authApi(cfg: ApiConfig?, callback: Route.() -> Unit): Route = if (cfg == null) { throw apiError(HttpStatusCode.NotImplemented, "API not implemented", TalerErrorCode.END) } - val header = context.request.headers["Authorization"] + val header = context.request.headers[HttpHeaders.Authorization] // Basic auth challenge when (cfg.authMethod) { AuthMethod.None -> {} diff --git a/nexus/src/test/kotlin/helpers.kt b/nexus/src/test/kotlin/helpers.kt @@ -17,6 +17,7 @@ * <http://www.gnu.org/licenses/> */ +import io.ktor.http.* import io.ktor.client.* import io.ktor.client.engine.mock.* import io.ktor.client.request.* @@ -203,5 +204,5 @@ suspend inline fun HttpClient.deleteA(url: String, builder: HttpRequestBuilder.( } fun HttpRequestBuilder.auth() { - headers["Authorization"] = "Bearer secret-token" + headers[HttpHeaders.Authorization] = "Bearer secret-token" } \ No newline at end of file diff --git a/nexus/src/test/kotlin/routines.kt b/nexus/src/test/kotlin/routines.kt @@ -39,13 +39,13 @@ suspend fun ApplicationTestBuilder.authRoutine( // Bad header client.request(path) { this.method = method - headers["Authorization"] = "WTF" + headers[HttpHeaders.Authorization] = "WTF" }.assertBadRequest(TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED) // Bad token client.request(path) { this.method = method - headers["Authorization"] = "Bearer bad-token" + headers[HttpHeaders.Authorization] = "Bearer bad-token" }.assertUnauthorized() // GLS deployment