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", "hemx-js",
"scraper", "scraper",
"tokio", "tokio",
"tower",
] ]
[[package]] [[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, The browser submits the same form with or without the hemx runtime. Cookies,
SameSite behavior, and credential inclusion remain browser/framework concerns. 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 ## Mutation handler
+1
View File
@@ -15,3 +15,4 @@ hemx-js = { path = "../hemx-js" }
[dev-dependencies] [dev-dependencies]
scraper = "0.23" scraper = "0.23"
tokio = { version = "1", features = ["macros", "rt"] } 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; pub use axum::extract::State;
use axum::extract::{FromRequest, FromRequestParts, Multipart}; use axum::extract::{FromRequest, FromRequestParts, Multipart};
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode}; use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode};
@@ -151,6 +151,12 @@ pub struct InteractionForm {
files: Vec<InteractionFile>, 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)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct InteractionRequest { pub struct InteractionRequest {
form: InteractionForm, form: InteractionForm,
@@ -232,6 +238,8 @@ pub struct StateHandlerRegistry<S> {
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum InteractionFormRejection { pub enum InteractionFormRejection {
UnsupportedMediaType,
BodyTooLarge,
InvalidBody, InvalidBody,
MissingHandle, MissingHandle,
InvalidHandle, InvalidHandle,
@@ -1227,6 +1235,11 @@ impl DispatchRegistry for HandlerRegistry {
impl IntoResponse for InteractionFormRejection { impl IntoResponse for InteractionFormRejection {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
let (status, message) = match self { 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::InvalidBody => (StatusCode::BAD_REQUEST, "invalid hemx form body"),
Self::MissingHandle => (StatusCode::BAD_REQUEST, "missing __h hemx handle field"), Self::MissingHandle => (StatusCode::BAD_REQUEST, "missing __h hemx handle field"),
Self::InvalidHandle => (StatusCode::BAD_REQUEST, "invalid __h hemx handle field"), Self::InvalidHandle => (StatusCode::BAD_REQUEST, "invalid __h hemx handle field"),
@@ -1254,31 +1267,56 @@ where
{ {
type Rejection = InteractionFormRejection; type Rejection = InteractionFormRejection;
async fn from_request(req: Request<Body>, _state: &S) -> Result<Self, Self::Rejection> { async fn from_request(req: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
if is_multipart(req.headers()) { match interaction_media_type(req.headers())? {
let multipart = Multipart::from_request(req, _state) InteractionMediaType::Multipart => {
let multipart = Multipart::from_request(req, state)
.await .await
.map_err(|_| InteractionFormRejection::InvalidBody)?; .map_err(extractor_rejection)?;
return Self::parse_multipart(multipart).await; Self::parse_multipart(multipart).await
} }
InteractionMediaType::UrlEncoded => {
let bytes = to_bytes(req.into_body(), 1024 * 1024) let bytes = Bytes::from_request(req, state)
.await .await
.map_err(|_| InteractionFormRejection::InvalidBody)?; .map_err(extractor_rejection)?;
Self::parse_urlencoded(&bytes) Self::parse_urlencoded(&bytes)
} }
} }
}
}
fn is_multipart(headers: &HeaderMap) -> bool { #[derive(Clone, Copy, Debug, Eq, PartialEq)]
headers enum InteractionMediaType {
Multipart,
UrlEncoded,
}
fn interaction_media_type(
headers: &HeaderMap,
) -> Result<InteractionMediaType, InteractionFormRejection> {
let content_type = headers
.get(header::CONTENT_TYPE) .get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.is_some_and(|content_type| { .ok_or(InteractionFormRejection::UnsupportedMediaType)?;
content_type match content_type
.split(';') .split(';')
.next() .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 { impl PageMode {
+62 -2
View File
@@ -1,6 +1,7 @@
use axum::extract::State; use axum::extract::{DefaultBodyLimit, State};
use axum::http::{header, HeaderMap}; use axum::http::{header, HeaderMap, Request, StatusCode};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::{body::Body, routing::post, Router};
use hemx_axum::{ use hemx_axum::{
interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path, interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path,
runtime_js_script_src, runtime_js_source, DispatchRejection, EffectResponse, Form, 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 hemx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot};
use scraper::{Html, Selector}; 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 { fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses") Selector::parse(value).expect("test selector parses")