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, Remote, RequestMeta};
23use crate::service::ServiceState;
24use crate::utils;
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: Multipart<'static>,
168    meta: RequestMeta,
169) -> Result<Box<Envelope>, BadStoreRequest> {
170    let mut items = utils::multipart_items(multipart, infer_attachment_type).await?;
171
172    let minidump_item = items
173        .iter_mut()
174        .find(|item| item.attachment_type() == Some(&AttachmentType::Minidump))
175        .ok_or(BadStoreRequest::MissingMinidump)?;
176
177    let embedded_opt = extract_embedded_minidump(minidump_item.payload()).await?;
178    if let Some(embedded) = embedded_opt {
179        minidump_item.set_payload(Minidump, embedded);
180    }
181
182    minidump_item.set_payload(Minidump, decode_minidump(minidump_item.payload())?);
183
184    validate_minidump(&minidump_item.payload())?;
185
186    if let Some(minidump_filename) = minidump_item.filename() {
187        minidump_item.set_filename(remove_container_extension(minidump_filename).to_owned());
188    }
189
190    let event_id = common::event_id_from_items(&items)?.unwrap_or_else(EventId::new);
191    let mut envelope = Envelope::from_request(Some(event_id), meta);
192
193    for item in items {
194        envelope.add_item(item);
195    }
196
197    Ok(envelope)
198}
199
200fn extract_raw_minidump(data: Bytes, meta: RequestMeta) -> Result<Box<Envelope>, BadStoreRequest> {
201    let mut item = Item::new(ItemType::Attachment);
202
203    item.set_payload(Minidump, decode_minidump(data)?);
204    validate_minidump(&item.payload())?;
205    item.set_filename(MINIDUMP_FILE_NAME);
206    item.set_attachment_type(AttachmentType::Minidump);
207
208    // Create an envelope with a random event id.
209    let mut envelope = Envelope::from_request(Some(EventId::new()), meta);
210    envelope.add_item(item);
211    Ok(envelope)
212}
213
214async fn handle(
215    state: ServiceState,
216    meta: RequestMeta,
217    content_type: RawContentType,
218    request: Request,
219) -> axum::response::Result<impl IntoResponse> {
220    // The minidump can either be transmitted as the request body, or as
221    // `upload_file_minidump` in a multipart form-data/ request.
222    // Minidump request payloads do not have the same structure as usual events from other SDKs. The
223    // minidump can either be transmitted as request body, or as `upload_file_minidump` in a
224    // multipart formdata request.
225    let envelope = if MINIDUMP_RAW_CONTENT_TYPES.contains(&content_type.as_ref()) {
226        extract_raw_minidump(request.extract().await?, meta)?
227    } else {
228        let Remote(multipart) = request.extract_with_state(&state).await?;
229        extract_multipart(multipart, meta).await?
230    };
231
232    let id = envelope.event_id();
233
234    // Never respond with a 429 since clients often retry these
235    match common::handle_envelope(&state, envelope).await {
236        Ok(_) | Err(BadStoreRequest::RateLimited(_)) => (),
237        Err(error) => return Err(error.into()),
238    };
239
240    // The return here is only useful for consistency because the UE4 crash reporter doesn't
241    // care about it.
242    Ok(TextResponse(id))
243}
244
245pub fn route(config: &Config) -> MethodRouter<ServiceState> {
246    // Set the single-attachment limit that applies only for raw minidumps. Multipart bypasses the
247    // limited body and applies its own limits.
248    post(handle).route_layer(DefaultBodyLimit::max(config.max_attachment_size()))
249}
250
251#[cfg(test)]
252mod tests {
253    use crate::envelope::ContentType;
254    use crate::utils::{FormDataIter, multipart_items};
255    use axum::body::Body;
256    use bzip2::Compression as BzCompression;
257    use bzip2::write::BzEncoder;
258    use flate2::Compression as GzCompression;
259    use flate2::write::GzEncoder;
260    use liblzma::write::XzEncoder;
261    use relay_config::Config;
262    use std::io::Write;
263    use zstd::stream::Encoder as ZstdEncoder;
264
265    use super::*;
266
267    #[test]
268    fn test_validate_minidump() {
269        let be_minidump = b"PMDMxxxxxx";
270        assert!(validate_minidump(be_minidump).is_ok());
271
272        let le_minidump = b"MDMPxxxxxx";
273        assert!(validate_minidump(le_minidump).is_ok());
274
275        let garbage = b"xxxxxx";
276        assert!(validate_minidump(garbage).is_err());
277    }
278
279    type EncodeFunction = fn(&[u8]) -> Result<Bytes, Box<dyn std::error::Error>>;
280
281    fn encode_gzip(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
282        let mut encoder = GzEncoder::new(Vec::new(), GzCompression::default());
283        encoder.write_all(be_minidump)?;
284        let compressed = encoder.finish()?;
285        Ok(Bytes::from(compressed))
286    }
287    fn encode_bzip(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
288        let mut encoder = BzEncoder::new(Vec::new(), BzCompression::default());
289        encoder.write_all(be_minidump)?;
290        let compressed = encoder.finish()?;
291        Ok(Bytes::from(compressed))
292    }
293    fn encode_xz(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
294        let mut encoder = XzEncoder::new(Vec::new(), 6);
295        encoder.write_all(be_minidump)?;
296        let compressed = encoder.finish()?;
297        Ok(Bytes::from(compressed))
298    }
299    fn encode_zst(be_minidump: &[u8]) -> Result<Bytes, Box<dyn std::error::Error>> {
300        let mut encoder = ZstdEncoder::new(Vec::new(), 0)?;
301        encoder.write_all(be_minidump)?;
302        let compressed = encoder.finish()?;
303        Ok(Bytes::from(compressed))
304    }
305
306    #[test]
307    fn test_validate_encoded_minidump() -> Result<(), Box<dyn std::error::Error>> {
308        let encoders: Vec<EncodeFunction> = vec![encode_gzip, encode_zst, encode_bzip, encode_xz];
309
310        for encoder in &encoders {
311            let be_minidump = b"PMDMxxxxxx";
312            let compressed = encoder(be_minidump)?;
313            let mut decoder = decoder_from(compressed).unwrap();
314            assert!(run_decoder(&mut decoder).is_ok());
315
316            let le_minidump = b"MDMPxxxxxx";
317            let compressed = encoder(le_minidump)?;
318            let mut decoder = decoder_from(compressed).unwrap();
319            assert!(run_decoder(&mut decoder).is_ok());
320
321            let garbage = b"xxxxxx";
322            let compressed = encoder(garbage)?;
323            let mut decoder = decoder_from(compressed).unwrap();
324            let decoded = run_decoder(&mut decoder);
325            assert!(decoded.is_ok());
326            assert!(validate_minidump(&decoded.unwrap()).is_err());
327        }
328
329        Ok(())
330    }
331
332    #[test]
333    fn test_remove_container_extension() -> Result<(), Box<dyn std::error::Error>> {
334        assert_eq!(remove_container_extension("minidump"), "minidump");
335        assert_eq!(remove_container_extension("minidump.gz"), "minidump");
336        assert_eq!(remove_container_extension("minidump.bz2"), "minidump");
337        assert_eq!(remove_container_extension("minidump.xz"), "minidump");
338        assert_eq!(remove_container_extension("minidump.zst"), "minidump");
339        assert_eq!(remove_container_extension("minidump.dmp"), "minidump.dmp");
340        assert_eq!(
341            remove_container_extension("minidump.dmp.gz"),
342            "minidump.dmp"
343        );
344        assert_eq!(
345            remove_container_extension("minidump.dmp.bz2"),
346            "minidump.dmp"
347        );
348        assert_eq!(
349            remove_container_extension("minidump.dmp.xz"),
350            "minidump.dmp"
351        );
352        assert_eq!(
353            remove_container_extension("minidump.dmp.zst"),
354            "minidump.dmp"
355        );
356
357        Ok(())
358    }
359
360    #[tokio::test]
361    async fn test_minidump_multipart_attachments() -> anyhow::Result<()> {
362        let multipart_body: &[u8] =
363            b"-----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
364            Content-Disposition: form-data; name=\"guid\"\x0d\x0a\x0d\x0add46bb04-bb27-448c-aad0-0deb0c134bdb\x0d\x0a\
365            -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
366            Content-Disposition: form-data; name=\"config.json\"; filename=\"config.json\"\x0d\x0a\x0d\x0a\
367            \"Sentry\": { \"Dsn\": \"https://ingest.us.sentry.io/xxxxxxx\", \"MaxBreadcrumbs\": 50, \"Debug\": true }\x0d\x0a\
368            -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
369            Content-Disposition: form-data; name=\"__sentry-breadcrumb1\"; filename=\"__sentry-breadcrumb1\"\x0d\x0a\
370            Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
371            \x82\
372            \xa9timestamp\xb82024-03-12T16:59:33.069Z\
373            \xa7message\xb5default level is info\x0d\x0a\
374            -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
375            Content-Disposition: form-data; name=\"__sentry-breadcrumb2\"; filename=\"__sentry-breadcrumb2\"\x0d\x0a\
376            Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
377            \x0d\x0a\
378            -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
379            Content-Disposition: form-data; name=\"__sentry-event\"; filename=\"__sentry-event\"\x0d\x0a\
380            Content-Type: application/octet-stream\x0d\x0a\x0d\x0a\
381            \x82\xa5level\xa5fatal\xa8platform\xa6native\x0d\x0a\
382            -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---\x0d\x0a\
383            Content-Disposition: form-data; name=\"view-hierarchy.json\"; filename=\"view-hierarchy.json\"\x0d\x0a\
384            Content-Type: application/json\x0d\x0a\x0d\x0a\
385            {\"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\
386            -----MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw-----\x0d\x0a";
387
388        let request = Request::builder()
389            .header(
390                "content-type",
391                "multipart/form-data; boundary=---MultipartBoundary-sQ95dYmFvVzJ2UcOSdGPBkqrW0syf0Uw---",
392            )
393            .body(Body::from(multipart_body))?;
394
395        let config = Config::default();
396
397        let multipart = utils::multipart_from_request(request, &config)?;
398        let items = multipart_items(multipart, infer_attachment_type).await?;
399
400        // we expect the multipart body to contain
401        // * one arbitrary attachment from the user (a `config.json`)
402        // * two breadcrumb files
403        // * one event file
404        // * one form-data item
405        // * one view-hierarchy file
406        assert_eq!(6, items.len());
407
408        // `config.json` has no content-type. MIME-detection in later processing will assign this.
409        let item = &items[0];
410        assert_eq!(item.filename().unwrap(), "config.json");
411        assert!(item.content_type().is_none());
412        assert_eq!(item.ty(), &ItemType::Attachment);
413        assert_eq!(item.attachment_type().unwrap(), &AttachmentType::Attachment);
414        assert_eq!(item.payload().len(), 95);
415
416        // the first breadcrumb buffer
417        let item = &items[1];
418        assert_eq!(item.filename().unwrap(), "__sentry-breadcrumb1");
419        assert_eq!(item.content_type().unwrap(), &ContentType::OctetStream);
420        assert_eq!(item.ty(), &ItemType::Attachment);
421        assert_eq!(
422            item.attachment_type().unwrap(),
423            &AttachmentType::Breadcrumbs
424        );
425        assert_eq!(item.payload().len(), 66);
426
427        // the second breadcrumb buffer is empty since we haven't reached our max in the first
428        let item = &items[2];
429        assert_eq!(item.filename().unwrap(), "__sentry-breadcrumb2");
430        assert_eq!(item.content_type().unwrap(), &ContentType::OctetStream);
431        assert_eq!(item.ty(), &ItemType::Attachment);
432        assert_eq!(
433            item.attachment_type().unwrap(),
434            &AttachmentType::Breadcrumbs
435        );
436        assert_eq!(item.payload().len(), 0);
437
438        // the msg-pack encoded event file
439        let item = &items[3];
440        assert_eq!(item.filename().unwrap(), "__sentry-event");
441        assert_eq!(item.content_type().unwrap(), &ContentType::OctetStream);
442        assert_eq!(item.ty(), &ItemType::Attachment);
443        assert_eq!(
444            item.attachment_type().unwrap(),
445            &AttachmentType::EventPayload
446        );
447        assert_eq!(item.payload().len(), 29);
448
449        // the next item is the view-hierarchy file
450        let item = &items[4];
451        assert_eq!(item.filename().unwrap(), "view-hierarchy.json");
452        assert_eq!(item.content_type().unwrap(), &ContentType::Json);
453        assert_eq!(item.ty(), &ItemType::Attachment);
454        assert_eq!(
455            item.attachment_type().unwrap(),
456            &AttachmentType::ViewHierarchy
457        );
458        assert_eq!(item.payload().len(), 184);
459
460        // the last item is the form-data if any and contains a `guid` from the `crashpad_handler`
461        let item = &items[5];
462        assert!(item.filename().is_none());
463        assert_eq!(item.content_type().unwrap(), &ContentType::Text);
464        assert_eq!(item.ty(), &ItemType::FormData);
465        assert!(item.attachment_type().is_none());
466        let form_payload = item.payload();
467        let form_data_entry = FormDataIter::new(form_payload.as_ref()).next().unwrap();
468        assert_eq!(form_data_entry.key(), "guid");
469        assert_eq!(
470            form_data_entry.value(),
471            "dd46bb04-bb27-448c-aad0-0deb0c134bdb"
472        );
473
474        Ok(())
475    }
476}