relay_server/endpoints/
minidump.rs

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
30/// The field name of a minidump in the multipart form-data upload.
31///
32/// Sentry requires
33const MINIDUMP_FIELD_NAME: &str = "upload_file_minidump";
34
35/// The field name of a view hierarchy file in the multipart form-data upload.
36/// It matches the expected file name of the view hierarchy, as outlined in RFC#33
37/// <https://github.com/getsentry/rfcs/blob/main/text/0033-view-hierarchy.md>
38const VIEW_HIERARCHY_FIELD_NAME: &str = "view-hierarchy.json";
39
40/// File name for a standalone minidump upload.
41///
42/// In contrast to the field name, this is used when a standalone minidump is uploaded not in a
43/// multipart request. The file name is later used to display the event attachment.
44const MINIDUMP_FILE_NAME: &str = "Minidump";
45
46/// Minidump attachments should have these magic bytes, little- and big-endian.
47const MINIDUMP_MAGIC_HEADER_LE: &[u8] = b"MDMP";
48const MINIDUMP_MAGIC_HEADER_BE: &[u8] = b"PMDM";
49
50/// Magic bytes for gzip compressed minidump containers.
51const GZIP_MAGIC_HEADER: &[u8] = b"\x1F\x8B";
52/// Magic bytes for xz compressed minidump containers.
53const XZ_MAGIC_HEADER: &[u8] = b"\xFD\x37\x7A\x58\x5A\x00";
54/// Magic bytes for bzip2 compressed minidump containers.
55const BZIP2_MAGIC_HEADER: &[u8] = b"\x42\x5A\x68";
56/// Magic bytes for zstd compressed minidump containers.
57const ZSTD_MAGIC_HEADER: &[u8] = b"\x28\xB5\x2F\xFD";
58
59/// Content types by which standalone uploads can be recognized.
60const 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
71/// Convenience wrapper to let a decoder decode its full input into a buffer.
72///
73/// Stops reading once `max_size` is exceeded and returns an error. This prevents
74/// decompression bombs from exhausting memory.
75fn 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
81/// Creates a decoder based on the magic bytes the minidump payload
82fn 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
102/// Tries to decode a minidump using any of the supported compression formats
103/// or returns the provided minidump payload untouched if no format where detected.
104///
105/// Returns an `Overflow` error if the decompressed size exceeds `max_size`.
106fn decode_minidump(minidump_data: Bytes, max_size: usize) -> Result<Bytes, BadStoreRequest> {
107    let Some(decoder) = decoder_from(minidump_data.clone()) else {
108        // this means we haven't detected any compression container
109        // proceed to process the payload untouched (as a plain minidump).
110        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            // we detected a compression container but failed to decode it
126            relay_log::trace!("invalid compression container");
127            Err(BadStoreRequest::InvalidCompressionContainer(err))
128        }
129    }
130}
131
132/// Removes any compression container file extensions from the minidump
133/// filename so it can be updated in the item. Otherwise, attachments that
134/// have been decoded would still show the extension in the UI, which is misleading.
135fn 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
142/// Extract a minidump from a nested multipart form.
143///
144/// This field is not a minidump (i.e. it doesn't start with the minidump magic header). It could be
145/// a multipart field containing a minidump; this happens in old versions of the Linux Electron SDK.
146///
147/// Unfortunately, the embedded multipart field is not recognized by the multipart parser as a
148/// multipart field containing a multipart body. For this case we will look if the field starts with
149/// a '--' and manually extract the boundary (which is what follows '--' up to the end of line) and
150/// manually construct a multipart with the detected boundary. If we can extract a multipart with an
151/// embedded minidump, then use that field.
152async 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    // Create an envelope with a random event id.
245    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    // The minidump can either be transmitted as the request body, or as
257    // `upload_file_minidump` in a multipart form-data/ request.
258    // Minidump request payloads do not have the same structure as usual events from other SDKs. The
259    // minidump can either be transmitted as request body, or as `upload_file_minidump` in a
260    // multipart formdata request.
261    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    // Never respond with a 429 since clients often retry these
272    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    // The return here is only useful for consistency because the UE4 crash reporter doesn't
281    // care about it.
282    Ok(TextResponse(id))
283}
284
285pub fn route(config: &Config) -> MethodRouter<ServiceState> {
286    // Set the single-attachment limit that applies only for raw minidumps. Multipart bypasses the
287    // limited body and applies its own limits.
288    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        // Create a minidump that will decompress to 100 bytes
376        let minidump_data = b"xxxxxxxxxx".repeat(10);
377        let compressed = encode_gzip(&minidump_data)?;
378
379        // With a limit larger than the decompressed size, decoding should succeed
380        let result = decode_minidump(compressed.clone(), 200);
381        assert!(result.is_ok());
382        assert_eq!(result.unwrap().len(), 100);
383
384        // With a limit smaller than the decompressed size, decoding should fail with Overflow
385        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        // we expect the multipart body to contain
465        // * one arbitrary attachment from the user (a `config.json`)
466        // * two breadcrumb files
467        // * one event file
468        // * one form-data item
469        // * one view-hierarchy file
470        assert_eq!(6, items.len());
471
472        // `config.json` has no content-type. MIME-detection in later processing will assign this.
473        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        // the first breadcrumb buffer
481        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        // the second breadcrumb buffer is empty since we haven't reached our max in the first
489        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        // the msg-pack encoded event file
497        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        // the next item is the view-hierarchy file
508        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        // the last item is the form-data if any and contains a `guid` from the `crashpad_handler`
519        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}