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

## Unreleased

### Added

- `guard.enabled` (default `true`) switches the whole auto-configuration off when set to `false`: no guard bean, filter or properties binding is created. Existing apps see no change.

### Fixed

- A `multipart/form-data` upload no longer loses its parts when `guard.detection-scan-body` is on (`Required part 'file' is not present`). The container parses the parts itself and never saw the guard's body replay, so the guard now scans a body rebuilt from the container-parsed parts (file names, part headers and part payloads, the surface the engine's multipart scan covers), reading payloads only until the rebuilt body reaches `guard.detection-max-body-inspect-bytes` in total. Large file parts stay in the container's spool, so at most that many bytes of an upload are held in memory. The container parses the upload before the detection check runs, within its own multipart size limits.
- `application/x-www-form-urlencoded` bodies bound through `@RequestParam` or `request.getParameter` no longer come back empty when body scanning is on, for the same reason. The body parameters are now merged after the query parameters.

## [1.2.0] - 2026-10-17

### Fixed
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ Initialization follows the fastapi-guard contract: lazy on the first request beh
(the `guard.lifespan` analog); with `guard.lazy-init=false` and no eager init, the first request
logs the same "eager init not honored" warning as fastapi-guard.

### Enabling and disabling

The guard is on by default as soon as the jar is on the classpath. Set `guard.enabled=false` to switch the whole auto-configuration off: no guard bean, filter or `guard.*` properties binding is created. An app that wants the guard opt-in sets `guard.enabled=false` as its default and turns it on per environment with `guard.enabled=true`.

## Engine dependency (includeBuild + dependency substitution)

