fix(axum): enforce mutation request boundaries
req: security/003
This commit is contained in:
+58
-20
@@ -1,4 +1,4 @@
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::body::{Body, Bytes};
|
||||
pub use axum::extract::State;
|
||||
use axum::extract::{FromRequest, FromRequestParts, Multipart};
|
||||
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode};
|
||||
@@ -151,6 +151,12 @@ pub struct InteractionForm {
|
||||
files: Vec<InteractionFile>,
|
||||
}
|
||||
|
||||
/// A validated hemx mutation request.
|
||||
///
|
||||
/// Only `application/x-www-form-urlencoded` and `multipart/form-data` are
|
||||
/// accepted. Body size is intentionally host policy: apply Axum's
|
||||
/// [`axum::extract::DefaultBodyLimit`] (or a compatible request-body limit)
|
||||
/// to the mutation route; limit rejections become HTTP 413 before dispatch.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct InteractionRequest {
|
||||
form: InteractionForm,
|
||||
@@ -232,6 +238,8 @@ pub struct StateHandlerRegistry<S> {
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum InteractionFormRejection {
|
||||
UnsupportedMediaType,
|
||||
BodyTooLarge,
|
||||
InvalidBody,
|
||||
MissingHandle,
|
||||
InvalidHandle,
|
||||
@@ -1227,6 +1235,11 @@ impl DispatchRegistry for HandlerRegistry {
|
||||
impl IntoResponse for InteractionFormRejection {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let (status, message) = match self {
|
||||
Self::UnsupportedMediaType => (
|
||||
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
"hemx interactions require application/x-www-form-urlencoded or multipart/form-data",
|
||||
),
|
||||
Self::BodyTooLarge => (StatusCode::PAYLOAD_TOO_LARGE, "hemx interaction body exceeds the host limit"),
|
||||
Self::InvalidBody => (StatusCode::BAD_REQUEST, "invalid hemx form body"),
|
||||
Self::MissingHandle => (StatusCode::BAD_REQUEST, "missing __h hemx handle field"),
|
||||
Self::InvalidHandle => (StatusCode::BAD_REQUEST, "invalid __h hemx handle field"),
|
||||
@@ -1254,31 +1267,56 @@ where
|
||||
{
|
||||
type Rejection = InteractionFormRejection;
|
||||
|
||||
async fn from_request(req: Request<Body>, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
if is_multipart(req.headers()) {
|
||||
let multipart = Multipart::from_request(req, _state)
|
||||
.await
|
||||
.map_err(|_| InteractionFormRejection::InvalidBody)?;
|
||||
return Self::parse_multipart(multipart).await;
|
||||
async fn from_request(req: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
|
||||
match interaction_media_type(req.headers())? {
|
||||
InteractionMediaType::Multipart => {
|
||||
let multipart = Multipart::from_request(req, state)
|
||||
.await
|
||||
.map_err(extractor_rejection)?;
|
||||
Self::parse_multipart(multipart).await
|
||||
}
|
||||
InteractionMediaType::UrlEncoded => {
|
||||
let bytes = Bytes::from_request(req, state)
|
||||
.await
|
||||
.map_err(extractor_rejection)?;
|
||||
Self::parse_urlencoded(&bytes)
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = to_bytes(req.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.map_err(|_| InteractionFormRejection::InvalidBody)?;
|
||||
Self::parse_urlencoded(&bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_multipart(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum InteractionMediaType {
|
||||
Multipart,
|
||||
UrlEncoded,
|
||||
}
|
||||
|
||||
fn interaction_media_type(
|
||||
headers: &HeaderMap,
|
||||
) -> Result<InteractionMediaType, InteractionFormRejection> {
|
||||
let content_type = headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|content_type| {
|
||||
content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.is_some_and(|mime| mime.trim().eq_ignore_ascii_case("multipart/form-data"))
|
||||
})
|
||||
.ok_or(InteractionFormRejection::UnsupportedMediaType)?;
|
||||
match content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref()
|
||||
{
|
||||
Some("multipart/form-data") => Ok(InteractionMediaType::Multipart),
|
||||
Some("application/x-www-form-urlencoded") => Ok(InteractionMediaType::UrlEncoded),
|
||||
_ => Err(InteractionFormRejection::UnsupportedMediaType),
|
||||
}
|
||||
}
|
||||
|
||||
fn extractor_rejection(rejection: impl IntoResponse) -> InteractionFormRejection {
|
||||
if rejection.into_response().status() == StatusCode::PAYLOAD_TOO_LARGE {
|
||||
InteractionFormRejection::BodyTooLarge
|
||||
} else {
|
||||
InteractionFormRejection::InvalidBody
|
||||
}
|
||||
}
|
||||
|
||||
impl PageMode {
|
||||
|
||||
Reference in New Issue
Block a user