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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## Unreleased

### Fixed

- `GuardFilter` no longer buffers every response. Responses stream unbuffered (SSE, `StreamingResponseBody`, `ResponseBodyEmitter`, `DeferredResult`, file downloads) and the security headers, CORS headers, metrics and status based behavior rules are applied just before the response commits, the way fastapi-guard's `PureASGISecurityMiddleware` does on `http.response.start`. The response is buffered only on requests where a `custom_response_modifier` is configured or a route or global return-pattern rule inspects the body. Guard-generated rejections are unchanged. The buffered response now implements `BoundedResponseBodyReader`, so body based return patterns (with `guard.behavior-scan-response-body=true`) evaluate; before, they never could.

- Body based `return_pattern` behavior rules now evaluate: `ServletGuardResponse` implements `BoundedResponseBodyReader`. Before, the engine could never read the response body in this adapter.

### Changed

- Streamed responses no longer get a `Content-Length` set by the guard, since the guard no longer buffers them. The container decides, usually chunked encoding.
- Documented how to align Kotlin and coroutines under Spring Boot 3.5 dependency management, which otherwise downgrades both and fails the application at startup (README and docs/installation.md).

## [1.1.0] - 2026-10-16

### Added
Expand Down
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,37 @@ dependencies {

It brings in the engine facade `com.guard-core:guard-core:4.3.3`. To build from source, see "Engine dependency" below, or consume the composite from a sibling checkout.

## Kotlin and coroutines versions under Spring Boot dependency management

Spring Boot 3.5 manages `kotlin.version` 1.9.25, `kotlin-coroutines.version` 1.8.1 and `kotlin-serialization.version` 1.6.3, and dependency management overrides the versions this library was built with (Kotlin 2.4.21, coroutines 1.11.0). A Java application that imports the `spring-boot-dependencies` BOM, or uses the Spring Boot Gradle plugin with `io.spring.dependency-management`, therefore fails at startup with `NoSuchMethodError: kotlinx.coroutines.BuildersKt.runBlockingK$default` (coroutines 1.11.0 added `runBlockingK`), and with coroutines 1.11.0 on a Kotlin 1.9 stdlib every request fails with `ClassNotFoundException: kotlin.coroutines.jvm.internal.SpillingKt`. Align both versions. Serialization can stay on the managed 1.6.3.

Gradle with the Spring Boot plugin and `io.spring.dependency-management`:

```groovy
ext['kotlin.version'] = '2.4.21'
ext['kotlin-coroutines.version'] = '1.11.0'
```

Maven with the BOM imported (declare these imports before `spring-boot-dependencies`, the first declaration wins):

```xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-bom</artifactId>
<version>2.4.21</version><type>pom</type><scope>import</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId><artifactId>kotlinx-coroutines-bom</artifactId>
<version>1.11.0</version><type>pom</type><scope>import</scope>
</dependency>
<!-- then the spring-boot-dependencies import -->
</dependencies>
</dependencyManagement>
```

With `spring-boot-starter-parent` as the parent, set `<kotlin.version>2.4.21</kotlin.version>` and `<kotlin-coroutines.version>1.11.0</kotlin-coroutines.version>` as properties instead.

## Quick start

```kotlin
Expand Down Expand Up @@ -145,8 +176,7 @@ workflow proves the published artifact graph (docs/release.md).

Adapter parity with the reference middleware releases is assessed per upstream feature release:
[docs/adapter-parity-8.1.0.md](docs/adapter-parity-8.1.0.md) maps fastapi-guard 8.1.0's
`PureASGISecurityMiddleware` onto this adapter's `OncePerRequestFilter` idiom (the streaming
trade is documented there honestly: the response wrapper buffers by design).
`PureASGISecurityMiddleware` onto this adapter's `OncePerRequestFilter` idiom. Responses stream unbuffered (SSE, `StreamingResponseBody`, file downloads): security headers, CORS, metrics and status based return rules apply just before the response commits. The response is buffered only on requests where a `custom_response_modifier` is configured or a route or global return-pattern rule inspects the body (`guard.behavior-scan-response-body=true` with a pattern that is not `status:`).

Local checkout:

Expand Down
4 changes: 3 additions & 1 deletion docs/adapter-parity-8.1.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ are the bytes the handler sees, with bounded prefix reads stopping the wire
pull at the cap. This is the exact contract of the reference's caching
receive (and Starlette's own `_body` cache it mirrors).

### 3. Unbuffered response streaming: DOES NOT HOLD (documented finding)
### 3. Unbuffered response streaming: HOLDS since the Unreleased change (history below)

Update: `GuardFilter` now streams by default. It wraps the response in `CommitHookResponse`, which runs the facade's `process_response` exit path and the CORS injection once, just before the first byte can leave (first write, flush, close, `sendError`, `sendRedirect`, or the completion of an async handler), the servlet analog of `http.response.start`. Only requests where a `custom_response_modifier` is configured or a route or global return-pattern rule inspects the body keep the buffering wrapper. The finding below is the original W10 assessment, kept for history.

`GuardFilter` wraps the response in Spring's `ContentCachingResponseWrapper`
and calls `copyBodyToResponse()` only after the facade's `process_response`
Expand Down
31 changes: 31 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,37 @@ dependencies {
The group ID is `com.guard-core` (the Central Portal namespace); Kotlin
packages stay `com.guardcore.*`.

## Kotlin and coroutines versions under Spring Boot dependency management

Spring Boot 3.5 manages `kotlin.version` 1.9.25, `kotlin-coroutines.version` 1.8.1 and `kotlin-serialization.version` 1.6.3, and dependency management overrides the versions this library was built with (Kotlin 2.4.21, coroutines 1.11.0). A Java application that imports the `spring-boot-dependencies` BOM, or uses the Spring Boot Gradle plugin with `io.spring.dependency-management`, therefore fails at startup with `NoSuchMethodError: kotlinx.coroutines.BuildersKt.runBlockingK$default` (coroutines 1.11.0 added `runBlockingK`), and with coroutines 1.11.0 on a Kotlin 1.9 stdlib every request fails with `ClassNotFoundException: kotlin.coroutines.jvm.internal.SpillingKt`. Align both versions. Serialization can stay on the managed 1.6.3.

Gradle with the Spring Boot plugin and `io.spring.dependency-management`:

```groovy
ext['kotlin.version'] = '2.4.21'
ext['kotlin-coroutines.version'] = '1.11.0'
```

Maven with the BOM imported (declare these imports before `spring-boot-dependencies`, the first declaration wins):

```xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-bom</artifactId>
<version>2.4.21</version><type>pom</type><scope>import</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId><artifactId>kotlinx-coroutines-bom</artifactId>
<version>1.11.0</version><type>pom</type><scope>import</scope>
</dependency>
<!-- then the spring-boot-dependencies import -->
</dependencies>
</dependencyManagement>
```

With `spring-boot-starter-parent` as the parent, set `<kotlin.version>2.4.21</kotlin.version>` and `<kotlin-coroutines.version>1.11.0</kotlin-coroutines.version>` as properties instead.

## Engine dependency (includeBuild + dependency substitution)

Consumers resolve the engine artifact `com.guard-core:guard-core` from Maven Central. This repository's own build pins the engine from source instead: CI and
Expand Down
117 changes: 117 additions & 0 deletions src/main/kotlin/com/guardcore/spring/CommitHookResponse.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.guardcore.spring

import jakarta.servlet.ServletOutputStream
import jakarta.servlet.WriteListener
import jakarta.servlet.http.HttpServletResponse
import jakarta.servlet.http.HttpServletResponseWrapper
import java.io.PrintWriter

/**
* The unbuffered response flavor (the `PureASGISecurityMiddleware` response path of fastapi-guard,
* guard/asgi.py): the body streams straight to the container and [beforeCommit] runs exactly once,
* just before the first byte can leave (first write, flush, close, sendError, sendRedirect), the
* servlet analog of the `http.response.start` message. Headers set by the hook therefore still
* reach the client, and nothing is held in memory.
*/
internal class CommitHookResponse(response: HttpServletResponse) :
HttpServletResponseWrapper(response) {
var beforeCommit: (() -> Unit)? = null
private var fired = false
private var stream: ServletOutputStream? = null
private var writer: PrintWriter? = null

/** Runs the hook once; safe to call from any commit point and again after the chain returns. */
fun fireBeforeCommit() {
if (fired) {
return
}
fired = true
beforeCommit?.invoke()
}

override fun getOutputStream(): ServletOutputStream =
stream ?: HookedStream(super.getOutputStream()).also { stream = it }

override fun getWriter(): PrintWriter =
writer ?: HookedWriter(super.getWriter()).also { writer = it }

override fun flushBuffer() {
fireBeforeCommit()
super.flushBuffer()
}

// The status is stamped first so the hook (metrics, status behavior rules) sees the real one.
override fun sendError(sc: Int) {
super.setStatus(sc)
fireBeforeCommit()
super.sendError(sc)
}

override fun sendError(sc: Int, msg: String?) {
super.setStatus(sc)
fireBeforeCommit()
super.sendError(sc, msg)
}

override fun sendRedirect(location: String?) {
super.setStatus(HttpServletResponse.SC_FOUND)
fireBeforeCommit()
super.sendRedirect(location)
}

private inner class HookedStream(private val delegate: ServletOutputStream) :
ServletOutputStream() {
override fun write(b: Int) {
fireBeforeCommit()
delegate.write(b)
}

override fun write(b: ByteArray, off: Int, len: Int) {
fireBeforeCommit()
delegate.write(b, off, len)
}

override fun flush() {
fireBeforeCommit()
delegate.flush()
}

override fun close() {
fireBeforeCommit()
delegate.close()
}

override fun isReady(): Boolean = delegate.isReady

override fun setWriteListener(listener: WriteListener) {
delegate.setWriteListener(listener)
}
}

private inner class HookedWriter(delegate: PrintWriter) : PrintWriter(delegate) {
override fun write(c: Int) {
fireBeforeCommit()
super.write(c)
}

override fun write(buf: CharArray, off: Int, len: Int) {
fireBeforeCommit()
super.write(buf, off, len)
}

override fun write(s: String, off: Int, len: Int) {
fireBeforeCommit()
super.write(s, off, len)
}

override fun flush() {
fireBeforeCommit()
super.flush()
}

override fun close() {
fireBeforeCommit()
super.close()
}
}
}
Loading
Loading