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::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::service::ServiceState;
24use crate::utils::{self, ConstrainedMultipart};
25
26/// The field name of a minidump in the multipart form-data upload.
27///
28/// Sentry requires
29const MINIDUMP_FIELD_NAME: &str = "upload_file_minidump";
30
31/// The field name of a view hierarchy file in the multipart form-data upload.
32/// It matches the expected file name of the view hierarchy, as outlined in RFC#33
33/// <https://github.com/getsentry/rfcs/blob/main/text/0033-view-hierarchy.md>
34const VIEW_HIERARCHY_FIELD_NAME: &str = "view-hierarchy.json";
35
36/// File name for a standalone minidump upload.
37///
38/// In contrast to the field name, this is used when a standalone minidump is uploaded not in a
39/// multipart request. The file name is later used to display the event attachment.
40const MINIDUMP_FILE_NAME: &str = "Minidump";
41
42/// Minidump attachments should have these magic bytes, little- and big-endian.
43const MINIDUMP_MAGIC_HEADER_LE: &[u8] = b"MDMP";
44const MINIDUMP_MAGIC_HEADER_BE: &[u8] = b"PMDM";
45
46/// Magic bytes for gzip compressed minidump containers.
47const GZIP_MAGIC_HEADER: &[u8] = b"\x1F\x8B";
48/// Magic bytes for xz compressed minidump containers.
49const XZ_MAGIC_HEADER: &[u8] = b"\xFD\x37\x7A\x58\x5A\x00";
50/// Magic bytes for bzip2 compressed minidump containers.
51const BZIP2_MAGIC_HEADER: &[u8] = b"\x42\x5A\x68";
52/// Magic bytes for zstd compressed minidump containers.
53const ZSTD_MAGIC_HEADER: &[u8] = b"\x28\xB5\x2F\xFD";
54
55/// Content types by which standalone uploads can be recognized.
56const MINIDUMP_RAW_CONTENT_TYPES: &[&str] = &["application/octet-stream", "application/x-dmp"];
57
58fn validate_minidump(data: &[u8]) -> Result<(), BadStoreRequest> {
59    if !data.starts_with(MINIDUMP_MAGIC_HEADER_LE) && !data.starts_with(MINIDUMP_MAGIC_HEADER_BE) {
60        relay_log::trace!("invalid minidump file");
61        return Err(BadStoreRequest::InvalidMinidump);
62    }
63
64    Ok(())
65}
66
67/// Convenience wrapper to let a decoder decode its full input into a buffer
68fn run_decoder(decoder: &mut Box<dyn Read>) -> std::io::Result<Vec<u8>> {
69    let mut buffer = Vec::new();
70    decoder.read_to_end(&mut buffer)?;
71    Ok(buffer)
72}
73
74/// Creates a decoder based on the magic bytes the minidump payload
75fn decoder_from(minidump_data: Bytes) -> Option<Box<dyn Read>> {
76    if minidump_data.starts_with(GZIP_MAGIC_HEADER) {
77        return Some(Box::new(GzDecoder::new(Cursor::new(minidump_data))));
78    } else if minidump_data.starts_with(XZ_MAGIC_HEADER) {
79        return Some(Box::new(XzDecoder::new(Cursor::new(minidump_data))));
80    } else if minidump_data.starts_with(BZIP2_MAGIC_HEADER) {
81        return Some(Box::new(BzDecoder::new(Cursor::new(minidump_data))));
82    } else if minidump_data.starts_with(ZSTD_MAGIC_HEADER) {
83        return match ZstdDecoder::new(Cursor::new(minidump_data)) {
84            Ok(decoder) => Some(Box::new(decoder)),
85            Err(ref err) => {
86                relay_log::error!(error = err as &dyn Error, "failed to create ZstdDecoder");
87                None
88            }
89        };
90    }
91
92    None
93}
94
95/// Tries to decode a minidump using any of the supported compression formats
96/// or returns the provided minidump payload untouched if no format where detected
97fn decode_minidump(minidump_data: Bytes) -> Result<Bytes, BadStoreRequest> {
98    match decoder_from(minidump_data.clone()) {
99        Some(mut decoder) => {
100            match run_decoder(&mut decoder) {
101                Ok(decoded) => Ok(Bytes::from(decoded)),
102                Err(err) => {
103                    // we detected a compression container but failed to decode it
104                    relay_log::trace!("invalid compression container");
105                    Err(BadStoreRequest::InvalidCompressionContainer(err))
106                }
107            }
108        }
109        None => {
110            // this means we haven't detected any compression container
111            // proceed to process the payload untouched (as a plain minidump).
112            Ok(minidump_data)
113        }
114    }
115}
116
117/// Removes any compression container file extensions from the minidump
118/// filename so it can be updated in the item. Otherwise, attachments that
119/// have been decoded would still show the extension in the UI, which is misleading.
120fn remove_container_extension(filename: &str) -> &str {
121    [".gz", ".xz", ".bz2", ".zst"]
122        .into_iter()
123        .find_map(|suffix| filename.strip_suffix(suffix))
124        .unwrap_or(filename)
125}
126
127fn infer_attachment_type(field_name: Option<&str>, _file_name: &str) -> AttachmentType {
128    match field_name.unwrap_or("") {
129        MINIDUMP_FIELD_NAME => AttachmentType::Minidump,
130        ITEM_NAME_BREADCRUMBS1 => AttachmentType::Breadcrumbs,
131        ITEM_NAME_BREADCRUMBS2 => AttachmentType::Breadcrumbs,
132        ITEM_NAME_EVENT => AttachmentType::EventPayload,
133        VIEW_HIERARCHY_FIELD_NAME => AttachmentType::ViewHierarchy,
134        _ => AttachmentType::Attachment,
135    }
136}
137
138/// Extract a minidump from a nested multipart form.
139///
140/// This field is not a minidump (i.e. it doesn't start with the minidump magic header). It could be
141/// a multipart field containing a minidump; this happens in old versions of the Linux Electron SDK.
142///
143/// Unfortunately, the embedded multipart field is not recognized by the multipart parser as a
144/// multipart field containing a multipart body. For this case we will look if the field starts with
145/// a '--' and manually extract the boundary (which is what follows '--' up to the end of line) and
146/// manually construct a multipart with the detected boundary. If we can extract a multipart with an
147/// embedded minidump, then use that field.
148async fn extract_embedded_minidump(payload: Bytes) -> Result<Option<Bytes>, BadStoreRequest> {
149    let boundary = match utils::get_multipart_boundary(&payload) {
150        Some(boundary) => boundary,
151        None => return Ok(None),
152    };
153
154    let stream = futures::stream::once(async { Ok::<_, Infallible>(payload.clone()) });
155    let mut multipart = Multipart::new(stream, boundary);
156
157    while let Some(field) = multipart.next_field().await? {
158        if field.name() == Some(MINIDUMP_FIELD_NAME) {
159            return Ok(Some(field.bytes().await?));
160        }
161    }
162
163    Ok(None)
164}
165
166async fn extract_multipart(
167    multipart: ConstrainedMultipart,
168    meta: RequestMeta,
169    config: &Config,
170) -> Result<Box<Envelope>, BadStoreRequest> {
171    let mut items = multipart.items(infer_attachment_type, config).await?;
172
173    let minidump_item = items
174        .iter_mut()
175        .find(|item| item.attachment_type() == Some(&AttachmentType::Minidump))
176        .ok_or(BadStoreRequest::MissingMinidump)?;
177
178    let embedded_opt = extract_embedded_minidump(minidump_item.payload()).await?;
179    if let Some(embedded) = embedded_opt {
180        minidump_item.set_payload(Minidump, embedded);
181    }
182
183    minidump_item.set_payload(Minidump, decode_minidump(minidump_item.payload())?);
184
185    validate_minidump(&minidump_item.payload())?;
186
187    if let Some(minidump_filename) = minidump_item.filename() {
188        minidump_item.set_filename(remove_container_extension(minidump_filename).to_owned());
189    }
190
191    let event_id = common::event_id_from_items(&items)?.unwrap_or_else(EventId::new);
192    let mut envelope = Envelope::from_request(Some(event_id), meta);
193
194    for item in items {
195        envelope.add_item(item);
196    }
197
198    Ok(envelope)
199}
200
201fn extract_raw_minidump(data: Bytes, meta: RequestMeta) -> Result<Box<Envelope>, BadStoreRequest> {
202    let mut item = Item::new(ItemType::Attachment);
203
204    item.set_payload(Minidump, decode_minidump(data)?);
205    validate_minidump(&item.payload())?;
206    item.set_filename(MINIDUMP_FILE_NAME);
207    item.set_attachment_type(AttachmentType::Minidump);
208
209    // Create an envelope with a random event id.
210    let mut envelope = Envelope::from_request(Some(EventId::new()), meta);
211    envelope.add_item(item);
212    Ok(envelope)
213}
214
215async fn handle(
216    state: ServiceState,
217    meta: RequestMeta,
218    content_type: RawContentType,
219    request: Request,
220) -> axum::response::Result<impl IntoResponse> {
221    // The minidump can either be transmitted as the request body, or as
222    // `upload_file_minidump` in a multipart form-data/ request.
223    // Minidump request payloads do not have the same structure as usual events from other SDKs. The
224    // minidump can either be transmitted as request body, or as `upload_file_minidump` in a
225    // multipart formdata request.
226    let envelope = if MINIDUMP_RAW_CONTENT_TYPES.contains(&content_type.as_ref()) {
227        extract_raw_minidump(request.extract().await?, meta)?
228    } else {
229        let multipart = request.extract_with_state(&state).await?;
230        extract_multipart(multipart, meta, state.config()).await?
231    };
232
233    let id = envelope.event_id();
234
235    // Never respond with a 429 since clients often retry these
236    match common::handle_envelope(&state, envelope).await {
237        Ok(_) | Err(BadStoreRequest::RateLimited(_)) => (),
238        Err(error) => return Err(error.into()),
239    };
240
241    // The return here is only useful for consistency because the UE4 crash reporter doesn't
242    // care about it.
243    Ok(TextResponse(id))
244}
245
246pub fn route(config: &Config) -> MethodRouter<ServiceState> {
247    // Set the single-attachment limit that applies only for raw minidumps. Multipart bypasses the
248    // limited body and applies its own limits.
249    post(handle).route_layer(DefaultBodyLimit::max(config.max_attachment_size()))
250}
251
252#[cfg(test)]
253mod tests {
254    use crate::envelope::ContentType;
255    use crate::utils::FormDataIter;
256    use axum::body::Body;
257    use bzip2::Compression as BzCompression;
258    use bzip2::write::BzEncoder;
259    use flate2::Compression as GzCompression;
260    use flate2::write::GzEncoder;
261    use liblzma::write::XzEncoder;
262    use relay_config::Config;
263    use std::io::Write;
264    use zstd::stream::Encoder as ZstdEncoder;
265
266    use super::*;
267
268    #[test]
269    fn test_validate_minidump() {
270        let be_minidump = b"PMDMxxxxxx";
271        assert!(validate_minidump(be_minidump).is_ok());
272
273        let le_minidump = b"MDMPxxxxxx";
274        assert!(validate_minidump(le_minidump).is_ok());
275
276        let garbage = b"xxxxxx";
277        assert!(validate_minidump(garbage).is_err());
278    }
279
280    type EncodeFunction = fn(&[u8]) -> Result<Bytes, Box<dyn std::error::Error>>;
281
282    fn encode_gzip(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
283        let mut encoder = GzEncoder::new(Vec::new(), GzCompression::default());
284        encoder.write_all(be_minidump)?;
285        let compressed = encoder.finish()?;
286        Ok(Bytes::from(compressed))
287    }
288    fn encode_bzip(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
289        let mut encoder = BzEncoder::new(Vec::new(), BzCompression::default());
290        encoder.write_all(be_minidump)?;
291        let compressed = encoder.finish()?;
292        Ok(Bytes::from(compressed))
293    }
294    fn encode_xz(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
295        let mut encoder = XzEncoder::new(Vec::new(), 6);
296        encoder.write_all(be_minidump)?;
297        let compressed = encoder.finish()?;
298        Ok(Bytes::from(compressed))
299    }
300    fn encode_zst(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
301        let mut encoder = ZstdEncoder::new(Vec::new(), 0)?;
302        encoder.write_all(be_minidump)?;
303        let compressed = encoder.finish()?;
304        Ok(Bytes::from(compressed))
305    }
306
307    #[test]
308    fn test_validate_encoded_minidump() -> Result<(), Box<dyn std::error::Error>> {
309        let encoders: Vec<EncodeFunction> = vec![encode_gzip, encode_zst, encode_bzip, encode_xz];
310
311        for encoder in &encoders {
312            let be_minidump = b"PMDMxxxxxx";
313            let compressed = encoder(be_minidump)?;
314            let mut decoder = decoder_from(compressed).unwrap();
315            assert!(run_decoder(&mut decoder).is_ok());
316
317            let le_minidump = b"MDMPxxxxxx";
318            let compressed = encoder(le_minidump)?;
319            let mut decoder = decoder_from(compressed).unwrap();
320            assert!(run_decoder(&mut decoder).is_ok());
321
322            let garbage = b"xxxxxx";
323            let compressed = encoder(garbage)?;
324            let mut decoder = decoder_from(compressed).unwrap();
325            let decoded = run_decoder(&mut decoder);
326            assert!(decoded.is_ok());
327            assert!(validate_minidump(&decoded.unwrap()).is_err());
328        }
329
330        Ok(())
331    }
332
333    #[test]
334    fn test_remove_container_extension() -> Result<(), Box<dyn std::error::Error>> {
335        assert_eq!(remove_container_extension("minidump"), "minidump");
336        assert_eq!(remove_container_extension("minidump.gz"), "minidump");
337        assert_eq!(remove_container_extension("minidump.bz2"), "minidump");
338        assert_eq!(remove_container_extension("minidump.xz"), "minidump");
339        assert_eq!(remove_container_extension("minidump.zst"), "minidump");
340        assert_eq!(remove_container_extension("minidump.dmp"), "minidump.dmp");
341        assert_eq!(
342            remove_container_extension("minidump.dmp.gz"),
343            "minidump.dmp"
344        );
345        assert_eq!(
346            remove_container_extension("minidump.dmp.bz2"),
347            "minidump.dmp"
348        );
349        assert_eq!(
350            remove_container_extension("minidump.dmp.xz"),
351            "minidump.dmp"
352        );
353        assert_eq!(
354            remove_container_extension("minidump.dmp.zst"),
355            "minidump.dmp"
356        );
357
358        Ok(())
359    }
360
361    #[tokio::test]
362    async fn test_minidump_multipart_attachments() -> anyhow::Result<()> {
363        let multipart_body: &[u8] =
364            b"-----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
365            Content-Disposition: form-data; name=\"guid\"\x0d\x0a\x0d\x0add46bb04-bb27-448c-aad0-0deb0c134bdb\x0d\x0a\
366            -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
367            Content-Disposition: form-data; name=\"config.json\"; filename=\"config.json\"\x0d\x0a\x0d\x0a\
368            \"Sentry\": { \"Dsn\": \"https://ingest.us.sentry.io/xxxxxxx\", \"MaxBreadcrumbs\": 50, \"Debug\": true }\x0d\x0a\
369            -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
370            Content-Disposition: form-data; name=\"__sentry-breadcrumb1\"; filename=\"__sentry-breadcrumb1\"\x0d\x0a\
371            Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
372            \x82\
373            \xa9timestamp\xb82024-03-12T16:59:33.069Z\
374            \xa7message\xb5default level is info\x0d\x0a\
375            -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
376            Content-Disposition: form-data; name=\"__sentry-breadcrumb2\"; filename=\"__sentry-breadcrumb2\"\x0d\x0a\
377            Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
378            \x0d\x0a\
379            -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
380            Content-Disposition: form-data; name=\"__sentry-event\"; filename=\"__sentry-event\"\x0d\x0a\
381            Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
382            \x82\xa5level\xa5fatal\xa8platform\xa6native\x0d\x0a\
383            -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
384            Content-Disposition: form-data; name=\"view-hierarchy.json\"; filename=\"view-hierarchy.json\"\x0d\x0a\
385            Content-Type: application/json\x0d\x0a\x0d\x0a\
386            {\"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\
387            -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw-----\x0d\x0a";
388
389        let request = Request::builder()
390            .header(
391                "content-type",
392                "multipart/form-data; boundary=---MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---",
393            )
394            .body(Body::from(multipart_body))?;
395
396        let config = Config::default();
397
398        let multipart = ConstrainedMultipart(utils::multipart_from_request(
399            request,
400            multer::Constraints::new(),
401        )?);
402        let items = multipart.items(infer_attachment_type, &config).await?;
403
404        // we expect the multipart body to contain
405        // * one arbitrary attachment from the user (a `config.json`)
406        // * two breadcrumb files
407        // * one event file
408        // * one form-data item
409        // * one view-hierarchy file
410        assert_eq!(6, items.len());
411
412        // `config.json` has no content-type. MIME-detection in later processing will assign this.
413        let item = &items[0];
414        assert_eq!(item.filename().unwrap(), "config.json");
415        assert!(item.content_type().is_none());
416        assert_eq!(item.ty(), &ItemType::Attachment);
417        assert_eq!(item.attachment_type().unwrap(), &AttachmentType::Attachment);
418        assert_eq!(item.payload().len(), 95);
419
420        // the first breadcrumb buffer
421        let item = &items[1];
422        assert_eq!(item.filename().unwrap(), "__sentry-breadcrumb1");
423        assert_eq!(item.content_type().unwrap(), &ContentType::OctetStream);
424        assert_eq!(item.ty(), &ItemType::Attachment);
425        assert_eq!(
426            item.attachment_type().unwrap(),
427            &AttachmentType::Breadcrumbs
428        );
429        assert_eq!(item.payload().len(), 66);
430
431        // the second breadcrumb buffer is empty since we haven't reached our max in the first
432        let item = &items[2];
433        assert_eq!(item.filename().unwrap(), "__sentry-breadcrumb2");
434        assert_eq!(item.content_type().unwrap(), &ContentType::OctetStream);
435        assert_eq!(item.ty(), &ItemType::Attachment);
436        assert_eq!(
437            item.attachment_type().unwrap(),
438            &AttachmentType::Breadcrumbs
439        );
440        assert_eq!(item.payload().len(), 0);
441
442        // the msg-pack encoded event file
443        let item = &items[3];
444        assert_eq!(item.filename().unwrap(), "__sentry-event");
445        assert_eq!(item.content_type().unwrap(), &ContentType::OctetStream);
446        assert_eq!(item.ty(), &ItemType::Attachment);
447        assert_eq!(
448            item.attachment_type().unwrap(),
449            &AttachmentType::EventPayload
450        );
451        assert_eq!(item.payload().len(), 29);
452
453        // the next item is the view-hierarchy file
454        let item = &items[4];
455        assert_eq!(item.filename().unwrap(), "view-hierarchy.json");
456        assert_eq!(item.content_type().unwrap(), &ContentType::Json);
457        assert_eq!(item.ty(), &ItemType::Attachment);
458        assert_eq!(
459            item.attachment_type().unwrap(),
460            &AttachmentType::ViewHierarchy
461        );
462        assert_eq!(item.payload().len(), 184);
463
464        // the last item is the form-data if any and contains a `guid` from the `crashpad_handler`
465        let item = &items[5];
466        assert!(item.filename().is_none());
467        assert_eq!(item.content_type().unwrap(), &ContentType::Text);
468        assert_eq!(item.ty(), &ItemType::FormData);
469        assert!(item.attachment_type().is_none());
470        let form_payload = item.payload();
471        let form_data_entry = FormDataIter::new(form_payload.as_ref()).next().unwrap();
472        assert_eq!(form_data_entry.key(), "guid");
473        assert_eq!(
474            form_data_entry.value(),
475            "dd46bb04-bb27-448c-aad0-0deb0c134bdb"
476        );
477
478        Ok(())
479    }
480}