1use axum::RequestExt;
2use axum::extract::{DefaultBodyLimit, Request};
3use axum::response::IntoResponse;
4use axum::routing::{MethodRouter, post};
5use bytes::Bytes;
6use bzip2::read::BzDecoder;
7use flate2::read::GzDecoder;
8use liblzma::read::XzDecoder;
9use multer::{Field, Multipart};
10use relay_config::Config;
11use relay_event_schema::protocol::EventId;
12use std::convert::Infallible;
13use std::error::Error;
14use std::io::Cursor;
15use std::io::Read;
16use zstd::stream::Decoder as ZstdDecoder;
17
18use crate::constants::{ITEM_NAME_BREADCRUMBS1, ITEM_NAME_BREADCRUMBS2, ITEM_NAME_EVENT};
19use crate::endpoints::common::{self, BadStoreRequest, TextResponse};
20use crate::envelope::ContentType::Minidump;
21use crate::envelope::{AttachmentType, Envelope, Item, ItemType};
22use crate::extractors::{RawContentType, RequestMeta};
23use crate::middlewares;
24use crate::service::ServiceState;
25use crate::services::outcome::{DiscardAttachmentType, DiscardItemType};
26use crate::utils::{
27 self, AttachmentStrategy, ConstrainedMultipart, read_attachment_bytes_into_item,
28};
29
30const MINIDUMP_FIELD_NAME: &str = "upload_file_minidump";
34
35const VIEW_HIERARCHY_FIELD_NAME: &str = "view-hierarchy.json";
39
40const MINIDUMP_FILE_NAME: &str = "Minidump";
45
46const MINIDUMP_MAGIC_HEADER_LE: &[u8] = b"MDMP";
48const MINIDUMP_MAGIC_HEADER_BE: &[u8] = b"PMDM";
49
50const GZIP_MAGIC_HEADER: &[u8] = b"\x1F\x8B";
52const XZ_MAGIC_HEADER: &[u8] = b"\xFD\x37\x7A\x58\x5A\x00";
54const BZIP2_MAGIC_HEADER: &[u8] = b"\x42\x5A\x68";
56const ZSTD_MAGIC_HEADER: &[u8] = b"\x28\xB5\x2F\xFD";
58
59const MINIDUMP_RAW_CONTENT_TYPES: &[&str] = &["application/octet-stream", "application/x-dmp"];
61
62fn validate_minidump(data: &[u8]) -> Result<(), BadStoreRequest> {
63 if !data.starts_with(MINIDUMP_MAGIC_HEADER_LE) && !data.starts_with(MINIDUMP_MAGIC_HEADER_BE) {
64 relay_log::trace!("invalid minidump file");
65 return Err(BadStoreRequest::InvalidMinidump);
66 }
67
68 Ok(())
69}
70
71fn run_decoder(mut decoder: impl Read) -> std::io::Result<Vec<u8>> {
76 let mut buffer = Vec::new();
77 decoder.read_to_end(&mut buffer)?;
78 Ok(buffer)
79}
80
81fn decoder_from(minidump_data: Bytes) -> Option<Box<dyn Read>> {
83 if minidump_data.starts_with(GZIP_MAGIC_HEADER) {
84 return Some(Box::new(GzDecoder::new(Cursor::new(minidump_data))));
85 } else if minidump_data.starts_with(XZ_MAGIC_HEADER) {
86 return Some(Box::new(XzDecoder::new(Cursor::new(minidump_data))));
87 } else if minidump_data.starts_with(BZIP2_MAGIC_HEADER) {
88 return Some(Box::new(BzDecoder::new(Cursor::new(minidump_data))));
89 } else if minidump_data.starts_with(ZSTD_MAGIC_HEADER) {
90 return match ZstdDecoder::new(Cursor::new(minidump_data)) {
91 Ok(decoder) => Some(Box::new(decoder)),
92 Err(ref err) => {
93 relay_log::error!(error = err as &dyn Error, "failed to create ZstdDecoder");
94 None
95 }
96 };
97 }
98
99 None
100}
101
102fn decode_minidump(minidump_data: Bytes, max_size: usize) -> Result<Bytes, BadStoreRequest> {
107 let Some(decoder) = decoder_from(minidump_data.clone()) else {
108 return Ok(minidump_data);
111 };
112
113 let decoder = decoder.take(max_size.saturating_add(1) as u64);
114
115 match run_decoder(decoder) {
116 Ok(decoded) => {
117 if decoded.len() > max_size {
118 return Err(BadStoreRequest::Overflow(DiscardItemType::Attachment(
119 DiscardAttachmentType::Minidump,
120 )));
121 }
122 Ok(Bytes::from(decoded))
123 }
124 Err(err) => {
125 relay_log::trace!("invalid compression container");
127 Err(BadStoreRequest::InvalidCompressionContainer(err))
128 }
129 }
130}
131
132fn remove_container_extension(filename: &str) -> &str {
136 [".gz", ".xz", ".bz2", ".zst"]
137 .into_iter()
138 .find_map(|suffix| filename.strip_suffix(suffix))
139 .unwrap_or(filename)
140}
141
142async fn extract_embedded_minidump(payload: Bytes) -> Result<Option<Bytes>, BadStoreRequest> {
153 let boundary = match utils::get_multipart_boundary(&payload) {
154 Some(boundary) => boundary,
155 None => return Ok(None),
156 };
157
158 let stream = futures::stream::once(async { Ok::<_, Infallible>(payload.clone()) });
159 let mut multipart = Multipart::new(stream, boundary);
160
161 while let Some(field) = multipart.next_field().await? {
162 if field.name() == Some(MINIDUMP_FIELD_NAME) {
163 return Ok(Some(field.bytes().await?));
164 }
165 }
166
167 Ok(None)
168}
169
170struct MinidumpAttachmentStrategy;
171
172impl AttachmentStrategy for MinidumpAttachmentStrategy {
173 fn add_to_item(
174 &self,
175 field: Field<'static>,
176 item: Item,
177 config: &Config,
178 ) -> impl Future<Output = Result<Option<Item>, multer::Error>> + Send {
179 read_attachment_bytes_into_item(field, item, config, false)
180 }
181
182 fn infer_type(&self, field: &Field) -> AttachmentType {
183 match field.name().unwrap_or("") {
184 MINIDUMP_FIELD_NAME => AttachmentType::Minidump,
185 ITEM_NAME_BREADCRUMBS1 => AttachmentType::Breadcrumbs,
186 ITEM_NAME_BREADCRUMBS2 => AttachmentType::Breadcrumbs,
187 ITEM_NAME_EVENT => AttachmentType::EventPayload,
188 VIEW_HIERARCHY_FIELD_NAME => AttachmentType::ViewHierarchy,
189 _ => AttachmentType::Attachment,
190 }
191 }
192}
193
194async fn extract_multipart(
195 multipart: ConstrainedMultipart,
196 meta: RequestMeta,
197 config: &Config,
198) -> Result<Box<Envelope>, BadStoreRequest> {
199 let mut items = multipart.items(config, MinidumpAttachmentStrategy).await?;
200
201 let minidump_item = items
202 .iter_mut()
203 .find(|item| item.attachment_type() == Some(AttachmentType::Minidump))
204 .ok_or(BadStoreRequest::MissingMinidump)?;
205
206 let embedded_opt = extract_embedded_minidump(minidump_item.payload()).await?;
207 if let Some(embedded) = embedded_opt {
208 minidump_item.set_payload(Minidump, embedded);
209 }
210
211 minidump_item.set_payload(
212 Minidump,
213 decode_minidump(minidump_item.payload(), config.max_attachment_size())?,
214 );
215
216 validate_minidump(&minidump_item.payload())?;
217
218 if let Some(minidump_filename) = minidump_item.filename() {
219 minidump_item.set_filename(remove_container_extension(minidump_filename).to_owned());
220 }
221
222 let event_id = common::event_id_from_items(&items)?.unwrap_or_else(EventId::new);
223 let mut envelope = Envelope::from_request(Some(event_id), meta);
224
225 for item in items {
226 envelope.add_item(item);
227 }
228
229 Ok(envelope)
230}
231
232fn extract_raw_minidump(
233 data: Bytes,
234 meta: RequestMeta,
235 max_size: usize,
236) -> Result<Box<Envelope>, BadStoreRequest> {
237 let mut item = Item::new(ItemType::Attachment);
238
239 item.set_payload(Minidump, decode_minidump(data, max_size)?);
240 validate_minidump(&item.payload())?;
241 item.set_filename(MINIDUMP_FILE_NAME);
242 item.set_attachment_type(AttachmentType::Minidump);
243
244 let mut envelope = Envelope::from_request(Some(EventId::new()), meta);
246 envelope.add_item(item);
247 Ok(envelope)
248}
249
250async fn handle(
251 state: ServiceState,
252 meta: RequestMeta,
253 content_type: RawContentType,
254 request: Request,
255) -> axum::response::Result<impl IntoResponse> {
256 let config = state.config();
262 let envelope = if MINIDUMP_RAW_CONTENT_TYPES.contains(&content_type.as_ref()) {
263 extract_raw_minidump(request.extract().await?, meta, config.max_attachment_size())?
264 } else {
265 let multipart = request.extract_with_state(&state).await?;
266 extract_multipart(multipart, meta, config).await?
267 };
268
269 let id = envelope.event_id();
270
271 match common::handle_envelope(&state, envelope)
273 .await
274 .map_err(|err| err.into_inner())
275 {
276 Ok(_) | Err(BadStoreRequest::RateLimited(_)) => (),
277 Err(error) => return Err(error.into()),
278 };
279
280 Ok(TextResponse(id))
283}
284
285pub fn route(config: &Config) -> MethodRouter<ServiceState> {
286 post(handle)
289 .route_layer(DefaultBodyLimit::max(config.max_attachment_size()))
290 .route_layer(axum::middleware::from_fn(middlewares::content_length))
291}
292
293#[cfg(test)]
294mod tests {
295 use crate::envelope::ContentType;
296 use crate::utils::FormDataIter;
297 use axum::body::Body;
298 use bzip2::Compression as BzCompression;
299 use bzip2::write::BzEncoder;
300 use flate2::Compression as GzCompression;
301 use flate2::write::GzEncoder;
302 use liblzma::write::XzEncoder;
303 use relay_config::Config;
304 use std::io::Write;
305 use zstd::stream::Encoder as ZstdEncoder;
306
307 use super::*;
308
309 #[test]
310 fn test_validate_minidump() {
311 let be_minidump = b"PMDMxxxxxx";
312 assert!(validate_minidump(be_minidump).is_ok());
313
314 let le_minidump = b"MDMPxxxxxx";
315 assert!(validate_minidump(le_minidump).is_ok());
316
317 let garbage = b"xxxxxx";
318 assert!(validate_minidump(garbage).is_err());
319 }
320
321 type EncodeFunction = fn(&[u8]) -> Result<Bytes, Box<dyn std::error::Error>>;
322
323 fn encode_gzip(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
324 let mut encoder = GzEncoder::new(Vec::new(), GzCompression::default());
325 encoder.write_all(be_minidump)?;
326 let compressed = encoder.finish()?;
327 Ok(Bytes::from(compressed))
328 }
329 fn encode_bzip(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
330 let mut encoder = BzEncoder::new(Vec::new(), BzCompression::default());
331 encoder.write_all(be_minidump)?;
332 let compressed = encoder.finish()?;
333 Ok(Bytes::from(compressed))
334 }
335 fn encode_xz(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
336 let mut encoder = XzEncoder::new(Vec::new(), 6);
337 encoder.write_all(be_minidump)?;
338 let compressed = encoder.finish()?;
339 Ok(Bytes::from(compressed))
340 }
341 fn encode_zst(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
342 let mut encoder = ZstdEncoder::new(Vec::new(), 0)?;
343 encoder.write_all(be_minidump)?;
344 let compressed = encoder.finish()?;
345 Ok(Bytes::from(compressed))
346 }
347
348 #[test]
349 fn test_validate_encoded_minidump() -> Result<(), Box<dyn std::error::Error>> {
350 let encoders: Vec<EncodeFunction> = vec![encode_gzip, encode_zst, encode_bzip, encode_xz];
351 for encoder in &encoders {
352 let be_minidump = b"PMDMxxxxxx";
353 let compressed = encoder(be_minidump)?;
354 let decoder = decoder_from(compressed).unwrap();
355 assert!(run_decoder(decoder).is_ok());
356
357 let le_minidump = b"MDMPxxxxxx";
358 let compressed = encoder(le_minidump)?;
359 let decoder = decoder_from(compressed).unwrap();
360 assert!(run_decoder(decoder).is_ok());
361
362 let garbage = b"xxxxxx";
363 let compressed = encoder(garbage)?;
364 let decoder = decoder_from(compressed).unwrap();
365 let decoded = run_decoder(decoder);
366 assert!(decoded.is_ok());
367 assert!(validate_minidump(&decoded.unwrap()).is_err());
368 }
369
370 Ok(())
371 }
372
373 #[test]
374 fn test_decode_minidump_size_limit() -> Result<(), Box<dyn std::error::Error>> {
375 let minidump_data = b"xxxxxxxxxx".repeat(10);
377 let compressed = encode_gzip(&minidump_data)?;
378
379 let result = decode_minidump(compressed.clone(), 200);
381 assert!(result.is_ok());
382 assert_eq!(result.unwrap().len(), 100);
383
384 let result = decode_minidump(compressed, 50);
386 assert!(matches!(result, Err(BadStoreRequest::Overflow(_))));
387
388 Ok(())
389 }
390
391 #[test]
392 fn test_remove_container_extension() -> Result<(), Box<dyn std::error::Error>> {
393 assert_eq!(remove_container_extension("minidump"), "minidump");
394 assert_eq!(remove_container_extension("minidump.gz"), "minidump");
395 assert_eq!(remove_container_extension("minidump.bz2"), "minidump");
396 assert_eq!(remove_container_extension("minidump.xz"), "minidump");
397 assert_eq!(remove_container_extension("minidump.zst"), "minidump");
398 assert_eq!(remove_container_extension("minidump.dmp"), "minidump.dmp");
399 assert_eq!(
400 remove_container_extension("minidump.dmp.gz"),
401 "minidump.dmp"
402 );
403 assert_eq!(
404 remove_container_extension("minidump.dmp.bz2"),
405 "minidump.dmp"
406 );
407 assert_eq!(
408 remove_container_extension("minidump.dmp.xz"),
409 "minidump.dmp"
410 );
411 assert_eq!(
412 remove_container_extension("minidump.dmp.zst"),
413 "minidump.dmp"
414 );
415
416 Ok(())
417 }
418
419 #[tokio::test]
420 async fn test_minidump_multipart_attachments() {
421 let multipart_body: &[u8] =
422 b"-----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
423 Content-Disposition: form-data; name=\"guid\"\x0d\x0a\x0d\x0add46bb04-bb27-448c-aad0-0deb0c134bdb\x0d\x0a\
424 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
425 Content-Disposition: form-data; name=\"config.json\"; filename=\"config.json\"\x0d\x0a\x0d\x0a\
426 \"Sentry\": { \"Dsn\": \"https://ingest.us.sentry.io/xxxxxxx\", \"MaxBreadcrumbs\": 50, \"Debug\": true }\x0d\x0a\
427 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
428 Content-Disposition: form-data; name=\"__sentry-breadcrumb1\"; filename=\"__sentry-breadcrumb1\"\x0d\x0a\
429 Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
430 \x82\
431 \xa9timestamp\xb82024-03-12T16:59:33.069Z\
432 \xa7message\xb5default level is info\x0d\x0a\
433 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
434 Content-Disposition: form-data; name=\"__sentry-breadcrumb2\"; filename=\"__sentry-breadcrumb2\"\x0d\x0a\
435 Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
436 \x0d\x0a\
437 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
438 Content-Disposition: form-data; name=\"__sentry-event\"; filename=\"__sentry-event\"\x0d\x0a\
439 Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
440 \x82\xa5level\xa5fatal\xa8platform\xa6native\x0d\x0a\
441 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
442 Content-Disposition: form-data; name=\"view-hierarchy.json\"; filename=\"view-hierarchy.json\"\x0d\x0a\
443 Content-Type: application/json\x0d\x0a\x0d\x0a\
444 {\"rendering_system\":\"android_view_system\",\"windows\":[{\"type\":\"com.android.internal.policy.DecorView\",\"width\":768.0,\"height\":1280.0,\"x\":0.0,\"y\":0.0,\"visibility\":\"visible\",\"alpha\":1.0}]}\x0d\x0a\
445 -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw-----\x0d\x0a";
446
447 let request = Request::builder()
448 .header(
449 "content-type",
450 "multipart/form-data; boundary=---MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---",
451 )
452 .body(Body::from(multipart_body)).unwrap();
453
454 let config = Config::default();
455
456 let multipart = ConstrainedMultipart(
457 utils::multipart_from_request(request, multer::Constraints::new()).unwrap(),
458 );
459 let items = multipart
460 .items(&config, MinidumpAttachmentStrategy)
461 .await
462 .unwrap();
463
464 assert_eq!(6, items.len());
471
472 let item = &items[0];
474 assert_eq!(item.filename().unwrap(), "config.json");
475 assert!(item.content_type().is_none());
476 assert_eq!(item.ty(), &ItemType::Attachment);
477 assert_eq!(item.attachment_type().unwrap(), AttachmentType::Attachment);
478 assert_eq!(item.payload().len(), 95);
479
480 let item = &items[1];
482 assert_eq!(item.filename().unwrap(), "__sentry-breadcrumb1");
483 assert_eq!(item.content_type().unwrap(), ContentType::OctetStream);
484 assert_eq!(item.ty(), &ItemType::Attachment);
485 assert_eq!(item.attachment_type().unwrap(), AttachmentType::Breadcrumbs);
486 assert_eq!(item.payload().len(), 66);
487
488 let item = &items[2];
490 assert_eq!(item.filename().unwrap(), "__sentry-breadcrumb2");
491 assert_eq!(item.content_type().unwrap(), ContentType::OctetStream);
492 assert_eq!(item.ty(), &ItemType::Attachment);
493 assert_eq!(item.attachment_type().unwrap(), AttachmentType::Breadcrumbs);
494 assert_eq!(item.payload().len(), 0);
495
496 let item = &items[3];
498 assert_eq!(item.filename().unwrap(), "__sentry-event");
499 assert_eq!(item.content_type().unwrap(), ContentType::OctetStream);
500 assert_eq!(item.ty(), &ItemType::Attachment);
501 assert_eq!(
502 item.attachment_type().unwrap(),
503 AttachmentType::EventPayload
504 );
505 assert_eq!(item.payload().len(), 29);
506
507 let item = &items[4];
509 assert_eq!(item.filename().unwrap(), "view-hierarchy.json");
510 assert_eq!(item.content_type().unwrap(), ContentType::Json);
511 assert_eq!(item.ty(), &ItemType::Attachment);
512 assert_eq!(
513 item.attachment_type().unwrap(),
514 AttachmentType::ViewHierarchy
515 );
516 assert_eq!(item.payload().len(), 184);
517
518 let item = &items[5];
520 assert!(item.filename().is_none());
521 assert_eq!(item.content_type().unwrap(), ContentType::Text);
522 assert_eq!(item.ty(), &ItemType::FormData);
523 assert!(item.attachment_type().is_none());
524 let form_payload = item.payload();
525 let form_data_entry = FormDataIter::new(form_payload.as_ref()).next().unwrap();
526 assert_eq!(form_data_entry.key(), "guid");
527 assert_eq!(
528 form_data_entry.value(),
529 "dd46bb04-bb27-448c-aad0-0deb0c134bdb"
530 );
531 }
532}