From a60c50e4165dcf180456c5b2ded396dc515e47e9 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sat, 17 Oct 2026 04:54:26 +0200 Subject: [PATCH] fix(response): implement BoundedResponseBodyReader so body return patterns evaluate Closes #25 --- CHANGELOG.md | 6 +++ .../com/guardcore/ktor/KtorGuardResponse.kt | 7 +++- .../guardcore/ktor/GuardReturnPatternTest.kt | 38 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/test/kotlin/com/guardcore/ktor/GuardReturnPatternTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c327589..ce52a3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## Unreleased + +### Fixed + +- Body based `return_pattern` behavior rules now evaluate: `KtorGuardResponse` implements `BoundedResponseBodyReader`, returning a bounded prefix of the materialized body (empty when a streaming body was left unread). Before, the engine could never read the response body in this adapter, so such rules never triggered. + ## [1.1.0] - 2026-10-16 ### Changed diff --git a/src/main/kotlin/com/guardcore/ktor/KtorGuardResponse.kt b/src/main/kotlin/com/guardcore/ktor/KtorGuardResponse.kt index 35b7a07..ded122a 100644 --- a/src/main/kotlin/com/guardcore/ktor/KtorGuardResponse.kt +++ b/src/main/kotlin/com/guardcore/ktor/KtorGuardResponse.kt @@ -1,5 +1,6 @@ package com.guardcore.ktor +import com.guardcore.protocols.BoundedResponseBodyReader import com.guardcore.protocols.GuardResponse import com.guardcore.protocols.GuardResponseFactory import io.ktor.http.ContentType @@ -18,7 +19,7 @@ import io.ktor.server.response.respondBytes public class KtorGuardResponse( override val statusCode: Int, bodyBytes: ByteArray, -) : GuardResponse { +) : GuardResponse, BoundedResponseBodyReader { /** Python `create_response` shape: string content. */ public constructor( statusCode: Int, @@ -36,6 +37,10 @@ public class KtorGuardResponse( override val body: ByteArray get() = currentBody + /** Python `read_body_prefix` (adapters.py:131): empty when a streaming body was left unread. */ + override suspend fun readBodyPrefix(maxBytes: Int): ByteArray = + currentBody.copyOf(minOf(maxBytes, currentBody.size)) + /** The response-exit path materializes a live view over the outgoing content. */ internal fun overrideBody(bytes: ByteArray) { currentBody = bytes diff --git a/src/test/kotlin/com/guardcore/ktor/GuardReturnPatternTest.kt b/src/test/kotlin/com/guardcore/ktor/GuardReturnPatternTest.kt new file mode 100644 index 0000000..ace379b --- /dev/null +++ b/src/test/kotlin/com/guardcore/ktor/GuardReturnPatternTest.kt @@ -0,0 +1,38 @@ +package com.guardcore.ktor + +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.call +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Body based return_pattern rules read the response body through BoundedResponseBodyReader. */ +class GuardReturnPatternTest { + @Test + fun bodyReturnPatternBansTheClientOnceTheThresholdIsExceeded() { + val config = baseConfig { behaviorScanResponseBody = true } + val decorator = GuardRouteDecorator(config) + guardTest( + config, + decorator = decorator, + routes = { + get("/leaky") { call.respondText("here is a leaked-token") } + .guardReturnMonitor(decorator, pattern = "leaked-token", maxOccurrences = 1) + }, + ) { + repeat(2) { + val response = + client.get("/leaky") { headers { append(HttpHeaders.XForwardedFor, TEST_CLIENT_IP) } } + assertEquals(HttpStatusCode.OK, response.status) + } + // The tracker trips when the count exceeds the threshold, so the second hit bans. + val banned = + client.get("/leaky") { headers { append(HttpHeaders.XForwardedFor, TEST_CLIENT_IP) } } + assertEquals(HttpStatusCode.Forbidden, banned.status) + } + } +}