fix(axum): enforce mutation request boundaries

req: security/003
This commit is contained in:
slhx agent
2026-07-13 21:37:55 +02:00
parent 3ac9549cc3
commit 56ace537f3
6 changed files with 127 additions and 23 deletions
+58 -20
View File
@@ -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 {