fix(axum): enforce mutation request boundaries
req: security/003
This commit is contained in:
Generated
+1
@@ -499,6 +499,7 @@ dependencies = [
|
|||||||
"hemx-js",
|
"hemx-js",
|
||||||
"scraper",
|
"scraper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user