Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/main/kotlin/com/guardcore/ktor/KtorGuardResponse.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down
38 changes: 38 additions & 0 deletions src/test/kotlin/com/guardcore/ktor/GuardReturnPatternTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading