Skip to content

Commit 46022c4

Browse files
pacifisteslcian
andauthored
feat(actix): capture HTTP request body (#731)
* Add request body size control options Add MaxRequestBodySize enum to control request body capture with options: - None: Don't capture request body (default) - Small: Capture up to 1000 bytes - Medium: Capture up to 10000 bytes - Always: Capture entire body Add max_request_body_size field to ClientOptions struct with default value of None * feat(sentry-actix): Add request body capture functionality Add support for capturing request bodies in the Sentry middleware for Actix-Web. This includes: - Configurable request body size limits (Small/Medium/Always) - Support for JSON and form-urlencoded content types - Body capture and restoration logic to maintain request integrity - Additional span data enrichment Updates dependencies: - Add serde_json, actix-http, futures dependencies * remove unused futures dependency * remove useless code and dependency, address clippy lints * remove unnecessary async for should_capture_request_body * feat: Add MaxRequestBodySize check for request body limits Implements a method to validate request body sizes against predefined limits: - None: Don't capture request bodies (default) - Small: Capture up to 1000 bytes - Medium: Capture up to 10000 bytes - Always: Capture entire body * feat(sentry-actix): Improve request body capture logic - Add chunked transfer encoding check to prevent capturing chunked requests - Add strict content-type validation for JSON and form-urlencoded - Implement content length validation against size limits * clippy * feat(core): add explicit size limit option for request body capture Add new `Explicit(usize)` variant to `MaxRequestBodySize` enum, allowing users to specify custom maximum request body size limits for event capture. * remove unnecessary async for should_capture_request_body * Add copy trait on MaxRequestBodySize * Remove MaxRequestBodySize::None check It's already handle by the is_within_size_limit. * replace unwrap by empty string * use copy instead of clone for max_request_body_size * set default max_request_body_size to medium --------- Co-authored-by: lcian <[email protected]>
1 parent 1ea3664 commit 46022c4

File tree

4 files changed

+126
-19
lines changed

4 files changed

+126
-19
lines changed

sentry-actix/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ futures-util = { version = "0.3.5", default-features = false }
1818
sentry-core = { version = "0.36.0", path = "../sentry-core", default-features = false, features = [
1919
"client",
2020
] }
21+
actix-http = "3.9.0"
2122

2223
[dev-dependencies]
2324
actix-web = { version = "4" }

sentry-actix/src/lib.rs

+86-19
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,20 @@
7373

7474
use std::borrow::Cow;
7575
use std::pin::Pin;
76+
use std::rc::Rc;
7677
use std::sync::Arc;
7778

79+
use actix_http::header::{self, HeaderMap};
7880
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
81+
use actix_web::error::PayloadError;
7982
use actix_web::http::StatusCode;
80-
use actix_web::Error;
83+
use actix_web::web::BytesMut;
84+
use actix_web::{Error, HttpMessage};
8185
use futures_util::future::{ok, Future, Ready};
8286
use futures_util::FutureExt;
8387

8488
use sentry_core::protocol::{self, ClientSdkPackage, Event, Request};
89+
use sentry_core::MaxRequestBodySize;
8590
use sentry_core::{Hub, SentryFutureExt};
8691

8792
/// A helper construct that can be used to reconfigure and build the middleware.
@@ -180,7 +185,7 @@ impl Default for Sentry {
180185

181186
impl<S, B> Transform<S, ServiceRequest> for Sentry
182187
where
183-
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
188+
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
184189
S::Future: 'static,
185190
{
186191
type Response = ServiceResponse<B>;
@@ -191,21 +196,75 @@ where
191196

192197
fn new_transform(&self, service: S) -> Self::Future {
193198
ok(SentryMiddleware {
194-
service,
199+
service: Rc::new(service),
195200
inner: self.clone(),
196201
})
197202
}
198203
}
199204

200205
/// The middleware for individual services.
201206
pub struct SentryMiddleware<S> {
202-
service: S,
207+
service: Rc<S>,
203208
inner: Sentry,
204209
}
205210

211+
fn should_capture_request_body(
212+
headers: &HeaderMap,
213+
max_request_body_size: MaxRequestBodySize,
214+
) -> bool {
215+
let is_chunked = headers
216+
.get(header::TRANSFER_ENCODING)
217+
.and_then(|h| h.to_str().ok())
218+
.map(|transfer_encoding| transfer_encoding.contains("chunked"))
219+
.unwrap_or(false);
220+
221+
let is_valid_content_type = headers
222+
.get(header::CONTENT_TYPE)
223+
.and_then(|h| h.to_str().ok())
224+
.is_some_and(|content_type| {
225+
matches!(
226+
content_type,
227+
"application/json" | "application/x-www-form-urlencoded"
228+
)
229+
});
230+
231+
let is_within_size_limit = headers
232+
.get(header::CONTENT_LENGTH)
233+
.and_then(|h| h.to_str().ok())
234+
.and_then(|content_length| content_length.parse::<usize>().ok())
235+
.map(|content_length| max_request_body_size.is_within_size_limit(content_length))
236+
.unwrap_or(false);
237+
238+
!is_chunked && is_valid_content_type && is_within_size_limit
239+
}
240+
241+
/// Extract a body from the HTTP request
242+
async fn body_from_http(req: &mut ServiceRequest) -> Result<BytesMut, PayloadError> {
243+
let mut stream = req.take_payload();
244+
245+
let mut body = BytesMut::new();
246+
while let Some(chunk) = futures_util::StreamExt::next(&mut stream).await {
247+
let chunk = chunk?;
248+
body.extend_from_slice(&chunk);
249+
}
250+
let (_, mut orig_payload) = actix_http::h1::Payload::create(true);
251+
orig_payload.unread_data(body.clone().freeze());
252+
req.set_payload(actix_web::dev::Payload::from(orig_payload));
253+
254+
Ok::<_, PayloadError>(body)
255+
}
256+
257+
async fn capture_request_body(req: &mut ServiceRequest) -> String {
258+
if let Ok(request_body) = body_from_http(req).await {
259+
String::from_utf8_lossy(&request_body).into_owned()
260+
} else {
261+
String::new()
262+
}
263+
}
264+
206265
impl<S, B> Service<ServiceRequest> for SentryMiddleware<S>
207266
where
208-
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
267+
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
209268
S::Future: 'static,
210269
{
211270
type Response = ServiceResponse<B>;
@@ -230,14 +289,18 @@ where
230289
options.auto_session_tracking
231290
&& options.session_mode == sentry_core::SessionMode::Request
232291
});
292+
let max_request_body_size = client
293+
.as_ref()
294+
.map(|client| client.options().max_request_body_size)
295+
.unwrap_or(MaxRequestBodySize::None);
233296
if track_sessions {
234297
hub.start_session();
235298
}
236299
let with_pii = client
237300
.as_ref()
238301
.is_some_and(|client| client.options().send_default_pii);
239302

240-
let sentry_req = sentry_request_from_http(&req, with_pii);
303+
let mut sentry_req = sentry_request_from_http(&req, with_pii);
241304
let name = transaction_name_from_http(&req);
242305

243306
let transaction = if inner.start_transaction {
@@ -258,21 +321,25 @@ where
258321
None
259322
};
260323

261-
let parent_span = hub.configure_scope(|scope| {
262-
let parent_span = scope.get_span();
263-
if let Some(transaction) = transaction.as_ref() {
264-
scope.set_span(Some(transaction.clone().into()));
265-
} else {
266-
scope.set_transaction((!inner.start_transaction).then_some(&name));
267-
}
268-
scope.add_event_processor(move |event| Some(process_event(event, &sentry_req)));
269-
parent_span
270-
});
324+
let svc = self.service.clone();
325+
async move {
326+
let mut req = req;
271327

272-
let fut = self.service.call(req).bind_hub(hub.clone());
328+
if should_capture_request_body(req.headers(), max_request_body_size) {
329+
sentry_req.data = Some(capture_request_body(&mut req).await);
330+
}
273331

274-
async move {
275-
// Service errors
332+
let parent_span = hub.configure_scope(|scope| {
333+
let parent_span = scope.get_span();
334+
if let Some(transaction) = transaction.as_ref() {
335+
scope.set_span(Some(transaction.clone().into()));
336+
} else {
337+
scope.set_transaction((!inner.start_transaction).then_some(&name));
338+
}
339+
scope.add_event_processor(move |event| Some(process_event(event, &sentry_req)));
340+
parent_span
341+
});
342+
let fut = svc.call(req).bind_hub(hub.clone());
276343
let mut res: Self::Response = match fut.await {
277344
Ok(res) => res,
278345
Err(e) => {

sentry-core/src/clientoptions.rs

+36
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,39 @@ pub enum SessionMode {
4040
Request,
4141
}
4242

43+
/// The maximum size of an HTTP request body that the SDK captures.
44+
///
45+
/// Only request bodies that parse as JSON or form data are currently captured.
46+
/// See the [Documentation on attaching request body](https://develop.sentry.dev/sdk/expected-features/#attaching-request-body-in-server-sdks)
47+
/// and the [Documentation on handling sensitive data](https://develop.sentry.dev/sdk/expected-features/data-handling/#sensitive-data)
48+
/// for more information.
49+
#[derive(Clone, Copy, PartialEq)]
50+
pub enum MaxRequestBodySize {
51+
/// Don't capture request body
52+
None,
53+
/// Capture up to 1000 bytes
54+
Small,
55+
/// Capture up to 10000 bytes
56+
Medium,
57+
/// Capture entire body
58+
Always,
59+
/// Capture up to a specific size
60+
Explicit(usize),
61+
}
62+
63+
impl MaxRequestBodySize {
64+
/// Check if the content length is within the size limit.
65+
pub fn is_within_size_limit(&self, content_length: usize) -> bool {
66+
match self {
67+
MaxRequestBodySize::None => false,
68+
MaxRequestBodySize::Small => content_length <= 1_000,
69+
MaxRequestBodySize::Medium => content_length <= 10_000,
70+
MaxRequestBodySize::Always => true,
71+
MaxRequestBodySize::Explicit(size) => content_length <= *size,
72+
}
73+
}
74+
}
75+
4376
/// Configuration settings for the client.
4477
///
4578
/// These options are explained in more detail in the general
@@ -148,6 +181,8 @@ pub struct ClientOptions {
148181
pub trim_backtraces: bool,
149182
/// The user agent that should be reported.
150183
pub user_agent: Cow<'static, str>,
184+
/// Controls how much of request bodies are captured
185+
pub max_request_body_size: MaxRequestBodySize,
151186
}
152187

153188
impl ClientOptions {
@@ -256,6 +291,7 @@ impl Default for ClientOptions {
256291
extra_border_frames: vec![],
257292
trim_backtraces: true,
258293
user_agent: Cow::Borrowed(USER_AGENT),
294+
max_request_body_size: MaxRequestBodySize::Medium,
259295
}
260296
}
261297
}

sentry-core/src/lib.rs

+3
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ mod hub_impl;
144144
#[cfg(feature = "client")]
145145
mod session;
146146

147+
#[cfg(feature = "client")]
148+
pub use crate::clientoptions::MaxRequestBodySize;
149+
147150
#[cfg(feature = "client")]
148151
pub use crate::{client::Client, hub_impl::SwitchGuard as HubSwitchGuard};
149152

0 commit comments

Comments
 (0)