The engine artifact `com.guard-core:guard-core` (groupId is the Central Portal namespace; Kotlin
Expand Down
4 changes: 4 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ Maven with the BOM imported (declare these imports before `spring-boot-dependenc

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.

## Enabling and disabling

The guard is on by default as soon as the jar is on the classpath. Set `guard.enabled=false` to switch the whole auto-configuration off: no guard bean, filter or `guard.*` properties binding is created. An app that wants the guard opt-in sets `guard.enabled=false` as its default and turns it on per environment with `guard.enabled=true`.

## 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
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,18 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
* Beans: SecurityConfig (guard.* properties + customizers), SpringGuardDecorator, GuardMiddleware
* (the engine-facing middleware), GuardFilter (registered over every dispatcher type so error
* dispatches stay guarded too, matching the ASGI middleware's envelope). `guard.eager-init=true`
* additionally registers the GuardEagerInitializer boot hook.
* additionally registers the GuardEagerInitializer boot hook. `guard.enabled=false` switches the
* whole auto-configuration off (no bean, filter or properties binding); the default is on.
*/
@AutoConfiguration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnClass(HttpServletRequest::class, DispatcherServlet::class)
@ConditionalOnProperty(
prefix = "guard",
name = ["enabled"],
havingValue = "true",
matchIfMissing = true,
)
@EnableConfigurationProperties(GuardProperties::class)
public open class GuardSecurityAutoConfiguration {
@Bean
Expand Down
132 changes: 132 additions & 0 deletions src/main/kotlin/com/guardcore/spring/ServletGuardRequest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.guardcore.protocols.BoundedBodyReader
import com.guardcore.protocols.GuardRequest
import com.guardcore.protocols.RequestState
import jakarta.servlet.ReadListener
import jakarta.servlet.ServletException
import jakarta.servlet.ServletInputStream
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletRequestWrapper
Expand Down Expand Up @@ -162,16 +163,32 @@ public class MapRequestState : RequestState {
* The body-caching request wrapper. Reads the servlet input stream exactly once into a growable
* cache; bounded prefix reads stop pulling from the wire after the prefix while the downstream
* servlet stream replays the cached bytes before continuing with the live remainder.
*
* The container's own form parsing (`getParts` for multipart, `getParameter*` for urlencoded
* bodies) reads the container's input directly and never sees this replay, so the two form flavors
* are handled apart from the replay cache: multipart is scanned from the container-parsed parts,
* and urlencoded bodies are merged into the parameter accessors from the cache.
*/
public class GuardHttpServletRequest(request: HttpServletRequest) :
HttpServletRequestWrapper(request) {
public val stateBag: MapRequestState = MapRequestState()

private val cache = ByteCache()
private var streamOpened: Boolean = false
private var multipartPrefix: ByteArray? = null
private var multipartPrefixLimit: Int = 0

private val isMultipart: Boolean
get() = contentType?.lowercase()?.startsWith(MULTIPART_FORM_DATA) == true

private val isUrlEncodedForm: Boolean
get() = contentType?.lowercase()?.startsWith(URLENCODED_FORM) == true

@Synchronized
public fun cachedBody(): ByteArray {
multipartScanBytes(Int.MAX_VALUE)?.let {
return it
}
openStreamIfNecessary()
cache.readUpstream()
return cache.snapshot()
Expand All @@ -182,11 +199,117 @@ public class GuardHttpServletRequest(request: HttpServletRequest) :
if (maxBytes <= 0) {
return ByteArray(0)
}
multipartScanBytes(maxBytes)?.let {
return it.copyOfRange(0, minOf(maxBytes, it.size))
}
openStreamIfNecessary()
cache.readUpstream(maxBytes)
return cache.snapshot().copyOfRange(0, minOf(maxBytes, cache.size()))
}

/**
* A multipart/form-data body rebuilt from the container-parsed parts (each part's headers, then
* its payload), reading payloads only until the rebuilt body reaches [maxBytes] in total. This is
* what the engine's multipart scan consumes (it scans file names, part headers and part payloads,
* the way the Python reference parses the capped raw body). The container spools large file parts
* to disk, so at most [maxBytes] of an upload is held in memory. Null when the request is not
* multipart or the container cannot parse parts (no multipart config); the raw replay path then
* applies.
*/
private fun multipartScanBytes(maxBytes: Int): ByteArray? {
if (!isMultipart || streamOpened) {
return null
}
multipartPrefix?.let { built ->
if (multipartPrefixLimit >= maxBytes) {
return built
}
}
val boundary = multipartBoundary() ?: return null
val parts =
try {
super.getParts()
} catch (_: ServletException) {
return null
} catch (_: IllegalStateException) {
return null
} catch (_: java.io.IOException) {
return null
}
val out = java.io.ByteArrayOutputStream()
for (part in parts) {
if (out.size() >= maxBytes) {
break
}
out.write("--$boundary\r\n".toByteArray())
for (name in part.headerNames) {
for (value in part.getHeaders(name)) {
out.write("$name: $value\r\n".toByteArray())
}
}
out.write("\r\n".toByteArray())
part.inputStream.use { out.write(it.readNBytes(maxOf(0, maxBytes - out.size()))) }
out.write("\r\n".toByteArray())
}
out.write("--$boundary--\r\n".toByteArray())
return out.toByteArray().also {
multipartPrefix = it
multipartPrefixLimit = maxBytes
}
}

private fun multipartBoundary(): String? =
BOUNDARY_PARAM.find(contentType ?: "")?.groupValues?.get(1)

/** Body parameters of a urlencoded form, appended after the query parameters (Tomcat order). */
private fun mergedParameters(): Map<String, Array<String>>? {
if (!streamOpened || !isUrlEncodedForm) {
return null
}
// Drain the live remainder first so the container's own parameter parse sees a finished stream
// instead of parsing the unread tail as if it were the whole body.
cachedBody()
val merged = linkedMapOf<String, MutableList<String>>()
for ((name, values) in super.getParameterMap()) {
merged.getOrPut(name) { mutableListOf() }.addAll(values)
}
val charset = characterEncoding?.let { Charset.forName(it) } ?: Charsets.UTF_8
for (pair in String(cache.snapshot(), Charsets.ISO_8859_1).split("&")) {
if (pair.isEmpty()) {
continue
}
val separator = pair.indexOf('=')
val rawName = if (separator < 0) pair else pair.substring(0, separator)
val rawValue = if (separator < 0) "" else pair.substring(separator + 1)
merged
.getOrPut(decodeForm(rawName, charset)) { mutableListOf() }
.add(decodeForm(rawValue, charset))
}
return merged.mapValues { it.value.toTypedArray() }
}

private fun decodeForm(raw: String, charset: Charset): String =
URLDecoder.decode(String(raw.toByteArray(Charsets.ISO_8859_1), charset), charset)

private var formParameterCache: Map<String, Array<String>>? = null

@Synchronized
private fun formParameters(): Map<String, Array<String>>? =
formParameterCache ?: mergedParameters()?.also { formParameterCache = it }

override fun getParameterMap(): Map<String, Array<String>> =
formParameters() ?: super.getParameterMap()

override fun getParameter(name: String): String? =
formParameters()?.let { it[name]?.firstOrNull() } ?: super.getParameter(name)

override fun getParameterValues(name: String): Array<String>? =
formParameters()?.let { it[name] } ?: super.getParameterValues(name)

override fun getParameterNames(): java.util.Enumeration<String> =
formParameters()?.let { java.util.Collections.enumeration(it.keys) }
?: super.getParameterNames()

private fun openStreamIfNecessary() {
if (!streamOpened) {
streamOpened = true
Expand All @@ -195,6 +318,9 @@ public class GuardHttpServletRequest(request: HttpServletRequest) :
}

override fun getInputStream(): ServletInputStream {
if (isMultipart && multipartPrefix != null) {
return super.getInputStream()
}
openStreamIfNecessary()
return cache.replayingServletInputStream()
}
Expand All @@ -206,6 +332,12 @@ public class GuardHttpServletRequest(request: HttpServletRequest) :
request.characterEncoding?.let { Charset.forName(it) } ?: Charsets.UTF_8,
),
)

private companion object {
const val MULTIPART_FORM_DATA = "multipart/form-data"
const val URLENCODED_FORM = "application/x-www-form-urlencoded"
val BOUNDARY_PARAM = Regex("""boundary="?([^";\s]+)"?""", RegexOption.IGNORE_CASE)
}
}

/** Single-source byte cache serving both the facade reads and the downstream servlet stream. */
Expand Down
132 changes: 132 additions & 0 deletions src/test/kotlin/com/guardcore/spring/GuardMultipartBodyScanTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package com.guardcore.spring

import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.SpringBootConfiguration
import org.springframework.boot.autoconfigure.EnableAutoConfiguration
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.TestPropertySource
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile

@RestController
public class MultipartTestController {
@PostMapping("/m/upload")
public fun upload(
@RequestPart("file") file: MultipartFile,
@RequestParam("note", required = false) note: String?,
): Map<String, Any?> =
mapOf(
"name" to file.originalFilename,
"size" to file.size,
"content" to String(file.bytes, Charsets.ISO_8859_1),
"note" to note,
)

@PostMapping("/m/form")
public fun form(@RequestParam("a") a: String): Map<String, String> = mapOf("a" to a)
}

@SpringBootConfiguration
@EnableAutoConfiguration
@org.springframework.context.annotation.Import(MultipartTestController::class)
public open class MultipartGuardTestApp

@SpringBootTest(
classes = [MultipartGuardTestApp::class],
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
)
@TestPropertySource(
properties =
[
"guard.enable-redis=false",
"guard.trusted-proxies[0]=127.0.0.1",
"guard.detection-scan-body=true",
"guard.detection-max-body-inspect-bytes=2048",
"spring.servlet.multipart.max-file-size=8MB",
"spring.servlet.multipart.max-request-size=9MB",
],
)
public class GuardMultipartBodyScanTest {
@Value("\${local.server.port}") public var port: Int = 0

private val client = HttpClient.newHttpClient()
private val nextClient = java.util.concurrent.atomic.AtomicInteger(0)
private val boundary = "----guardBoundary7MA4YWxkTrZu0gW"

private fun multipart(file: ByteArray, note: String? = null): ByteArray {
val out = java.io.ByteArrayOutputStream()
fun text(s: String) = out.write(s.toByteArray(Charsets.ISO_8859_1))
if (note != null) {
text("--$boundary\r\nContent-Disposition: form-data; name=\"note\"\r\n\r\n$note\r\n")
}
text(
"--$boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.bin\"\r\n" +
"Content-Type: application/octet-stream\r\n\r\n",
)
out.write(file)
text("\r\n--$boundary--\r\n")
return out.toByteArray()
}

private fun post(path: String, contentType: String, body: ByteArray): HttpResponse<String> =
client.send(
HttpRequest.newBuilder(URI("http://127.0.0.1:$port$path"))
.header("Content-Type", contentType)
.header("User-Agent", "Mozilla/5.0")
.header("X-Forwarded-For", "198.51.100.${nextClient.incrementAndGet()}")
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build(),
HttpResponse.BodyHandlers.ofString(),
)

private fun upload(file: ByteArray, note: String? = null): HttpResponse<String> =
post("/m/upload", "multipart/form-data; boundary=$boundary", multipart(file, note))

@Test
public fun `a small multipart upload reaches the controller intact with body scanning on`() {
val response = upload("hello multipart".toByteArray(), note = "hi")
assertThat(response.statusCode()).isEqualTo(200)
assertThat(response.body())
.contains("\"name\":\"a.bin\"", "\"size\":15", "hello multipart", "\"note\":\"hi\"")
}

@Test
public fun `a multipart upload far larger than the inspect cap reaches the controller intact`() {
val big = ByteArray(5 * 1024 * 1024) { ('a'.code + it % 26).toByte() }
val response = upload(big)
assertThat(response.statusCode()).isEqualTo(200)
assertThat(response.body()).contains("\"size\":${big.size}")
assertThat(response.body()).contains(String(big, 0, 64, Charsets.ISO_8859_1))
assertThat(response.body()).contains(String(big, big.size - 64, 64, Charsets.ISO_8859_1))
}

@Test
public fun `an attack payload in a multipart text field is still blocked`() {
val response =
upload("harmless".toByteArray(), note = "1 UNION SELECT username, password FROM users")
assertThat(response.statusCode()).isEqualTo(400)
assertThat(response.body()).isEqualTo("Suspicious activity detected")
}

@Test
public fun `an attack payload inside a text file part is blocked like the reference`() {
val response = upload("<script>alert(document.cookie)</script>".toByteArray())
assertThat(response.statusCode()).isEqualTo(400)
}

@Test
public fun `a urlencoded form still binds request params with body scanning on`() {
val response = post("/m/form", "application/x-www-form-urlencoded", "a=1".toByteArray())
assertThat(response.statusCode()).isEqualTo(200)
assertThat(response.body()).contains("\"a\":\"1\"")
}
}
Loading
Loading