1#![cfg_attr(
24 not(test),
25 deny(
26 clippy::unwrap_used,
27 clippy::expect_used,
28 clippy::panic,
29 clippy::unreachable,
30 clippy::todo,
31 clippy::unimplemented,
32 clippy::indexing_slicing,
33 )
34)]
35
36use axum::extract::{FromRequest, FromRequestParts};
37use axum::response::{IntoResponse, Response};
38
39macro_rules! impl_extractor_deref {
40 ($extractor:ident) => {
41 impl<T> std::ops::Deref for $extractor<T> {
42 type Target = T;
43
44 fn deref(&self) -> &Self::Target {
45 &self.0
46 }
47 }
48
49 impl<T> std::ops::DerefMut for $extractor<T> {
50 fn deref_mut(&mut self) -> &mut Self::Target {
51 &mut self.0
52 }
53 }
54 };
55}
56
57#[derive(Debug, Clone, Copy, Default)]
62pub struct Form<T>(pub T);
63
64impl_extractor_deref!(Form);
65
66impl<S, T> FromRequest<S> for Form<T>
67where
68 S: Send + Sync,
69 axum::extract::Form<T>: FromRequest<S, Rejection = axum::extract::rejection::FormRejection>,
70{
71 type Rejection = crate::AutumnError;
72
73 async fn from_request(req: axum::extract::Request, state: &S) -> Result<Self, Self::Rejection> {
74 axum::extract::Form::from_request(req, state)
75 .await
76 .map(|axum::extract::Form(value)| Self(value))
77 .map_err(|err| rejection_to_error(err.status(), err.body_text()))
78 }
79}
80
81#[derive(Debug, Clone, Copy, Default)]
87pub struct Json<T>(pub T);
88
89impl_extractor_deref!(Json);
90
91impl<S, T> FromRequest<S> for Json<T>
92where
93 S: Send + Sync,
94 axum::extract::Json<T>: FromRequest<S, Rejection = axum::extract::rejection::JsonRejection>,
95{
96 type Rejection = crate::AutumnError;
97
98 async fn from_request(req: axum::extract::Request, state: &S) -> Result<Self, Self::Rejection> {
99 axum::extract::Json::from_request(req, state)
100 .await
101 .map(|axum::extract::Json(value)| Self(value))
102 .map_err(|err| rejection_to_error(err.status(), err.body_text()))
103 }
104}
105
106impl<T> IntoResponse for Json<T>
107where
108 axum::Json<T>: IntoResponse,
109{
110 fn into_response(self) -> Response {
111 axum::Json(self.0).into_response()
112 }
113}
114
115#[derive(Debug, Clone, Copy, Default)]
120pub struct Path<T>(pub T);
121
122impl_extractor_deref!(Path);
123
124impl<S, T> FromRequestParts<S> for Path<T>
125where
126 S: Send + Sync,
127 axum::extract::Path<T>:
128 FromRequestParts<S, Rejection = axum::extract::rejection::PathRejection>,
129{
130 type Rejection = crate::AutumnError;
131
132 async fn from_request_parts(
133 parts: &mut axum::http::request::Parts,
134 state: &S,
135 ) -> Result<Self, Self::Rejection> {
136 axum::extract::Path::from_request_parts(parts, state)
137 .await
138 .map(|axum::extract::Path(value)| Self(value))
139 .map_err(|err| rejection_to_error(err.status(), err.body_text()))
140 }
141}
142
143#[derive(Debug, Clone, Copy, Default)]
167pub struct Query<T>(pub T);
168
169impl_extractor_deref!(Query);
170
171impl<S, T> FromRequestParts<S> for Query<T>
172where
173 S: Send + Sync,
174 axum::extract::Query<T>:
175 FromRequestParts<S, Rejection = axum::extract::rejection::QueryRejection>,
176{
177 type Rejection = crate::AutumnError;
178
179 async fn from_request_parts(
180 parts: &mut axum::http::request::Parts,
181 state: &S,
182 ) -> Result<Self, Self::Rejection> {
183 axum::extract::Query::from_request_parts(parts, state)
184 .await
185 .map(|axum::extract::Query(value)| Self(value))
186 .map_err(|err| rejection_to_error(err.status(), err.body_text()))
187 }
188}
189
190fn rejection_to_error(status: http::StatusCode, body_text: String) -> crate::AutumnError {
191 crate::AutumnError::bad_request_msg(body_text).with_status(status)
192}
193
194#[cfg(feature = "multipart")]
205pub struct Multipart {
206 inner: axum::extract::Multipart,
207 config: crate::security::config::UploadConfig,
208}
209
210#[cfg(feature = "multipart")]
211impl Multipart {
212 pub async fn next_field(&mut self) -> crate::AutumnResult<Option<MultipartField<'_>>> {
219 let Some(mut field) = self
220 .inner
221 .next_field()
222 .await
223 .map_err(|err| multipart_error_to_error(&err))?
224 else {
225 return Ok(None);
226 };
227
228 let needs_sniff = field.file_name().is_some()
235 && (!self.config.allowed_mime_types.is_empty()
236 || self.config.reject_on_content_type_mismatch);
237
238 let filename_is_empty = field.file_name().is_some_and(str::is_empty);
249
250 if !needs_sniff {
251 return Ok(Some(MultipartField::new(
261 field,
262 self.config.max_file_size_bytes,
263 )));
264 }
265
266 let mut prefix: Vec<u8> = Vec::with_capacity(SNIFF_PREFIX_BYTES);
270 while prefix.len() < SNIFF_PREFIX_BYTES {
271 match field
272 .chunk()
273 .await
274 .map_err(|err| multipart_error_to_error(&err))?
275 {
276 Some(chunk) => {
277 prefix.extend_from_slice(&chunk);
278 if prefix.len() > self.config.max_file_size_bytes {
281 return Err(file_too_large_error(self.config.max_file_size_bytes));
282 }
283 }
284 None => break,
285 }
286 }
287
288 let declared_essence = field.content_type().map(content_type_essence);
294 let sniffed = sniff_content_type(&prefix);
295
296 let is_empty_file_input = filename_is_empty && prefix.is_empty();
303 if !is_empty_file_input {
304 if !self.config.allowed_mime_types.is_empty() {
308 enforce_upload_allow_list(
309 &self.config.allowed_mime_types,
310 &prefix,
311 declared_essence,
312 sniffed,
313 )?;
314 }
315 if self.config.reject_on_content_type_mismatch {
316 enforce_content_type_match(declared_essence, sniffed)?;
317 }
318 }
319
320 Ok(Some(MultipartField {
321 inner: field,
322 max_file_size_bytes: self.config.max_file_size_bytes,
323 prefix,
324 sniffed_content_type: sniffed,
325 is_empty_optional_input: is_empty_file_input,
326 }))
327 }
328}
329
330#[cfg(feature = "multipart")]
334fn sniff_content_type(prefix: &[u8]) -> Option<&'static str> {
335 infer::get(prefix).map(|kind| kind.mime_type())
336}
337
338#[cfg(feature = "multipart")]
341fn content_type_essence(raw: &str) -> &str {
342 raw.split(';').next().unwrap_or("").trim()
343}
344
345#[cfg(feature = "multipart")]
351fn prefix_looks_like_markup(prefix: &[u8]) -> bool {
352 let bytes = prefix.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(prefix);
353 bytes.iter().find(|byte| !byte.is_ascii_whitespace()) == Some(&b'<')
354}
355
356#[cfg(feature = "multipart")]
359fn truncate_for_error(value: &str) -> String {
360 const MAX_CHARS: usize = 128;
361 value.chars().take(MAX_CHARS).collect()
362}
363
364#[cfg(feature = "multipart")]
370fn is_signatureless_text_type(essence: &str) -> bool {
371 let lower = essence.to_ascii_lowercase();
373 lower.starts_with("text/") || matches!(lower.as_str(), "application/json" | "application/csv")
374}
375
376#[cfg(feature = "multipart")]
389fn enforce_upload_allow_list(
390 allowed: &[String],
391 prefix: &[u8],
392 declared_essence: Option<&str>,
393 sniffed: Option<&str>,
394) -> crate::AutumnResult<()> {
395 let in_list = |value: &str| {
396 allowed
397 .iter()
398 .any(|entry| entry.eq_ignore_ascii_case(value))
399 };
400 if let Some(sniffed) = sniffed {
401 if !in_list(sniffed) {
402 return Err(crate::AutumnError::bad_request_msg(format!(
403 "upload content type not allowed: sniffed={sniffed}"
404 )));
405 }
406 } else if prefix_looks_like_markup(prefix) {
407 return Err(crate::AutumnError::bad_request_msg(
408 "upload rejected: unrecognized file content looks like markup",
409 ));
410 } else if !matches!(
411 declared_essence,
412 Some(essence) if is_signatureless_text_type(essence) && in_list(essence)
413 ) {
414 return Err(crate::AutumnError::bad_request_msg(format!(
415 "upload content type could not be verified from its content: declared={}",
416 declared_essence.map_or_else(String::new, truncate_for_error)
417 )));
418 }
419 Ok(())
420}
421
422#[cfg(feature = "multipart")]
427fn enforce_content_type_match(
428 declared_essence: Option<&str>,
429 sniffed: Option<&str>,
430) -> crate::AutumnResult<()> {
431 let Some(declared_essence) = declared_essence else {
432 return Err(crate::AutumnError::bad_request_msg(
433 "content-type mismatch check enabled but the upload declared no content type",
434 ));
435 };
436 match sniffed {
437 Some(sniffed) if declared_essence.eq_ignore_ascii_case(sniffed) => Ok(()),
438 Some(sniffed) => Err(crate::AutumnError::bad_request_msg(format!(
439 "declared content type {} does not match sniffed content type {sniffed}",
440 truncate_for_error(declared_essence)
441 ))),
442 None => Err(crate::AutumnError::bad_request_msg(format!(
443 "cannot verify declared content type {}: file content is unrecognized",
444 truncate_for_error(declared_essence)
445 ))),
446 }
447}
448
449#[cfg(feature = "multipart")]
450impl<S> axum::extract::FromRequest<S> for Multipart
451where
452 S: Send + Sync,
453 axum::extract::Multipart:
454 axum::extract::FromRequest<S, Rejection = axum::extract::multipart::MultipartRejection>,
455{
456 type Rejection = crate::AutumnError;
457
458 async fn from_request(
459 mut req: axum::extract::Request,
460 state: &S,
461 ) -> Result<Self, Self::Rejection> {
462 let config = req
463 .extensions()
464 .get::<crate::security::config::UploadConfig>()
465 .cloned()
466 .unwrap_or_default();
467 axum::extract::DefaultBodyLimit::max(config.max_request_size_bytes).apply(&mut req);
468 let inner = axum::extract::Multipart::from_request(req, state)
469 .await
470 .map_err(|err| multipart_rejection_to_error(&err))?;
471 Ok(Self { inner, config })
472 }
473}
474
475#[cfg(feature = "multipart")]
480const SNIFF_PREFIX_BYTES: usize = 512;
481
482#[cfg(feature = "multipart")]
484pub struct MultipartField<'a> {
485 inner: axum::extract::multipart::Field<'a>,
486 max_file_size_bytes: usize,
487 prefix: Vec<u8>,
491 sniffed_content_type: Option<&'static str>,
494 is_empty_optional_input: bool,
504}
505
506#[cfg(all(feature = "multipart", feature = "storage"))]
507struct MultipartFieldStreamState<'a> {
508 inner: axum::extract::multipart::Field<'a>,
509 prefix: Option<bytes::Bytes>,
511 total: usize,
512 max: usize,
513 errored: bool,
514}
515
516#[cfg(feature = "multipart")]
517#[allow(clippy::elidable_lifetime_names)]
518impl<'a> MultipartField<'a> {
519 const fn new(inner: axum::extract::multipart::Field<'a>, max_file_size_bytes: usize) -> Self {
522 Self {
523 inner,
524 max_file_size_bytes,
525 prefix: Vec::new(),
526 sniffed_content_type: None,
527 is_empty_optional_input: false,
528 }
529 }
530
531 #[must_use]
533 pub fn name(&self) -> Option<&str> {
534 self.inner.name()
535 }
536
537 #[must_use]
545 pub const fn sniffed_content_type(&self) -> Option<&str> {
546 self.sniffed_content_type
547 }
548
549 #[must_use]
577 pub fn file_name(&self) -> Option<&str> {
578 if self.is_empty_optional_input {
579 None
580 } else {
581 self.inner.file_name()
582 }
583 }
584
585 #[must_use]
587 pub fn content_type(&self) -> Option<&str> {
588 self.inner.content_type()
589 }
590
591 #[must_use]
612 pub fn with_max_bytes(mut self, max: usize) -> Self {
613 self.max_file_size_bytes = self.max_file_size_bytes.min(max);
614 self
615 }
616
617 pub async fn bytes_limited(mut self) -> crate::AutumnResult<Vec<u8>> {
624 let mut out = self.prefix;
628 let mut read = out.len();
629 if read > self.max_file_size_bytes {
630 return Err(file_too_large_error(self.max_file_size_bytes));
631 }
632 while let Some(chunk) = self
633 .inner
634 .chunk()
635 .await
636 .map_err(|err| multipart_error_to_error(&err))?
637 {
638 read += chunk.len();
639 if read > self.max_file_size_bytes {
640 return Err(file_too_large_error(self.max_file_size_bytes));
641 }
642 out.extend_from_slice(&chunk);
643 }
644 Ok(out)
645 }
646
647 #[cfg(feature = "storage")]
684 pub async fn save_to_blob_store<'b>(
685 self,
686 store: &'b (dyn crate::storage::BlobStore + '_),
687 key: impl Into<String>,
688 ) -> crate::AutumnResult<crate::storage::Blob>
689 where
690 'a: 'b,
691 {
692 let key = key.into();
693 let content_type = self
696 .sniffed_content_type
697 .map(str::to_owned)
698 .or_else(|| self.inner.content_type().map(str::to_owned))
699 .unwrap_or_else(|| "application/octet-stream".to_owned());
700
701 let prefix = if self.prefix.is_empty() {
702 None
703 } else {
704 Some(bytes::Bytes::from(self.prefix))
705 };
706
707 let state = MultipartFieldStreamState {
713 inner: self.inner,
714 prefix,
715 total: 0,
716 max: self.max_file_size_bytes,
717 errored: false,
718 };
719
720 let stream = futures::stream::unfold(state, |mut state| async move {
721 if state.errored {
722 return None;
723 }
724 if let Some(prefix) = state.prefix.take() {
725 state.total = state.total.saturating_add(prefix.len());
726 if state.total > state.max {
727 let err = crate::storage::BlobStoreError::PayloadTooLarge(format!(
728 "uploaded file exceeds limit of {} bytes",
729 state.max,
730 ));
731 state.errored = true;
732 return Some((Err(err), state));
733 }
734 return Some((Ok(prefix), state));
735 }
736 match state.inner.chunk().await {
737 Ok(Some(chunk)) => {
738 state.total = state.total.saturating_add(chunk.len());
739 if state.total > state.max {
740 let err = crate::storage::BlobStoreError::PayloadTooLarge(format!(
741 "uploaded file exceeds limit of {} bytes",
742 state.max,
743 ));
744 state.errored = true;
745 Some((Err(err), state))
746 } else {
747 Some((Ok(chunk), state))
748 }
749 }
750 Ok(None) => None,
751 Err(err) => {
752 state.errored = true;
758 let mapped = blob_error_from_multipart(&err);
759 Some((Err(mapped), state))
760 }
761 }
762 });
763 let stream: crate::storage::ByteStream<'b> = Box::pin(stream);
764
765 store
766 .put_stream(&key, &content_type, stream)
767 .await
768 .map_err(crate::storage::BlobStoreError::into_autumn_error)
769 }
770
771 pub async fn save_to<P: AsRef<std::path::Path>>(
778 mut self,
779 path: P,
780 ) -> crate::AutumnResult<usize> {
781 use tokio::io::AsyncWriteExt as _;
782
783 let path = path.as_ref();
784 let mut file = tokio::fs::File::create(path)
785 .await
786 .map_err(crate::AutumnError::internal_server_error)?;
787
788 let mut written = 0usize;
789 if !self.prefix.is_empty() {
792 written += self.prefix.len();
793 if written > self.max_file_size_bytes {
794 drop(file);
795 let _ = tokio::fs::remove_file(path).await;
796 return Err(file_too_large_error(self.max_file_size_bytes));
797 }
798 file.write_all(&self.prefix)
799 .await
800 .map_err(crate::AutumnError::internal_server_error)?;
801 }
802 while let Some(chunk) = self
803 .inner
804 .chunk()
805 .await
806 .map_err(|err| multipart_error_to_error(&err))?
807 {
808 written += chunk.len();
809 if written > self.max_file_size_bytes {
810 drop(file);
811 let _ = tokio::fs::remove_file(path).await;
812 return Err(file_too_large_error(self.max_file_size_bytes));
813 }
814 file.write_all(&chunk)
815 .await
816 .map_err(crate::AutumnError::internal_server_error)?;
817 }
818 file.flush()
819 .await
820 .map_err(crate::AutumnError::internal_server_error)?;
821 Ok(written)
822 }
823}
824
825#[cfg(feature = "multipart")]
826fn multipart_rejection_to_error(
827 err: &axum::extract::multipart::MultipartRejection,
828) -> crate::AutumnError {
829 crate::AutumnError::bad_request_msg(err.body_text()).with_status(err.status())
830}
831
832#[cfg(feature = "multipart")]
833#[cfg(all(feature = "multipart", feature = "storage"))]
841fn blob_error_from_multipart(
842 err: &axum::extract::multipart::MultipartError,
843) -> crate::storage::BlobStoreError {
844 let status = err.status();
845 let body = err.body_text();
846 if status == http::StatusCode::PAYLOAD_TOO_LARGE {
847 crate::storage::BlobStoreError::PayloadTooLarge(body)
848 } else if status.is_client_error() {
849 crate::storage::BlobStoreError::InvalidInput(body)
850 } else {
851 crate::storage::BlobStoreError::Io(body)
852 }
853}
854
855#[cfg(feature = "multipart")]
856fn multipart_error_to_error(err: &axum::extract::multipart::MultipartError) -> crate::AutumnError {
857 crate::AutumnError::bad_request_msg(err.body_text()).with_status(err.status())
858}
859
860#[cfg(feature = "multipart")]
861fn file_too_large_error(max_file_size_bytes: usize) -> crate::AutumnError {
862 crate::AutumnError::bad_request_msg(format!(
863 "uploaded file exceeds limit of {max_file_size_bytes} bytes",
864 ))
865 .with_status(http::StatusCode::PAYLOAD_TOO_LARGE)
866}
867
868pub use axum::extract::State;
869
870#[cfg(all(test, feature = "multipart"))]
871mod tests {
872 use super::*;
873 use axum::extract::FromRequest;
874 use axum::http::Request;
875
876 #[tokio::test]
877 async fn test_multipart_field_bytes_limited_success() {
878 let body = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n\r\nhello\r\n--boundary--\r\n";
879 let req = Request::builder()
880 .header("content-type", "multipart/form-data; boundary=boundary")
881 .body(axum::body::Body::from(body))
882 .unwrap();
883
884 let mut multipart = axum::extract::Multipart::from_request(req, &())
885 .await
886 .unwrap();
887 let field = multipart.next_field().await.unwrap().unwrap();
888
889 let wrapper = MultipartField::new(field, 100);
890
891 let bytes = wrapper.bytes_limited().await.unwrap();
892 assert_eq!(bytes, b"hello");
893 }
894
895 #[tokio::test]
896 async fn test_multipart_field_bytes_limited_too_large() {
897 let body = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n\r\nhello world\r\n--boundary--\r\n";
898 let req = Request::builder()
899 .header("content-type", "multipart/form-data; boundary=boundary")
900 .body(axum::body::Body::from(body))
901 .unwrap();
902
903 let mut multipart = axum::extract::Multipart::from_request(req, &())
904 .await
905 .unwrap();
906 let field = multipart.next_field().await.unwrap().unwrap();
907
908 let wrapper = MultipartField::new(field, 5);
909
910 let err = wrapper.bytes_limited().await.unwrap_err();
911 assert_eq!(err.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
912 }
913
914 #[tokio::test]
915 async fn test_multipart_field_save_to_success() {
916 let body = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n\r\nfile content\r\n--boundary--\r\n";
917 let req = Request::builder()
918 .header("content-type", "multipart/form-data; boundary=boundary")
919 .body(axum::body::Body::from(body))
920 .unwrap();
921
922 let mut multipart = axum::extract::Multipart::from_request(req, &())
923 .await
924 .unwrap();
925 let field = multipart.next_field().await.unwrap().unwrap();
926
927 let wrapper = MultipartField::new(field, 100);
928
929 let dir = tempfile::tempdir().unwrap();
930 let file_path = dir.path().join("out.txt");
931
932 let written = wrapper.save_to(&file_path).await.unwrap();
933 assert_eq!(written, 12);
934
935 let content = std::fs::read_to_string(&file_path).unwrap();
936 assert_eq!(content, "file content");
937 }
938
939 #[tokio::test]
940 async fn test_multipart_field_save_to_too_large() {
941 let body = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n\r\nfile content\r\n--boundary--\r\n";
942 let req = Request::builder()
943 .header("content-type", "multipart/form-data; boundary=boundary")
944 .body(axum::body::Body::from(body))
945 .unwrap();
946
947 let mut multipart = axum::extract::Multipart::from_request(req, &())
948 .await
949 .unwrap();
950 let field = multipart.next_field().await.unwrap().unwrap();
951
952 let wrapper = MultipartField::new(field, 4);
953
954 let dir = tempfile::tempdir().unwrap();
955 let file_path = dir.path().join("out_large.txt");
956
957 let err = wrapper.save_to(&file_path).await.unwrap_err();
958 assert_eq!(err.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
959
960 assert!(!file_path.exists());
961 }
962
963 #[cfg(feature = "storage")]
964 #[tokio::test]
965 async fn test_multipart_field_save_to_blob_store_success() {
966 use crate::storage::{BlobStore, LocalBlobStore, local::SigningKey};
967 use std::time::Duration;
968
969 let body = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\nContent-Type: text/plain\r\n\r\nblob content\r\n--boundary--\r\n";
970 let req = Request::builder()
971 .header("content-type", "multipart/form-data; boundary=boundary")
972 .body(axum::body::Body::from(body))
973 .unwrap();
974
975 let mut multipart = axum::extract::Multipart::from_request(req, &())
976 .await
977 .unwrap();
978 let field = multipart.next_field().await.unwrap().unwrap();
979
980 let wrapper = MultipartField::new(field, 100);
981
982 let root = tempfile::tempdir().unwrap();
983 let store = LocalBlobStore::new(
984 "local",
985 root.path(),
986 "/blobs",
987 Duration::from_secs(3600),
988 SigningKey::random(),
989 vec![],
990 )
991 .unwrap();
992
993 let blob = wrapper.save_to_blob_store(&store, "myblob").await.unwrap();
994 assert_eq!(blob.key, "myblob");
995 assert_eq!(blob.content_type, "text/plain");
996
997 let bytes = store.get("myblob").await.unwrap();
998 assert_eq!(&bytes[..], b"blob content");
999 }
1000
1001 #[cfg(feature = "storage")]
1002 #[tokio::test]
1003 async fn test_multipart_field_save_to_blob_store_too_large() {
1004 use crate::storage::{BlobStore, LocalBlobStore, local::SigningKey};
1005 use std::time::Duration;
1006
1007 let body = "--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\nContent-Type: text/plain\r\n\r\nblob content\r\n--boundary--\r\n";
1008 let req = Request::builder()
1009 .header("content-type", "multipart/form-data; boundary=boundary")
1010 .body(axum::body::Body::from(body))
1011 .unwrap();
1012
1013 let mut multipart = axum::extract::Multipart::from_request(req, &())
1014 .await
1015 .unwrap();
1016 let field = multipart.next_field().await.unwrap().unwrap();
1017
1018 let wrapper = MultipartField::new(field, 4); let root = tempfile::tempdir().unwrap();
1021 let store = LocalBlobStore::new(
1022 "local",
1023 root.path(),
1024 "/blobs",
1025 Duration::from_secs(3600),
1026 SigningKey::random(),
1027 vec![],
1028 )
1029 .unwrap();
1030
1031 let err = wrapper
1032 .save_to_blob_store(&store, "myblob")
1033 .await
1034 .unwrap_err();
1035 assert_eq!(err.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
1036
1037 let get_err = store.get("myblob").await.unwrap_err();
1039 assert_eq!(get_err.status(), http::StatusCode::NOT_FOUND);
1040 }
1041
1042 #[tokio::test]
1043 async fn test_multipart_field_metadata() {
1044 let body = "--boundary\r\nContent-Disposition: form-data; name=\"custom_name\"; filename=\"custom_file.png\"\r\nContent-Type: image/png\r\n\r\npng\r\n--boundary--\r\n";
1045 let req = Request::builder()
1046 .header("content-type", "multipart/form-data; boundary=boundary")
1047 .body(axum::body::Body::from(body))
1048 .unwrap();
1049
1050 let mut multipart = axum::extract::Multipart::from_request(req, &())
1051 .await
1052 .unwrap();
1053 let field = multipart.next_field().await.unwrap().unwrap();
1054
1055 let wrapper = MultipartField::new(field, 100);
1056
1057 assert_eq!(wrapper.name(), Some("custom_name"));
1058 assert_eq!(wrapper.file_name(), Some("custom_file.png"));
1059 assert_eq!(wrapper.content_type(), Some("image/png"));
1060
1061 let tighter = wrapper.with_max_bytes(50);
1062 assert_eq!(tighter.max_file_size_bytes, 50);
1063
1064 let not_tighter = tighter.with_max_bytes(200);
1065 assert_eq!(not_tighter.max_file_size_bytes, 50); }
1067
1068 #[test]
1069 fn sniff_content_type_recognizes_png() {
1070 let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00];
1071 assert_eq!(sniff_content_type(&png), Some("image/png"));
1072 }
1073
1074 #[test]
1075 fn sniff_content_type_unknown_is_none() {
1076 assert_eq!(sniff_content_type(&[0x01, 0x02]), None);
1077 assert_eq!(sniff_content_type(&[]), None);
1078 }
1079
1080 #[test]
1081 fn content_type_essence_strips_parameters() {
1082 assert_eq!(
1083 content_type_essence("image/png; charset=binary"),
1084 "image/png"
1085 );
1086 assert_eq!(content_type_essence(" text/csv "), "text/csv");
1087 assert_eq!(content_type_essence("application/json"), "application/json");
1088 assert_eq!(content_type_essence(""), "");
1089 }
1090
1091 #[test]
1092 fn is_signatureless_text_type_is_case_insensitive() {
1093 assert!(is_signatureless_text_type("text/csv"));
1096 assert!(is_signatureless_text_type("Text/Csv"));
1097 assert!(is_signatureless_text_type("TEXT/PLAIN"));
1098 assert!(is_signatureless_text_type("application/json"));
1099 assert!(is_signatureless_text_type("Application/JSON"));
1100 assert!(is_signatureless_text_type("application/csv"));
1101 assert!(!is_signatureless_text_type("image/png"));
1104 assert!(!is_signatureless_text_type("Image/PNG"));
1105 assert!(!is_signatureless_text_type("application/octet-stream"));
1106 assert!(!is_signatureless_text_type("application/pdf"));
1107 }
1108
1109 #[test]
1110 fn prefix_looks_like_markup_detects_leading_angle_bracket() {
1111 assert!(prefix_looks_like_markup(b"<!DOCTYPE html>"));
1112 assert!(prefix_looks_like_markup(b"<svg onload=alert(1)>"));
1113 assert!(prefix_looks_like_markup(b"<?xml version=\"1.0\"?>"));
1114 assert!(prefix_looks_like_markup(b" \n\t<html>"));
1116 assert!(prefix_looks_like_markup(&[0xEF, 0xBB, 0xBF, b'<', b'a']));
1117 assert!(!prefix_looks_like_markup(b"a,b\n1,2"));
1119 assert!(!prefix_looks_like_markup(br#"{"a":1}"#));
1120 assert!(!prefix_looks_like_markup(&[0x01, 0x02]));
1121 assert!(!prefix_looks_like_markup(b""));
1122 }
1123
1124 #[tokio::test]
1125 async fn next_field_exposes_sniffed_content_type_for_genuine_png() {
1126 let mut body: Vec<u8> = Vec::new();
1129 body.extend_from_slice(
1130 b"--boundary\r\nContent-Disposition: form-data; name=\"file\"; \
1131 filename=\"real.png\"\r\nContent-Type: application/octet-stream\r\n\r\n",
1132 );
1133 body.extend_from_slice(&[
1134 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
1135 0x44, 0x52,
1136 ]);
1137 body.extend_from_slice(b"\r\n--boundary--\r\n");
1138
1139 let req = Request::builder()
1140 .header("content-type", "multipart/form-data; boundary=boundary")
1141 .body(axum::body::Body::from(body))
1142 .unwrap();
1143
1144 let inner = axum::extract::Multipart::from_request(req, &())
1145 .await
1146 .unwrap();
1147 let mut multipart = Multipart {
1148 inner,
1149 config: crate::security::config::UploadConfig {
1150 allowed_mime_types: vec!["image/png".to_owned()],
1151 ..crate::security::config::UploadConfig::default()
1152 },
1153 };
1154
1155 let field = multipart.next_field().await.unwrap().unwrap();
1156 assert_eq!(field.sniffed_content_type(), Some("image/png"));
1157 let bytes = field.bytes_limited().await.unwrap();
1159 assert_eq!(bytes.len(), 16);
1160 }
1161}
1162
1163#[derive(Debug, Clone, PartialEq, Eq)]
1184pub struct CurrentPath(pub String);
1185
1186impl CurrentPath {
1187 #[must_use]
1189 pub fn as_str(&self) -> &str {
1190 &self.0
1191 }
1192}
1193
1194impl<S> FromRequestParts<S> for CurrentPath
1195where
1196 S: Send + Sync,
1197{
1198 type Rejection = std::convert::Infallible;
1199
1200 async fn from_request_parts(
1201 parts: &mut axum::http::request::Parts,
1202 state: &S,
1203 ) -> Result<Self, Self::Rejection> {
1204 let axum::extract::OriginalUri(uri) =
1210 axum::extract::OriginalUri::from_request_parts(parts, state)
1211 .await
1212 .unwrap();
1213 Ok(Self(uri.path().to_owned()))
1214 }
1215}
1216
1217use crate::security::trusted_proxies::ResolvedClientIdentity;
1220
1221pub struct ClientAddr(pub std::net::IpAddr);
1240
1241impl ClientAddr {
1242 #[must_use]
1244 pub const fn ip(&self) -> std::net::IpAddr {
1245 self.0
1246 }
1247}
1248
1249impl<S> FromRequestParts<S> for ClientAddr
1250where
1251 S: Send + Sync,
1252{
1253 type Rejection = (axum::http::StatusCode, &'static str);
1254
1255 async fn from_request_parts(
1256 parts: &mut axum::http::request::Parts,
1257 _state: &S,
1258 ) -> Result<Self, Self::Rejection> {
1259 parts
1260 .extensions
1261 .get::<ResolvedClientIdentity>()
1262 .and_then(|id| id.addr)
1263 .map(ClientAddr)
1264 .ok_or((
1265 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1266 "ClientAddr not resolved. Is the TrustedProxiesLayer installed?",
1267 ))
1268 }
1269}
1270
1271impl<S> axum::extract::OptionalFromRequestParts<S> for ClientAddr
1272where
1273 S: Send + Sync,
1274{
1275 type Rejection = std::convert::Infallible;
1276
1277 async fn from_request_parts(
1278 parts: &mut axum::http::request::Parts,
1279 _state: &S,
1280 ) -> Result<Option<Self>, Self::Rejection> {
1281 Ok(parts
1282 .extensions
1283 .get::<ResolvedClientIdentity>()
1284 .and_then(|id| id.addr)
1285 .map(ClientAddr))
1286 }
1287}
1288
1289pub struct ClientHost(pub String);
1298
1299impl ClientHost {
1300 #[must_use]
1302 pub fn as_str(&self) -> &str {
1303 &self.0
1304 }
1305}
1306
1307impl<S> FromRequestParts<S> for ClientHost
1308where
1309 S: Send + Sync,
1310{
1311 type Rejection = (axum::http::StatusCode, &'static str);
1312
1313 async fn from_request_parts(
1314 parts: &mut axum::http::request::Parts,
1315 _state: &S,
1316 ) -> Result<Self, Self::Rejection> {
1317 parts
1318 .extensions
1319 .get::<ResolvedClientIdentity>()
1320 .and_then(|id| id.host.clone())
1321 .map(ClientHost)
1322 .ok_or((
1323 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1324 "ClientHost not resolved. Is the TrustedProxiesLayer installed?",
1325 ))
1326 }
1327}
1328
1329impl<S> axum::extract::OptionalFromRequestParts<S> for ClientHost
1330where
1331 S: Send + Sync,
1332{
1333 type Rejection = std::convert::Infallible;
1334
1335 async fn from_request_parts(
1336 parts: &mut axum::http::request::Parts,
1337 _state: &S,
1338 ) -> Result<Option<Self>, Self::Rejection> {
1339 Ok(parts
1340 .extensions
1341 .get::<ResolvedClientIdentity>()
1342 .and_then(|id| id.host.clone())
1343 .map(ClientHost))
1344 }
1345}
1346
1347pub struct ClientScheme(pub String);
1356
1357impl ClientScheme {
1358 #[must_use]
1360 pub fn as_str(&self) -> &str {
1361 &self.0
1362 }
1363
1364 #[must_use]
1366 pub fn is_https(&self) -> bool {
1367 self.0.eq_ignore_ascii_case("https")
1368 }
1369}
1370
1371impl<S> FromRequestParts<S> for ClientScheme
1372where
1373 S: Send + Sync,
1374{
1375 type Rejection = (axum::http::StatusCode, &'static str);
1376
1377 async fn from_request_parts(
1378 parts: &mut axum::http::request::Parts,
1379 _state: &S,
1380 ) -> Result<Self, Self::Rejection> {
1381 parts
1382 .extensions
1383 .get::<ResolvedClientIdentity>()
1384 .map(|id| Self(id.scheme.clone().unwrap_or_else(|| "http".to_owned())))
1385 .ok_or((
1386 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1387 "ClientScheme not resolved. Is the TrustedProxiesLayer installed?",
1388 ))
1389 }
1390}
1391
1392impl<S> axum::extract::OptionalFromRequestParts<S> for ClientScheme
1393where
1394 S: Send + Sync,
1395{
1396 type Rejection = std::convert::Infallible;
1397
1398 async fn from_request_parts(
1399 parts: &mut axum::http::request::Parts,
1400 _state: &S,
1401 ) -> Result<Option<Self>, Self::Rejection> {
1402 Ok(parts
1403 .extensions
1404 .get::<ResolvedClientIdentity>()
1405 .map(|id| Self(id.scheme.clone().unwrap_or_else(|| "http".to_owned()))))
1406 }
1407}
1408
1409#[cfg(test)]
1410mod trusted_proxy_extractor_tests {
1411 use super::*;
1412 use axum::Router;
1413 use axum::body::Body;
1414 use axum::routing::get;
1415 use tower::ServiceExt;
1416
1417 fn make_identity(addr: &str, host: &str, scheme: &str) -> ResolvedClientIdentity {
1418 ResolvedClientIdentity {
1419 addr: Some(addr.parse().unwrap()),
1420 host: Some(host.to_owned()),
1421 scheme: Some(scheme.to_owned()),
1422 }
1423 }
1424
1425 #[tokio::test]
1426 async fn client_addr_extractor_reads_from_extension() {
1427 async fn handler(ClientAddr(ip): ClientAddr) -> String {
1428 ip.to_string()
1429 }
1430
1431 let app = Router::new().route("/", get(handler));
1432
1433 let mut req = axum::http::Request::builder()
1434 .uri("/")
1435 .body(Body::empty())
1436 .unwrap();
1437 req.extensions_mut()
1438 .insert(make_identity("192.0.2.1", "app.example", "https"));
1439
1440 let resp = app.oneshot(req).await.unwrap();
1441 assert_eq!(resp.status(), axum::http::StatusCode::OK);
1442 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1443 assert_eq!(&body[..], b"192.0.2.1");
1444 }
1445
1446 #[tokio::test]
1447 async fn client_host_extractor_reads_from_extension() {
1448 async fn handler(ClientHost(host): ClientHost) -> String {
1449 host
1450 }
1451
1452 let app = Router::new().route("/", get(handler));
1453
1454 let mut req = axum::http::Request::builder()
1455 .uri("/")
1456 .body(Body::empty())
1457 .unwrap();
1458 req.extensions_mut()
1459 .insert(make_identity("192.0.2.1", "app.example", "https"));
1460
1461 let resp = app.oneshot(req).await.unwrap();
1462 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1463 assert_eq!(&body[..], b"app.example");
1464 }
1465
1466 #[tokio::test]
1467 async fn client_scheme_extractor_reads_from_extension() {
1468 async fn handler(ClientScheme(scheme): ClientScheme) -> String {
1469 scheme
1470 }
1471
1472 let app = Router::new().route("/", get(handler));
1473
1474 let mut req = axum::http::Request::builder()
1475 .uri("/")
1476 .body(Body::empty())
1477 .unwrap();
1478 req.extensions_mut()
1479 .insert(make_identity("192.0.2.1", "app.example", "https"));
1480
1481 let resp = app.oneshot(req).await.unwrap();
1482 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1483 assert_eq!(&body[..], b"https");
1484 }
1485
1486 #[tokio::test]
1487 async fn client_addr_missing_returns_500() {
1488 async fn handler(_: ClientAddr) -> &'static str {
1489 "ok"
1490 }
1491
1492 let app = Router::new().route("/", get(handler));
1493 let req = axum::http::Request::builder()
1494 .uri("/")
1495 .body(Body::empty())
1496 .unwrap();
1497 let resp = app.oneshot(req).await.unwrap();
1498 assert_eq!(resp.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR);
1499 }
1500
1501 #[tokio::test]
1502 async fn optional_client_addr_returns_none_when_missing() {
1503 async fn handler(addr: Option<ClientAddr>) -> String {
1504 if addr.is_some() {
1505 "some".to_owned()
1506 } else {
1507 "none".to_owned()
1508 }
1509 }
1510
1511 let app = Router::new().route("/", get(handler));
1512 let req = axum::http::Request::builder()
1513 .uri("/")
1514 .body(Body::empty())
1515 .unwrap();
1516 let resp = app.oneshot(req).await.unwrap();
1517 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1518 assert_eq!(&body[..], b"none");
1519 }
1520
1521 #[tokio::test]
1522 async fn current_path_extracts_uri_path() {
1523 async fn handler(CurrentPath(path): CurrentPath) -> String {
1524 path
1525 }
1526
1527 let app = Router::new().route("/admin/posts", get(handler));
1528 let req = axum::http::Request::builder()
1529 .uri("/admin/posts")
1530 .body(Body::empty())
1531 .unwrap();
1532 let resp = app.oneshot(req).await.unwrap();
1533 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1534 assert_eq!(&body[..], b"/admin/posts");
1535 }
1536
1537 #[tokio::test]
1538 async fn current_path_strips_query_string() {
1539 async fn handler(CurrentPath(path): CurrentPath) -> String {
1540 path
1541 }
1542
1543 let app = Router::new().route("/admin/posts", get(handler));
1544 let req = axum::http::Request::builder()
1545 .uri("/admin/posts?page=2")
1546 .body(Body::empty())
1547 .unwrap();
1548 let resp = app.oneshot(req).await.unwrap();
1549 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1550 assert_eq!(&body[..], b"/admin/posts");
1551 }
1552
1553 #[tokio::test]
1554 async fn current_path_preserves_prefix_under_nested_router() {
1555 async fn handler(CurrentPath(path): CurrentPath) -> String {
1562 path
1563 }
1564
1565 let inner = Router::new().route("/posts", get(handler));
1566 let app = Router::new().nest("/admin", inner);
1567 let req = axum::http::Request::builder()
1568 .uri("/admin/posts")
1569 .body(Body::empty())
1570 .unwrap();
1571 let resp = app.oneshot(req).await.unwrap();
1572 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
1573 assert_eq!(&body[..], b"/admin/posts");
1574 }
1575}