laterite_admin/upload.rs
1//! Reading an uploaded form, with its request token checked on the way in.
2//!
3//! An ordinary admin form carries its token in a body the guard reads before the
4//! handler runs. An upload cannot work that way: reading the body to find one
5//! field would mean holding a whole file in memory, which is the thing streaming
6//! ingest exists to avoid. The reference system never faces this, because its
7//! runtime parses the form before the handler is reached.
8//!
9//! So the token travels in the body as usual, and this extractor checks it while
10//! parsing rather than before. Two rules keep that honest:
11//!
12//! - **The token must be the first field.** A later one would mean buffering
13//! everything ahead of it, and the whole point is not to.
14//! - **A handler cannot skip the check.** The guard marks the check outstanding
15//! and refuses the response if it is still outstanding when the handler
16//! returns, so taking the upload with a bare parser fails loudly rather than
17//! running unguarded.
18
19use axum::extract::{FromRequest, Multipart, Request};
20use axum::response::{IntoResponse, Response};
21
22use crate::error::csrf_rejected;
23use crate::session::{self, SessionHandle};
24
25/// An upload whose request token has been verified.
26///
27/// Obtain fields from it exactly as from `axum::extract::Multipart`; the first
28/// field has already been consumed and checked.
29pub struct VerifiedUpload {
30 inner: Multipart,
31}
32
33impl VerifiedUpload {
34 /// The next form field, or `None` at the end.
35 pub async fn next_field(
36 &mut self,
37 ) -> Result<Option<axum::extract::multipart::Field<'_>>, axum::extract::multipart::MultipartError>
38 {
39 self.inner.next_field().await
40 }
41}
42
43impl<S> FromRequest<S> for VerifiedUpload
44where
45 S: Send + Sync,
46{
47 type Rejection = Response;
48
49 async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
50 // The guard put these here: the session's token to compare against, and
51 // the marker it will check after the handler returns.
52 let expected = req
53 .extensions()
54 .get::<SessionHandle>()
55 .map(|h| h.csrf_token())
56 .unwrap_or_default();
57 let pending = req.extensions().get::<session::CsrfPending>().cloned();
58
59 let mut inner = Multipart::from_request(req, state)
60 .await
61 .map_err(|e| e.into_response())?;
62
63 // Already checked beside the body (a header, or the action's query
64 // string), so the first field is the handler's to read.
65 let Some(pending) = pending else {
66 return Ok(Self { inner });
67 };
68
69 let submitted = match inner.next_field().await {
70 Ok(Some(field)) if field.name() == Some(session::CSRF_FIELD) => field.text().await.ok(),
71 // Anything else means the token was not where it has to be. Reading
72 // on to look for it would buffer the upload, so this is refused.
73 _ => None,
74 };
75
76 if !session::token_matches(&expected, submitted.as_deref()) {
77 tracing::warn!("upload rejected: request token missing or wrong");
78 return Err(csrf_rejected());
79 }
80 pending.satisfy();
81 Ok(Self { inner })
82 }
83}