Skip to content

Commit 7f4b354

Browse files
feat(playstation): Skip large attachments (#4793)
1 parent d2e9251 commit 7f4b354

File tree

9 files changed

+343
-113
lines changed

9 files changed

+343
-113
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
- Switch `sysinfo` dependency back to upstream and update to 0.35.1. ([#4776](https://github.com/getsentry/relay/pull/4776))
2323
- Consistently always emit session outcomes. ([#4798](https://github.com/getsentry/relay/pull/4798))
2424
- Set default sdk name for playstation crashes. ([#4802](https://github.com/getsentry/relay/pull/4802))
25+
- Skip large attachments on playstation crashes. ([#4793](https://github.com/getsentry/relay/pull/4793))
2526

2627
## 25.5.1
2728

relay-server/src/endpoints/attachments.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use axum::extract::Path;
22
use axum::http::StatusCode;
33
use axum::response::IntoResponse;
44
use multer::Multipart;
5+
use relay_config::Config;
56
use relay_event_schema::protocol::EventId;
67
use serde::Deserialize;
78

@@ -20,8 +21,10 @@ async fn extract_envelope(
2021
meta: RequestMeta,
2122
path: AttachmentPath,
2223
multipart: Multipart<'static>,
24+
config: &Config,
2325
) -> Result<Box<Envelope>, BadStoreRequest> {
24-
let items = utils::multipart_items(multipart, |_, _| AttachmentType::default()).await?;
26+
let items =
27+
utils::multipart_items(multipart, |_, _| AttachmentType::default(), config, false).await?;
2528

2629
let mut envelope = Envelope::from_request(Some(path.event_id), meta);
2730
for item in items {
@@ -37,7 +40,7 @@ pub async fn handle(
3740
Path(path): Path<AttachmentPath>,
3841
Remote(multipart): Remote<Multipart<'static>>,
3942
) -> Result<impl IntoResponse, BadStoreRequest> {
40-
let envelope = extract_envelope(meta, path, multipart).await?;
43+
let envelope = extract_envelope(meta, path, multipart, state.config()).await?;
4144
common::handle_envelope(&state, envelope).await?;
4245
Ok(StatusCode::CREATED)
4346
}

relay-server/src/endpoints/minidump.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,9 @@ async fn extract_embedded_minidump(payload: Bytes) -> Result<Option<Bytes>, BadS
166166
async fn extract_multipart(
167167
multipart: Multipart<'static>,
168168
meta: RequestMeta,
169+
config: &Config,
169170
) -> Result<Box<Envelope>, BadStoreRequest> {
170-
let mut items = utils::multipart_items(multipart, infer_attachment_type).await?;
171+
let mut items = utils::multipart_items(multipart, infer_attachment_type, config, false).await?;
171172

172173
let minidump_item = items
173174
.iter_mut()
@@ -226,7 +227,7 @@ async fn handle(
226227
extract_raw_minidump(request.extract().await?, meta)?
227228
} else {
228229
let Remote(multipart) = request.extract_with_state(&state).await?;
229-
extract_multipart(multipart, meta).await?
230+
extract_multipart(multipart, meta, state.config()).await?
230231
};
231232

232233
let id = envelope.event_id();
@@ -395,7 +396,7 @@ mod tests {
395396
let config = Config::default();
396397

397398
let multipart = utils::multipart_from_request(request, &config)?;
398-
let items = multipart_items(multipart, infer_attachment_type).await?;
399+
let items = multipart_items(multipart, infer_attachment_type, &config, false).await?;
399400

400401
// we expect the multipart body to contain
401402
// * one arbitrary attachment from the user (a `config.json`)

relay-server/src/endpoints/playstation.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@ fn infer_attachment_type(_field_name: Option<&str>, file_name: &str) -> Attachme
4343
async fn extract_multipart(
4444
multipart: Multipart<'static>,
4545
meta: RequestMeta,
46+
config: &Config,
4647
) -> Result<Box<Envelope>, BadStoreRequest> {
47-
let mut items = utils::multipart_items(multipart, infer_attachment_type).await?;
48+
let mut items = utils::multipart_items(multipart, infer_attachment_type, config, true).await?;
4849

4950
let prosperodump_item = items
5051
.iter_mut()
@@ -73,7 +74,7 @@ async fn handle(
7374
) -> axum::response::Result<impl IntoResponse> {
7475
// The crash dumps are transmitted as `...` in a multipart form-data/ request.
7576
let Remote(multipart) = request.extract_with_state(&state).await?;
76-
let mut envelope = extract_multipart(multipart, meta).await?;
77+
let mut envelope = extract_multipart(multipart, meta, state.config()).await?;
7778
envelope.require_feature(Feature::PlaystationIngestion);
7879

7980
let id = envelope.event_id();
@@ -89,5 +90,5 @@ async fn handle(
8990
}
9091

9192
pub fn route(config: &Config) -> MethodRouter<ServiceState> {
92-
post(handle).route_layer(DefaultBodyLimit::max(config.max_attachment_size()))
93+
post(handle).route_layer(DefaultBodyLimit::max(config.max_attachments_size()))
9394
}

relay-server/src/utils/multipart.rs

Lines changed: 165 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
use std::io;
2+
use std::task::Poll;
23

34
use axum::extract::Request;
4-
use multer::Multipart;
5+
use bytes::{Bytes, BytesMut};
6+
use futures::{StreamExt, TryStreamExt};
7+
use multer::{Field, Multipart};
58
use relay_config::Config;
69
use serde::{Deserialize, Serialize};
710

@@ -152,6 +155,8 @@ pub fn get_multipart_boundary(data: &[u8]) -> Option<&str> {
152155
pub async fn multipart_items<F>(
153156
mut multipart: Multipart<'_>,
154157
mut infer_type: F,
158+
config: &Config,
159+
ignore_large_fields: bool,
155160
) -> Result<Items, multer::Error>
156161
where
157162
F: FnMut(Option<&str>, &str) -> AttachmentType,
@@ -164,12 +169,21 @@ where
164169
let mut item = Item::new(ItemType::Attachment);
165170
item.set_attachment_type(infer_type(field.name(), file_name));
166171
item.set_filename(file_name);
167-
// Extract the body after the immutable borrow on `file_name` is gone.
168-
if let Some(content_type) = field.content_type() {
169-
item.set_payload(content_type.as_ref().into(), field.bytes().await?);
170-
} else {
171-
item.set_payload_without_content_type(field.bytes().await?);
172+
173+
let content_type = field.content_type().cloned();
174+
let field = LimitedField::new(field, config.max_attachment_size());
175+
match field.bytes().await {
176+
Err(multer::Error::FieldSizeExceeded { .. }) if ignore_large_fields => continue,
177+
Err(err) => return Err(err),
178+
Ok(bytes) => {
179+
if let Some(content_type) = content_type {
180+
item.set_payload(content_type.as_ref().into(), bytes);
181+
} else {
182+
item.set_payload_without_content_type(bytes);
183+
}
184+
}
172185
}
186+
173187
items.push(item);
174188
} else if let Some(field_name) = field.name().map(str::to_owned) {
175189
// Ensure to decode this SAFELY to match Django's POST data behavior. This allows us to
@@ -193,6 +207,76 @@ where
193207
Ok(items)
194208
}
195209

210+
/// Wrapper around `multer::Field` which consumes the entire underlying stream even when the
211+
/// size limit is exceeded.
212+
///
213+
/// The idea being that you can process fields in a multi-part form even if one fields is too large.
214+
struct LimitedField<'a> {
215+
field: Field<'a>,
216+
consumed_size: usize,
217+
size_limit: usize,
218+
inner_finished: bool,
219+
}
220+
221+
impl<'a> LimitedField<'a> {
222+
fn new(field: Field<'a>, limit: usize) -> Self {
223+
LimitedField {
224+
field,
225+
consumed_size: 0,
226+
size_limit: limit,
227+
inner_finished: false,
228+
}
229+
}
230+
231+
async fn bytes(self) -> Result<Bytes, multer::Error> {
232+
self.try_fold(BytesMut::new(), |mut acc, x| async move {
233+
acc.extend_from_slice(&x);
234+
Ok(acc)
235+
})
236+
.await
237+
.map(|x| x.freeze())
238+
}
239+
}
240+
241+
impl futures::Stream for LimitedField<'_> {
242+
type Item = Result<Bytes, multer::Error>;
243+
244+
fn poll_next(
245+
mut self: std::pin::Pin<&mut Self>,
246+
cx: &mut std::task::Context<'_>,
247+
) -> std::task::Poll<Option<Self::Item>> {
248+
if self.inner_finished {
249+
return Poll::Ready(None);
250+
}
251+
252+
match self.field.poll_next_unpin(cx) {
253+
err @ Poll::Ready(Some(Err(_))) => err,
254+
Poll::Ready(Some(Ok(t))) => {
255+
self.consumed_size += t.len();
256+
match self.consumed_size <= self.size_limit {
257+
true => Poll::Ready(Some(Ok(t))),
258+
false => {
259+
cx.waker().wake_by_ref();
260+
Poll::Pending
261+
}
262+
}
263+
}
264+
Poll::Ready(None) if self.consumed_size > self.size_limit => {
265+
self.inner_finished = true;
266+
Poll::Ready(Some(Err(multer::Error::FieldSizeExceeded {
267+
limit: self.size_limit as u64,
268+
field_name: self.field.name().map(Into::into),
269+
})))
270+
}
271+
Poll::Ready(None) => {
272+
self.inner_finished = true;
273+
Poll::Ready(None)
274+
}
275+
Poll::Pending => Poll::Pending,
276+
}
277+
}
278+
}
279+
196280
pub fn multipart_from_request(
197281
request: Request,
198282
config: &Config,
@@ -204,9 +288,8 @@ pub fn multipart_from_request(
204288
.unwrap_or("");
205289
let boundary = multer::parse_boundary(content_type)?;
206290

207-
let limits = multer::SizeLimit::new()
208-
.whole_stream(config.max_attachments_size() as u64)
209-
.per_field(config.max_attachment_size() as u64);
291+
// Only enforce the stream limit here as the `per_field` limit is enforced by `LimitedField`.
292+
let limits = multer::SizeLimit::new().whole_stream(config.max_attachments_size() as u64);
210293

211294
Ok(Multipart::with_constraints(
212295
request.into_body().into_data_stream(),
@@ -287,4 +370,77 @@ mod tests {
287370

288371
Ok(())
289372
}
373+
374+
#[tokio::test]
375+
async fn test_individual_size_limit_exceeded() -> anyhow::Result<()> {
376+
let data = "--X-BOUNDARY\r\n\
377+
Content-Disposition: form-data; name=\"file\"; filename=\"large.txt\"\r\n\
378+
Content-Type: text/plain\r\n\
379+
\r\n\
380+
content too large for limit\r\n\
381+
--X-BOUNDARY\r\n\
382+
Content-Disposition: form-data; name=\"small_file\"; filename=\"small.txt\"\r\n\
383+
Content-Type: text/plain\r\n\
384+
\r\n\
385+
ok\r\n\
386+
--X-BOUNDARY--\r\n";
387+
388+
let stream = futures::stream::once(async { Ok::<_, Infallible>(data) });
389+
let multipart = Multipart::new(stream, "X-BOUNDARY");
390+
391+
let config = Config::from_json_value(serde_json::json!({
392+
"limits": {
393+
"max_attachment_size": 5
394+
}
395+
}))?;
396+
397+
let items =
398+
multipart_items(multipart, |_, _| AttachmentType::Attachment, &config, true).await?;
399+
400+
// The large field is skipped so only the small one should make it through.
401+
assert_eq!(items.len(), 1);
402+
let item = &items[0];
403+
assert_eq!(item.filename(), Some("small.txt"));
404+
assert_eq!(item.payload(), Bytes::from("ok"));
405+
406+
Ok(())
407+
}
408+
409+
#[tokio::test]
410+
async fn test_collective_size_limit_exceeded() -> anyhow::Result<()> {
411+
let data = "--X-BOUNDARY\r\n\
412+
Content-Disposition: form-data; name=\"file\"; filename=\"large.txt\"\r\n\
413+
Content-Type: text/plain\r\n\
414+
\r\n\
415+
content too large for limit\r\n\
416+
--X-BOUNDARY\r\n\
417+
Content-Disposition: form-data; name=\"small_file\"; filename=\"small.txt\"\r\n\
418+
Content-Type: text/plain\r\n\
419+
\r\n\
420+
ok\r\n\
421+
--X-BOUNDARY--\r\n";
422+
423+
let stream = futures::stream::once(async { Ok::<_, Infallible>(data) });
424+
425+
let config = Config::from_json_value(serde_json::json!({
426+
"limits": {
427+
"max_attachments_size": 5
428+
}
429+
}))?;
430+
let limits = multer::SizeLimit::new().whole_stream(config.max_attachments_size() as u64);
431+
432+
let multipart = Multipart::with_constraints(
433+
stream,
434+
"X-BOUNDARY",
435+
multer::Constraints::new().size_limit(limits),
436+
);
437+
438+
let result =
439+
multipart_items(multipart, |_, _| AttachmentType::Attachment, &config, true).await;
440+
441+
// Should be warned if the overall stream limit is being breached.
442+
assert!(result.is_err_and(|x| matches!(x, multer::Error::StreamSizeExceeded { limit: _ })));
443+
444+
Ok(())
445+
}
290446
}

tests/integration/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
processing_config,
2525
relay_with_processing,
2626
relay_with_playstation,
27+
relay_processing_with_playstation,
2728
consumer_fixture,
2829
events_consumer,
2930
outcomes_consumer,

tests/integration/fixtures/__init__.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -423,23 +423,37 @@ def send_unreal_request(self, project_id, file_content, dsn_key_idx=0):
423423
response.raise_for_status()
424424
return response
425425

426-
def send_playstation_request(self, project_id, file_content, dsn_key_idx=0):
426+
def send_playstation_request(
427+
self,
428+
project_id,
429+
crash_file_content,
430+
crash_video_content=None,
431+
dsn_key_idx=0,
432+
):
427433
"""
428434
Sends a request to the playstation endpoint
429435
:param project_id: the project id
430436
:param file_content: the unreal file content
431437
"""
438+
files = {}
439+
if crash_video_content:
440+
files["upload_file_crash_video"] = (
441+
"crash-video.webm",
442+
crash_video_content,
443+
"video/webm",
444+
)
445+
446+
files["upload_file_minidump"] = (
447+
"playstation.prosperodmp",
448+
crash_file_content,
449+
"application/octet-stream",
450+
)
451+
432452
response = self.post(
433453
"/api/{}/playstation/?sentry_key={}".format(
434454
project_id, self.get_dsn_public_key(project_id, dsn_key_idx)
435455
),
436-
files={
437-
"upload_file_minidump": (
438-
"playstation.prosperodmp",
439-
file_content,
440-
"application/octet-stream",
441-
)
442-
},
456+
files=files,
443457
)
444458

445459
response.raise_for_status()

tests/integration/fixtures/processing.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def inner(options=None, **kwargs):
9696

9797

9898
@pytest.fixture
99-
def relay_with_playstation(relay_with_processing):
99+
def relay_processing_with_playstation(relay_with_processing):
100100
"""
101101
Skips the test if Relay is not compiled with Playstation support else behaves like
102102
`relay_with_processing`
@@ -111,6 +111,21 @@ def relay_with_playstation(relay_with_processing):
111111
return relay_with_processing
112112

113113

114+
@pytest.fixture
115+
def relay_with_playstation(mini_sentry, relay):
116+
"""
117+
Skips the test if Relay is not compiled with Playstation support else behaves like `relay`
118+
"""
119+
internal_relay = relay(mini_sentry)
120+
try:
121+
response = internal_relay.post("/api/42/playstation/")
122+
if response.status_code == 404:
123+
pytest.skip("Test requires Relay compiled with PlayStation support")
124+
except Exception:
125+
pytest.skip("Test requires Relay compiled with PlayStation support")
126+
return relay
127+
128+
114129
def kafka_producer(options):
115130
# look for the servers (it is the only config we are interested in)
116131
servers = [

0 commit comments

Comments
 (0)