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
Generated
+1
View File
@@ -499,6 +499,7 @@ dependencies = [
"hemx-js",
"scraper",
"tokio",
"tower",
]
[[package]]
+1 -1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -116,6 +116,10 @@ pub struct NewProject {
The browser submits the same form with or without the hemx runtime. Cookies,
SameSite behavior, and credential inclusion remain browser/framework concerns.
`hemx_axum::InteractionRequest` accepts only URL-encoded and multipart forms;
apply Axum's `DefaultBodyLimit` (or a compatible host limit) to every mutation
route. Media-type and size checks run before dispatch, while CSRF remains the
explicit application or middleware check shown below. req: security/003
## Mutation handler
+1
View File
@@ -15,3 +15,4 @@ hemx-js = { path = "../hemx-js" }
[dev-dependencies]
scraper = "0.23"
tokio = { version = "1", features = ["macros", "rt"] }
tower = { version = "0.5", features = ["util"] }
+53 -15
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)
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(|_| InteractionFormRejection::InvalidBody)?;
return Self::parse_multipart(multipart).await;
.map_err(extractor_rejection)?;
Self::parse_multipart(multipart).await
}
let bytes = to_bytes(req.into_body(), 1024 * 1024)
InteractionMediaType::UrlEncoded => {
let bytes = Bytes::from_request(req, state)
.await
.map_err(|_| InteractionFormRejection::InvalidBody)?;
.map_err(extractor_rejection)?;
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
.ok_or(InteractionFormRejection::UnsupportedMediaType)?;
match content_type
.split(';')
.next()
.is_some_and(|mime| mime.trim().eq_ignore_ascii_case("multipart/form-data"))
})
.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 {
+62 -2
View File
@@ -1,6 +1,7 @@
use axum::extract::State;
use axum::http::{header, HeaderMap};
use axum::extract::{DefaultBodyLimit, State};
use axum::http::{header, HeaderMap, Request, StatusCode};
use axum::response::IntoResponse;
use axum::{body::Body, routing::post, Router};
use hemx_axum::{
interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path,
runtime_js_script_src, runtime_js_source, DispatchRejection, EffectResponse, Form,
@@ -10,6 +11,65 @@ use hemx_axum::{
};
use hemx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot};
use scraper::{Html, Selector};
use std::sync::atomic::{AtomicUsize, Ordering};
use tower::ServiceExt;
static MUTATION_CALLS: AtomicUsize = AtomicUsize::new(0);
async fn bounded_mutation(_: InteractionRequest) -> StatusCode {
MUTATION_CALLS.fetch_add(1, Ordering::SeqCst);
StatusCode::NO_CONTENT
}
#[tokio::test]
async fn interaction_boundary_honors_media_type_and_host_body_limit() {
// test req: security/003
MUTATION_CALLS.store(0, Ordering::SeqCst);
let app = Router::new()
.route("/mutate", post(bounded_mutation))
.layer(DefaultBodyLimit::max(32));
let unsupported = app
.clone()
.oneshot(
Request::post("/mutate")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"__h":"1"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(unsupported.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 0);
let oversized = app
.clone()
.oneshot(
Request::post("/mutate")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(format!("__h=1&value={}", "x".repeat(64))))
.unwrap(),
)
.await
.unwrap();
assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 0);
let accepted = app
.oneshot(
Request::post("/mutate")
.header(
header::CONTENT_TYPE,
"application/x-www-form-urlencoded; charset=utf-8",
)
.body(Body::from("__h=1"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(accepted.status(), StatusCode::NO_CONTENT);
assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 1);
}
fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses")