From a0e3d0d73be1667708bbf08dc57957dba6712c78 Mon Sep 17 00:00:00 2001 From: Dennis Bogers Date: Wed, 5 Aug 2026 17:57:33 +0200 Subject: [PATCH] fix(appsec): treat empty request/response bodies as absent Event sources commonly represent the absence of a body as an empty string (GET/OPTIONS/HEAD requests, 204 or redirect responses). The AppSec processor attempted to parse these as JSON, failing with "EOF while parsing a value at line 1 column 0" and logging "aap: unable to parse body" at INFO on every such invocation. Skip body extraction entirely when the body is empty: there is nothing to analyze, and no reason to log a parse failure. --- bottlecap/src/appsec/processor/response.rs | 27 ++++++++++ .../src/lifecycle/invocation/triggers/body.rs | 49 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/bottlecap/src/appsec/processor/response.rs b/bottlecap/src/appsec/processor/response.rs index b07fa5ea7..996bac90a 100644 --- a/bottlecap/src/appsec/processor/response.rs +++ b/bottlecap/src/appsec/processor/response.rs @@ -94,6 +94,9 @@ impl InvocationPayload for RawPayload { HashMap::default() } fn response_body<'a>(&'a self) -> Option> { + if self.data.is_empty() { + return None; + } Some(Box::new(Cursor::new(&self.data))) } } @@ -132,4 +135,28 @@ mod test { assert!(response.response_headers_no_cookies().is_empty()); assert_eq!(response.response_status_code(), Some(0)); } + + #[test] + fn test_empty_body_in_apigw_response() { + let response = r#"{ + "statusCode": 204, + "headers": {}, + "multiValueHeaders": {}, + "body": "" + }"#; + let response = ExpectedResponseFormat::ApiGatewayResponse + .parse(response.as_bytes()) + .expect("response should have parsed cleanly") + .expect("response should have been Some"); + assert!(response.response_body().is_none()); + } + + #[test] + fn test_empty_raw_response() { + let response = ExpectedResponseFormat::Raw + .parse(b"") + .expect("response should have parsed cleanly") + .expect("response should have been Some"); + assert!(response.response_body().is_none()); + } } diff --git a/bottlecap/src/lifecycle/invocation/triggers/body.rs b/bottlecap/src/lifecycle/invocation/triggers/body.rs index 6d09961fd..41d8d3d0b 100644 --- a/bottlecap/src/lifecycle/invocation/triggers/body.rs +++ b/bottlecap/src/lifecycle/invocation/triggers/body.rs @@ -16,11 +16,19 @@ pub struct Body { impl Body { /// Obtains a reader to the data contained in this [`Body`], decoded from /// Base64 if [`Body::is_base64_encoded`] is `true`. + /// + /// Returns [`None`] if there is no body, including when it is an empty + /// string, which is how many event sources represent the absence of a + /// body (e.g. `GET` requests or `204` responses). pub(crate) fn reader<'a>(&'a self) -> Result>, base64::DecodeError> { let Some(body) = &self.body else { return Ok(None); }; + if body.is_empty() { + return Ok(None); + } + if self.is_base64_encoded { let body = base64::engine::general_purpose::STANDARD.decode(body)?; let reader = Bytes::from(body).reader(); @@ -30,3 +38,44 @@ impl Body { } } } + +#[cfg(test)] +mod tests { + use std::io::read_to_string; + + use super::*; + + #[test] + fn test_reader_no_body() { + let body = Body { + body: None, + is_base64_encoded: false, + }; + assert!(body.reader().expect("should not fail").is_none()); + } + + #[test] + fn test_reader_empty_body() { + let body = Body { + body: Some(String::new()), + is_base64_encoded: false, + }; + assert!(body.reader().expect("should not fail").is_none()); + } + + #[test] + fn test_reader_base64_body() { + let body = Body { + body: Some("eyJmb28iOiJiYXIifQ==".to_string()), + is_base64_encoded: true, + }; + let reader = body + .reader() + .expect("should not fail") + .expect("should be Some"); + assert_eq!( + read_to_string(reader).expect("should read cleanly"), + r#"{"foo":"bar"}"# + ); + } +}