From fbff3e80ed9bdaf2fde607515ecc88a8a8c83ab3 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Sun, 12 Jul 2026 22:30:25 -0700 Subject: [PATCH 1/3] feat(sts): accept AssumeRoleWithWebIdentity params from form-encoded POST bodies AWS STS accepts parameters in the query string or an application/x-www-form-urlencoded POST body; SDKs send the latter, so the query-only parse made /.sts unreachable from unmodified AWS SDK STS clients and the SDKs' built-in web-identity credential providers. - core: RequestInfo gains an optional form_body field runtimes populate for form-encoded POSTs, plus an is_form_urlencoded_post() predicate so every runtime shares one definition of when to collect it. Form POSTs are not part of the S3 protocol, so collecting them never steals a payload the data path needs. - sts: try_handle_sts checks the query first, then form_body; params are read from exactly one source, never merged (matching AWS). - cf-workers: RequestParts::absorb_form_body() collects such bodies before dispatch and hands back an empty JsBody. - examples: all three runtimes (cf-workers, server, lambda) wire the form body up before building RequestInfo. Refs source-cooperative/data.source.coop#184 --- Cargo.lock | 20 ++++---- crates/cf-workers/src/request.rs | 31 ++++++++++++ crates/core/src/route_handler.rs | 81 ++++++++++++++++++++++++++++++++ crates/core/src/router.rs | 1 + crates/sts/src/lib.rs | 11 ++++- crates/sts/src/request.rs | 11 +++-- crates/sts/src/route_handler.rs | 47 ++++++++++++++++-- docs/auth/proxy-auth.md | 7 +++ docs/reference/operations.md | 2 +- examples/cf-workers/src/lib.rs | 9 +++- examples/lambda/src/main.rs | 22 ++++++++- examples/server/src/server.rs | 28 ++++++++++- 12 files changed, 244 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 67b6d3e..472f486 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1121,7 +1121,7 @@ dependencies = [ [[package]] name = "multistore" -version = "0.6.3" +version = "0.6.4" dependencies = [ "async-trait", "base64", @@ -1147,7 +1147,7 @@ dependencies = [ [[package]] name = "multistore-cf-workers" -version = "0.6.3" +version = "0.6.4" dependencies = [ "async-trait", "bytes", @@ -1170,7 +1170,7 @@ dependencies = [ [[package]] name = "multistore-cf-workers-example" -version = "0.6.3" +version = "0.6.4" dependencies = [ "bytes", "console_error_panic_hook", @@ -1194,7 +1194,7 @@ dependencies = [ [[package]] name = "multistore-lambda" -version = "0.6.3" +version = "0.6.4" dependencies = [ "bytes", "http", @@ -1214,7 +1214,7 @@ dependencies = [ [[package]] name = "multistore-metering" -version = "0.6.3" +version = "0.6.4" dependencies = [ "bytes", "futures", @@ -1226,7 +1226,7 @@ dependencies = [ [[package]] name = "multistore-oidc-provider" -version = "0.6.3" +version = "0.6.4" dependencies = [ "base64", "chrono", @@ -1246,7 +1246,7 @@ dependencies = [ [[package]] name = "multistore-path-mapping" -version = "0.6.3" +version = "0.6.4" dependencies = [ "multistore", "percent-encoding", @@ -1255,7 +1255,7 @@ dependencies = [ [[package]] name = "multistore-server" -version = "0.6.3" +version = "0.6.4" dependencies = [ "axum", "bytes", @@ -1277,7 +1277,7 @@ dependencies = [ [[package]] name = "multistore-static-config" -version = "0.6.3" +version = "0.6.4" dependencies = [ "chrono", "multistore", @@ -1289,7 +1289,7 @@ dependencies = [ [[package]] name = "multistore-sts" -version = "0.6.3" +version = "0.6.4" dependencies = [ "aes-gcm", "base64", diff --git a/crates/cf-workers/src/request.rs b/crates/cf-workers/src/request.rs index d79358e..5172969 100644 --- a/crates/cf-workers/src/request.rs +++ b/crates/cf-workers/src/request.rs @@ -46,6 +46,12 @@ pub struct RequestParts { pub query: Option, /// The HTTP request headers. pub headers: HeaderMap, + /// The collected form-encoded body of an + /// `application/x-www-form-urlencoded` `POST`, populated by + /// [`absorb_form_body`](Self::absorb_form_body). AWS SDKs send + /// query-protocol operations (STS `AssumeRoleWithWebIdentity`) this way, + /// so without it the STS route handler never sees SDK requests. + pub form_body: Option, } impl RequestParts { @@ -81,11 +87,35 @@ impl RequestParts { signing_path, query, headers, + form_body: None, }, body, )) } + /// Collect a form-encoded `POST` body into [`form_body`](Self::form_body), + /// returning the (now empty) body to pass on to the gateway. + /// + /// A no-op passthrough for every other request shape, so integrators can + /// call it unconditionally between [`from_web_sys`](Self::from_web_sys) + /// and dispatch: + /// + /// ```rust,ignore + /// let (mut parts, mut body) = RequestParts::from_web_sys(&req)?; + /// body = parts.absorb_form_body(body).await?; + /// ``` + /// + /// Form-encoded `POST`s are not part of the S3 protocol, so consuming the + /// stream here never steals a payload the forwarding path needs. + pub async fn absorb_form_body(&mut self, body: JsBody) -> Result { + if !self.as_request_info().is_form_urlencoded_post() { + return Ok(body); + } + let bytes = crate::body::collect_js_body(body).await?; + self.form_body = Some(String::from_utf8_lossy(&bytes).into_owned()); + Ok(JsBody::new(None)) + } + /// Borrow this struct as a [`RequestInfo`] for gateway dispatch. /// /// Sets the signing path to the raw, percent-encoded @@ -102,5 +132,6 @@ impl RequestParts { None, ) .with_signing_path(&self.signing_path) + .with_form_body(self.form_body.as_deref()) } } diff --git a/crates/core/src/route_handler.rs b/crates/core/src/route_handler.rs index 89a8565..26c1595 100644 --- a/crates/core/src/route_handler.rs +++ b/crates/core/src/route_handler.rs @@ -257,6 +257,19 @@ pub struct RequestInfo<'a> { /// When `None`, `query` is used for both operation parsing and signature /// verification. pub signing_query: Option<&'a str>, + /// The form-encoded request body, for handlers that accept parameters in + /// the body as well as the query string. + /// + /// AWS query-protocol operations (STS `AssumeRoleWithWebIdentity` in + /// particular) are sent by AWS SDKs as `POST` requests with an + /// `application/x-www-form-urlencoded` body instead of a query string, so + /// a body-blind dispatch can never serve unmodified SDK clients. Runtimes + /// that want SDK compatibility should collect the body of such requests + /// (see [`is_form_urlencoded_post`](Self::is_form_urlencoded_post)) and + /// attach it here via + /// [`with_form_body`](Self::with_form_body); the S3 data path never uses + /// form-encoded bodies, so this is `None` for every other request shape. + pub form_body: Option<&'a str>, } impl<'a> RequestInfo<'a> { @@ -277,9 +290,43 @@ impl<'a> RequestInfo<'a> { params: Params::default(), signing_path: None, signing_query: None, + form_body: None, } } + /// Attach a form-encoded request body for handlers that accept parameters + /// in the body (AWS query-protocol operations like STS). See + /// [`RequestInfo::form_body`]. + pub fn with_form_body(mut self, form_body: Option<&'a str>) -> Self { + self.form_body = form_body; + self + } + + /// Whether this request is a `POST` with an + /// `application/x-www-form-urlencoded` body — the shape AWS SDKs use for + /// query-protocol operations like STS `AssumeRoleWithWebIdentity`. + /// + /// Runtimes use this to decide when to collect the body and attach it via + /// [`with_form_body`](Self::with_form_body). The check ignores any + /// `; charset=...` parameter on the content type. Form-encoded `POST`s are + /// not part of the S3 protocol, so collecting such a body never steals a + /// payload the data path needs. + pub fn is_form_urlencoded_post(&self) -> bool { + self.method == Method::POST + && self + .headers + .get(http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|v| { + v.split(';') + .next() + .unwrap_or("") + .trim() + .eq_ignore_ascii_case("application/x-www-form-urlencoded") + }) + .unwrap_or(false) + } + /// Set the original client-facing path for SigV4 signature verification. /// /// Use this when the proxy rewrites paths (e.g. path-mapping) so that @@ -453,4 +500,38 @@ mod tests { let filtered = filter_response_headers(&headers); assert!(filtered.is_empty()); } + + #[test] + fn test_is_form_urlencoded_post() { + let form = |ct: &str| { + let mut headers = http::HeaderMap::new(); + headers.insert("content-type", ct.parse().unwrap()); + headers + }; + + let headers = form("application/x-www-form-urlencoded"); + assert!( + RequestInfo::new(&Method::POST, "/", None, &headers, None).is_form_urlencoded_post() + ); + // Charset parameter and casing are ignored. + let headers = form("Application/X-WWW-Form-Urlencoded; charset=UTF-8"); + assert!( + RequestInfo::new(&Method::POST, "/", None, &headers, None).is_form_urlencoded_post() + ); + // Wrong method. + let headers = form("application/x-www-form-urlencoded"); + assert!( + !RequestInfo::new(&Method::GET, "/", None, &headers, None).is_form_urlencoded_post() + ); + // Wrong content type (S3 batch delete is a POST with XML). + let headers = form("application/xml"); + assert!( + !RequestInfo::new(&Method::POST, "/", None, &headers, None).is_form_urlencoded_post() + ); + // No content type at all. + let headers = http::HeaderMap::new(); + assert!( + !RequestInfo::new(&Method::POST, "/", None, &headers, None).is_form_urlencoded_post() + ); + } } diff --git a/crates/core/src/router.rs b/crates/core/src/router.rs index b6bcc44..0bc0939 100644 --- a/crates/core/src/router.rs +++ b/crates/core/src/router.rs @@ -68,6 +68,7 @@ impl Router { source_ip: req.source_ip, signing_path: req.signing_path, signing_query: req.signing_query, + form_body: req.form_body, }; matched .value diff --git a/crates/sts/src/lib.rs b/crates/sts/src/lib.rs index 89f53d8..d18c205 100644 --- a/crates/sts/src/lib.rs +++ b/crates/sts/src/lib.rs @@ -43,18 +43,25 @@ pub use responses::{build_sts_error_response, build_sts_response}; pub use sealed_token::TokenKey; /// Try to handle an STS request. Returns `Some((status, xml))` if the query -/// contained an STS action, or `None` if it wasn't an STS request. +/// string or form-encoded body contained an STS action, or `None` if it +/// wasn't an STS request. +/// +/// AWS STS accepts `AssumeRoleWithWebIdentity` parameters either in the query +/// string or as an `application/x-www-form-urlencoded` `POST` body — SDKs send +/// the latter — so both are checked, query first. Parameters are read from +/// exactly one source, never merged across the two. /// /// Requires a `TokenKey` — minted credentials are encrypted into the session /// token itself, so no server-side storage is needed. If `token_key` is `None` /// and an STS request arrives, an error response is returned. pub async fn try_handle_sts( query: Option<&str>, + form_body: Option<&str>, config: &C, jwks_cache: &JwksCache, token_key: Option<&TokenKey>, ) -> Option<(u16, String)> { - let sts_result = try_parse_sts_request(query)?; + let sts_result = try_parse_sts_request(query).or_else(|| try_parse_sts_request(form_body))?; let (status, xml) = match sts_result { Ok(sts_request) => { let Some(key) = token_key else { diff --git a/crates/sts/src/request.rs b/crates/sts/src/request.rs index 9d28e1e..ab41703 100644 --- a/crates/sts/src/request.rs +++ b/crates/sts/src/request.rs @@ -1,6 +1,9 @@ //! STS request parsing. //! -//! Extracts `AssumeRoleWithWebIdentity` parameters from query strings. +//! Extracts `AssumeRoleWithWebIdentity` parameters from a form-urlencoded +//! parameter string — a query string, or the body of an +//! `application/x-www-form-urlencoded` `POST` (the shape AWS SDKs send); +//! `try_handle_sts` checks both. use multistore::error::ProxyError; @@ -15,9 +18,11 @@ pub struct StsRequest { pub duration_seconds: Option, } -/// Try to parse an STS request from the query string. +/// Try to parse an STS request from a form-urlencoded parameter string — a +/// query string, or a form-encoded `POST` body (the two places AWS STS accepts +/// parameters). /// -/// Returns `None` if the query does not contain `Action=AssumeRoleWithWebIdentity` +/// Returns `None` if the string does not contain `Action=AssumeRoleWithWebIdentity` /// (i.e., this is not an STS request). Returns `Some(Ok(..))` on success or /// `Some(Err(..))` if it is an STS request but required parameters are missing. pub fn try_parse_sts_request(query: Option<&str>) -> Option> { diff --git a/crates/sts/src/route_handler.rs b/crates/sts/src/route_handler.rs index 298e57f..426d65a 100644 --- a/crates/sts/src/route_handler.rs +++ b/crates/sts/src/route_handler.rs @@ -18,8 +18,14 @@ struct StsHandler { impl RouteHandler for StsHandler { fn handle<'a>(&'a self, req: &'a RequestInfo<'a>) -> RouteHandlerFuture<'a> { Box::pin(async move { - let (status, xml) = - try_handle_sts(req.query, &self.config, &self.cache, self.key.as_ref()).await?; + let (status, xml) = try_handle_sts( + req.query, + req.form_body, + &self.config, + &self.cache, + self.key.as_ref(), + ) + .await?; Some(ProxyResult::xml(status, xml)) }) } @@ -29,9 +35,12 @@ impl RouteHandler for StsHandler { pub trait StsRouterExt { /// Register the STS handler on the given `path`. /// - /// STS requests are identified by query parameters - /// (`Action=AssumeRoleWithWebIdentity`), not by path, so any path - /// can be used (e.g. `"/"` or `"/.sts"`). + /// STS requests are identified by their parameters + /// (`Action=AssumeRoleWithWebIdentity`) — in the query string or, as AWS + /// SDKs send them, in a form-encoded `POST` body surfaced via + /// [`RequestInfo::form_body`] — not by path, so any path can be used + /// (e.g. `"/"` or `"/.sts"`). Runtimes must populate `form_body` for + /// form-encoded `POST`s or SDK clients will fall through unhandled. fn with_sts( self, path: &str, @@ -107,4 +116,32 @@ mod tests { "non-STS request to / must fall through" ); } + + #[tokio::test] + async fn sts_form_body_post_is_handled() { + // AWS SDKs send AssumeRoleWithWebIdentity as a form-encoded POST body + // with no query string at all. + let router = test_router(); + let headers = http::HeaderMap::new(); + let req = RequestInfo::new(&http::Method::POST, "/", None, &headers, None) + .with_form_body(Some( + "Action=AssumeRoleWithWebIdentity&Version=2011-06-15&RoleArn=test&WebIdentityToken=tok", + )); + assert!( + router.dispatch(&req).await.is_some(), + "STS form-body POST must be intercepted by the router" + ); + } + + #[tokio::test] + async fn non_sts_form_body_falls_through() { + let router = test_router(); + let headers = http::HeaderMap::new(); + let req = RequestInfo::new(&http::Method::POST, "/", None, &headers, None) + .with_form_body(Some("grant_type=client_credentials")); + assert!( + router.dispatch(&req).await.is_none(), + "non-STS form body must fall through" + ); + } } diff --git a/docs/auth/proxy-auth.md b/docs/auth/proxy-auth.md index 96df564..f374cc3 100644 --- a/docs/auth/proxy-auth.md +++ b/docs/auth/proxy-auth.md @@ -91,6 +91,13 @@ When a client calls `AssumeRoleWithWebIdentity`: ### STS Request Parameters +Parameters are accepted either in the query string or as an +`application/x-www-form-urlencoded` `POST` body — the latter is how AWS SDKs +send them, so unmodified SDK STS clients (e.g. a client constructed with a +custom `endpoint_url`, or the SDK's built-in web-identity credential provider) +work against the proxy. Parameters are read from exactly one source: the query +string wins if it contains an STS `Action`. + | Parameter | Required | Description | |-----------|----------|-------------| | `Action` | Yes | Must be `AssumeRoleWithWebIdentity` | diff --git a/docs/reference/operations.md b/docs/reference/operations.md index c2b2a5a..dc5416b 100644 --- a/docs/reference/operations.md +++ b/docs/reference/operations.md @@ -36,7 +36,7 @@ Handled by an STS closure (registered on the `Router` via `StsRouterExt`). | Operation | HTTP Method | Description | |-----------|------------|-------------| -| AssumeRoleWithWebIdentity | `POST /?Action=AssumeRoleWithWebIdentity&...` | Exchange OIDC JWT for temporary credentials | +| AssumeRoleWithWebIdentity | `POST /?Action=AssumeRoleWithWebIdentity&...` (params in the query string or a form-encoded body, as AWS SDKs send them) | Exchange OIDC JWT for temporary credentials | ## OIDC Discovery Endpoints diff --git a/examples/cf-workers/src/lib.rs b/examples/cf-workers/src/lib.rs index 6514450..9edc9aa 100644 --- a/examples/cf-workers/src/lib.rs +++ b/examples/cf-workers/src/lib.rs @@ -105,9 +105,16 @@ async fn fetch(req: web_sys::Request, env: Env, _ctx: Context) -> Result Result<(), Error> { async fn request_handler(req: Request) -> Result, Error> { let state = STATE.get().expect("state not initialized"); - let (parts, body) = req.into_parts(); + let (parts, mut body) = req.into_parts(); let method = parts.method; let uri = parts.uri; let path = uri.path().to_string(); @@ -121,7 +121,25 @@ async fn request_handler(req: Request) -> Result, Error> { tracing::debug!(method = %method, uri = %uri, "incoming request"); - let req_info = RequestInfo::new(&method, &path, query.as_deref(), &headers, None); + // AWS SDKs send STS AssumeRoleWithWebIdentity as a form-encoded POST body + // rather than query parameters; collect it so the STS handler sees it. + // Lambda bodies are already buffered, so this is a cheap move. + let form_body = if RequestInfo::new(&method, &path, query.as_deref(), &headers, None) + .is_form_urlencoded_post() + { + let text = match &body { + Body::Text(s) => s.clone(), + Body::Binary(b) => String::from_utf8_lossy(b).into_owned(), + Body::Empty => String::new(), + }; + body = Body::Empty; + Some(text) + } else { + None + }; + + let req_info = RequestInfo::new(&method, &path, query.as_deref(), &headers, None) + .with_form_body(form_body.as_deref()); Ok( match state diff --git a/examples/server/src/server.rs b/examples/server/src/server.rs index 5d671e0..ae4ea3f 100644 --- a/examples/server/src/server.rs +++ b/examples/server/src/server.rs @@ -147,7 +147,7 @@ async fn request_handler( State(state): State>>, req: axum::extract::Request, ) -> Response { - let (parts, body) = req.into_parts(); + let (parts, mut body) = req.into_parts(); let method = parts.method; let uri = parts.uri; let path = uri.path().to_string(); @@ -160,7 +160,31 @@ async fn request_handler( "incoming request" ); - let req_info = RequestInfo::new(&method, &path, query.as_deref(), &headers, None); + // AWS SDKs send STS AssumeRoleWithWebIdentity as a form-encoded POST body + // rather than query parameters; collect it so the STS handler sees it. + // Form bodies carry a JWT and a few short params, so 64 KiB is generous. + const FORM_BODY_MAX_BYTES: usize = 64 * 1024; + let form_body = if RequestInfo::new(&method, &path, query.as_deref(), &headers, None) + .is_form_urlencoded_post() + { + match axum::body::to_bytes(body, FORM_BODY_MAX_BYTES).await { + Ok(bytes) => { + body = Body::empty(); + Some(String::from_utf8_lossy(&bytes).into_owned()) + } + Err(_) => { + return Response::builder() + .status(400) + .body(Body::from("form body too large or unreadable")) + .unwrap(); + } + } + } else { + None + }; + + let req_info = RequestInfo::new(&method, &path, query.as_deref(), &headers, None) + .with_form_body(form_body.as_deref()); match state .handler From c094faa9474534f8d01da80d0439fc1db78f7750 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 15 Jul 2026 05:57:47 -0700 Subject: [PATCH 2/3] fix(cf-workers): bound form body collection by Content-Length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit absorb_form_body materialized any form-encoded POST body into WASM memory with no size cap, running before the gateway's max_request_body_size guard. Require a parseable Content-Length at or under 64 KiB before collecting — SDK STS clients always send one, and a chunked body with no declared length could be arbitrarily large. Also build RequestInfo once per request in the server and lambda examples instead of twice (review nit). Co-Authored-By: Claude Fable 5 --- crates/cf-workers/src/request.rs | 58 ++++++++++++++++++++++++++++++++ examples/lambda/src/main.rs | 8 ++--- examples/server/src/server.rs | 8 ++--- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/crates/cf-workers/src/request.rs b/crates/cf-workers/src/request.rs index 5172969..36a2d00 100644 --- a/crates/cf-workers/src/request.rs +++ b/crates/cf-workers/src/request.rs @@ -9,6 +9,21 @@ use crate::response::headermap_from_js; use http::{HeaderMap, Method, Uri}; use multistore::route_handler::RequestInfo; +/// Maximum collected form body size. STS `AssumeRoleWithWebIdentity` bodies +/// are a JWT plus a few short parameters, so 64 KiB is generous. +pub const FORM_BODY_MAX_BYTES: usize = 64 * 1024; + +/// Whether the declared `Content-Length` permits collecting the body into +/// WASM memory. Absent or unparseable lengths are rejected — the body could +/// be arbitrarily large. +fn form_body_within_limit(headers: &HeaderMap) -> bool { + headers + .get(http::header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .is_some_and(|len| len <= FORM_BODY_MAX_BYTES) +} + /// Owned HTTP request metadata extracted from a `web_sys::Request`. /// /// Workers passes a `web_sys::Request` with borrowed JS strings and a @@ -107,10 +122,21 @@ impl RequestParts { /// /// Form-encoded `POST`s are not part of the S3 protocol, so consuming the /// stream here never steals a payload the forwarding path needs. + /// + /// # Errors + /// + /// Collecting materializes the body into WASM memory, so it is bounded + /// *before* reading: a form `POST` whose `Content-Length` is missing, + /// unparseable, or above [`FORM_BODY_MAX_BYTES`] is rejected. SDK STS + /// clients always send an accurate `Content-Length`; a chunked body with + /// no declared length could be arbitrarily large. pub async fn absorb_form_body(&mut self, body: JsBody) -> Result { if !self.as_request_info().is_form_urlencoded_post() { return Ok(body); } + if !form_body_within_limit(&self.headers) { + return Err("form body too large or missing Content-Length".into()); + } let bytes = crate::body::collect_js_body(body).await?; self.form_body = Some(String::from_utf8_lossy(&bytes).into_owned()); Ok(JsBody::new(None)) @@ -135,3 +161,35 @@ impl RequestParts { .with_form_body(self.form_body.as_deref()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn headers_with_len(len: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert(http::header::CONTENT_LENGTH, len.parse().unwrap()); + h + } + + #[test] + fn form_body_limit_accepts_small_declared_length() { + assert!(form_body_within_limit(&headers_with_len("1024"))); + assert!(form_body_within_limit(&headers_with_len( + &FORM_BODY_MAX_BYTES.to_string() + ))); + } + + #[test] + fn form_body_limit_rejects_oversized_declared_length() { + assert!(!form_body_within_limit(&headers_with_len( + &(FORM_BODY_MAX_BYTES + 1).to_string() + ))); + } + + #[test] + fn form_body_limit_rejects_missing_or_bad_content_length() { + assert!(!form_body_within_limit(&HeaderMap::new())); + assert!(!form_body_within_limit(&headers_with_len("not-a-number"))); + } +} diff --git a/examples/lambda/src/main.rs b/examples/lambda/src/main.rs index f15365a..788eac7 100644 --- a/examples/lambda/src/main.rs +++ b/examples/lambda/src/main.rs @@ -124,9 +124,8 @@ async fn request_handler(req: Request) -> Result, Error> { // AWS SDKs send STS AssumeRoleWithWebIdentity as a form-encoded POST body // rather than query parameters; collect it so the STS handler sees it. // Lambda bodies are already buffered, so this is a cheap move. - let form_body = if RequestInfo::new(&method, &path, query.as_deref(), &headers, None) - .is_form_urlencoded_post() - { + let req_info = RequestInfo::new(&method, &path, query.as_deref(), &headers, None); + let form_body = if req_info.is_form_urlencoded_post() { let text = match &body { Body::Text(s) => s.clone(), Body::Binary(b) => String::from_utf8_lossy(b).into_owned(), @@ -138,8 +137,7 @@ async fn request_handler(req: Request) -> Result, Error> { None }; - let req_info = RequestInfo::new(&method, &path, query.as_deref(), &headers, None) - .with_form_body(form_body.as_deref()); + let req_info = req_info.with_form_body(form_body.as_deref()); Ok( match state diff --git a/examples/server/src/server.rs b/examples/server/src/server.rs index ae4ea3f..4c7d743 100644 --- a/examples/server/src/server.rs +++ b/examples/server/src/server.rs @@ -164,9 +164,8 @@ async fn request_handler( // rather than query parameters; collect it so the STS handler sees it. // Form bodies carry a JWT and a few short params, so 64 KiB is generous. const FORM_BODY_MAX_BYTES: usize = 64 * 1024; - let form_body = if RequestInfo::new(&method, &path, query.as_deref(), &headers, None) - .is_form_urlencoded_post() - { + let req_info = RequestInfo::new(&method, &path, query.as_deref(), &headers, None); + let form_body = if req_info.is_form_urlencoded_post() { match axum::body::to_bytes(body, FORM_BODY_MAX_BYTES).await { Ok(bytes) => { body = Body::empty(); @@ -183,8 +182,7 @@ async fn request_handler( None }; - let req_info = RequestInfo::new(&method, &path, query.as_deref(), &headers, None) - .with_form_body(form_body.as_deref()); + let req_info = req_info.with_form_body(form_body.as_deref()); match state .handler From d16727bf051314a53a688b3821323735ed4c1287 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 15 Jul 2026 06:21:17 -0700 Subject: [PATCH 3/3] fix(sts): never consume the body of a form-labeled POST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content-Type is client-controlled, so a POST carrying application/x-www-form-urlencoded is not necessarily STS — a mislabeled S3 CompleteMultipartUpload/DeleteObjects would previously have its XML body drained into form_body and destroyed, failing the operation with no way to recover the payload. All three runtimes now hand the same bytes downstream: cf-workers rebuilds the stream via JsBody::from_bytes, axum rebuilds Body::from(bytes), and lambda copies without clearing the already-buffered body. The collection gate moves into core as RequestInfo::should_collect_form_body — form-urlencoded POST with a declared Content-Length within the shared FORM_BODY_MAX_BYTES (64 KiB). Oversized or length-less bodies are passed through untouched instead of rejected (they cannot be legitimate SDK STS requests), which also gives lambda the size bound it was missing and replaces the three independent copies of the cap. Co-Authored-By: Claude Fable 5 --- crates/cf-workers/src/body.rs | 17 +++++++ crates/cf-workers/src/request.rs | 77 ++++++----------------------- crates/core/src/route_handler.rs | 85 ++++++++++++++++++++++++++++---- docs/auth/proxy-auth.md | 4 +- examples/cf-workers/src/lib.rs | 2 + examples/lambda/src/main.rs | 16 +++--- examples/server/src/server.rs | 15 +++--- 7 files changed, 130 insertions(+), 86 deletions(-) diff --git a/crates/cf-workers/src/body.rs b/crates/cf-workers/src/body.rs index 1a4d3f7..00fbf30 100644 --- a/crates/cf-workers/src/body.rs +++ b/crates/cf-workers/src/body.rs @@ -19,6 +19,23 @@ impl JsBody { pub fn stream(&self) -> Option<&web_sys::ReadableStream> { self.0.as_ref() } + + /// Build a `JsBody` whose stream yields the given bytes. + /// + /// Used to hand an equivalent body back after collecting one (e.g. + /// [`RequestParts::absorb_form_body`](crate::request::RequestParts::absorb_form_body)), + /// so a request that falls through to the forwarding path still carries + /// its payload. Uses the same `web_sys::Response` trick as + /// [`collect_js_body`], in reverse. + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Ok(Self::new(None)); + } + let mut buf = bytes.to_vec(); + let resp = web_sys::Response::new_with_opt_u8_array(Some(&mut buf)) + .map_err(|e| format!("Response::new failed: {:?}", e))?; + Ok(Self::new(resp.body())) + } } // SAFETY: Workers is single-threaded; these are required by Gateway's generic bounds. diff --git a/crates/cf-workers/src/request.rs b/crates/cf-workers/src/request.rs index 36a2d00..97807a6 100644 --- a/crates/cf-workers/src/request.rs +++ b/crates/cf-workers/src/request.rs @@ -9,21 +9,6 @@ use crate::response::headermap_from_js; use http::{HeaderMap, Method, Uri}; use multistore::route_handler::RequestInfo; -/// Maximum collected form body size. STS `AssumeRoleWithWebIdentity` bodies -/// are a JWT plus a few short parameters, so 64 KiB is generous. -pub const FORM_BODY_MAX_BYTES: usize = 64 * 1024; - -/// Whether the declared `Content-Length` permits collecting the body into -/// WASM memory. Absent or unparseable lengths are rejected — the body could -/// be arbitrarily large. -fn form_body_within_limit(headers: &HeaderMap) -> bool { - headers - .get(http::header::CONTENT_LENGTH) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse::().ok()) - .is_some_and(|len| len <= FORM_BODY_MAX_BYTES) -} - /// Owned HTTP request metadata extracted from a `web_sys::Request`. /// /// Workers passes a `web_sys::Request` with borrowed JS strings and a @@ -109,7 +94,7 @@ impl RequestParts { } /// Collect a form-encoded `POST` body into [`form_body`](Self::form_body), - /// returning the (now empty) body to pass on to the gateway. + /// returning an equivalent body to pass on to the gateway. /// /// A no-op passthrough for every other request shape, so integrators can /// call it unconditionally between [`from_web_sys`](Self::from_web_sys) @@ -120,26 +105,26 @@ impl RequestParts { /// body = parts.absorb_form_body(body).await?; /// ``` /// - /// Form-encoded `POST`s are not part of the S3 protocol, so consuming the - /// stream here never steals a payload the forwarding path needs. - /// - /// # Errors + /// `Content-Type` is client-controlled, so a form-labeled `POST` is not + /// necessarily STS — it could be a mislabeled S3 write + /// (`CompleteMultipartUpload`, `DeleteObjects`). The returned body is + /// therefore rebuilt from the collected bytes, so a request that falls + /// through to the S3 pipeline sees its payload unchanged. /// - /// Collecting materializes the body into WASM memory, so it is bounded - /// *before* reading: a form `POST` whose `Content-Length` is missing, - /// unparseable, or above [`FORM_BODY_MAX_BYTES`] is rejected. SDK STS - /// clients always send an accurate `Content-Length`; a chunked body with - /// no declared length could be arbitrarily large. + /// Collection is gated on + /// [`RequestInfo::should_collect_form_body`](multistore::route_handler::RequestInfo::should_collect_form_body): + /// a form `POST` with a missing or oversized declared `Content-Length` is + /// passed through untouched rather than buffered into WASM memory. The + /// declared length bounds the actual bytes read because Cloudflare's edge + /// terminates HTTP — the request stream is derived from message framing, + /// which cannot deliver more bytes than the declared `Content-Length`. pub async fn absorb_form_body(&mut self, body: JsBody) -> Result { - if !self.as_request_info().is_form_urlencoded_post() { + if !self.as_request_info().should_collect_form_body() { return Ok(body); } - if !form_body_within_limit(&self.headers) { - return Err("form body too large or missing Content-Length".into()); - } let bytes = crate::body::collect_js_body(body).await?; self.form_body = Some(String::from_utf8_lossy(&bytes).into_owned()); - Ok(JsBody::new(None)) + JsBody::from_bytes(&bytes) } /// Borrow this struct as a [`RequestInfo`] for gateway dispatch. @@ -161,35 +146,3 @@ impl RequestParts { .with_form_body(self.form_body.as_deref()) } } - -#[cfg(test)] -mod tests { - use super::*; - - fn headers_with_len(len: &str) -> HeaderMap { - let mut h = HeaderMap::new(); - h.insert(http::header::CONTENT_LENGTH, len.parse().unwrap()); - h - } - - #[test] - fn form_body_limit_accepts_small_declared_length() { - assert!(form_body_within_limit(&headers_with_len("1024"))); - assert!(form_body_within_limit(&headers_with_len( - &FORM_BODY_MAX_BYTES.to_string() - ))); - } - - #[test] - fn form_body_limit_rejects_oversized_declared_length() { - assert!(!form_body_within_limit(&headers_with_len( - &(FORM_BODY_MAX_BYTES + 1).to_string() - ))); - } - - #[test] - fn form_body_limit_rejects_missing_or_bad_content_length() { - assert!(!form_body_within_limit(&HeaderMap::new())); - assert!(!form_body_within_limit(&headers_with_len("not-a-number"))); - } -} diff --git a/crates/core/src/route_handler.rs b/crates/core/src/route_handler.rs index 26c1595..a7389d5 100644 --- a/crates/core/src/route_handler.rs +++ b/crates/core/src/route_handler.rs @@ -217,6 +217,11 @@ impl Params { } } +/// Maximum form body size a runtime should collect into +/// [`RequestInfo::form_body`]. STS `AssumeRoleWithWebIdentity` bodies are a +/// JWT plus a few short parameters, so 64 KiB is generous. +pub const FORM_BODY_MAX_BYTES: usize = 64 * 1024; + /// Parsed request metadata passed to route handlers. pub struct RequestInfo<'a> { /// The HTTP method (GET, PUT, HEAD, etc.). @@ -265,10 +270,15 @@ pub struct RequestInfo<'a> { /// `application/x-www-form-urlencoded` body instead of a query string, so /// a body-blind dispatch can never serve unmodified SDK clients. Runtimes /// that want SDK compatibility should collect the body of such requests - /// (see [`is_form_urlencoded_post`](Self::is_form_urlencoded_post)) and - /// attach it here via - /// [`with_form_body`](Self::with_form_body); the S3 data path never uses - /// form-encoded bodies, so this is `None` for every other request shape. + /// (see [`should_collect_form_body`](Self::should_collect_form_body)) and + /// attach it here via [`with_form_body`](Self::with_form_body). + /// + /// `Content-Type` is client-controlled, so a request carrying a form + /// content type is not necessarily an STS request — an S3 `POST` + /// (`CompleteMultipartUpload`, `DeleteObjects`) could be mislabeled. + /// Collecting the body must therefore never *consume* it: the runtime + /// must pass the same bytes downstream so a request that falls through + /// to the S3 pipeline is unaffected. pub form_body: Option<&'a str>, } @@ -302,15 +312,37 @@ impl<'a> RequestInfo<'a> { self } + /// Whether a runtime should collect this request's body into + /// [`form_body`](Self::form_body): a form-urlencoded `POST` (see + /// [`is_form_urlencoded_post`](Self::is_form_urlencoded_post)) whose + /// declared `Content-Length` is present, parseable, and within + /// [`FORM_BODY_MAX_BYTES`]. + /// + /// The length gate bounds how much body a runtime buffers into memory on + /// an unauthenticated pre-dispatch path. A request with a missing or + /// oversized declared length cannot be a legitimate SDK STS request + /// (those always declare a small `Content-Length`), so runtimes leave its + /// body untouched and let it fall through rather than rejecting it. + pub fn should_collect_form_body(&self) -> bool { + self.is_form_urlencoded_post() + && self + .headers + .get(http::header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .is_some_and(|len| len <= FORM_BODY_MAX_BYTES) + } + /// Whether this request is a `POST` with an /// `application/x-www-form-urlencoded` body — the shape AWS SDKs use for /// query-protocol operations like STS `AssumeRoleWithWebIdentity`. /// - /// Runtimes use this to decide when to collect the body and attach it via - /// [`with_form_body`](Self::with_form_body). The check ignores any - /// `; charset=...` parameter on the content type. Form-encoded `POST`s are - /// not part of the S3 protocol, so collecting such a body never steals a - /// payload the data path needs. + /// The check ignores any `; charset=...` parameter on the content type. + /// `Content-Type` is client-controlled, so this does **not** prove the + /// request is STS — see [`form_body`](Self::form_body) for why collection + /// must not consume the body. Runtimes should gate collection on + /// [`should_collect_form_body`](Self::should_collect_form_body), which + /// also bounds the body size. pub fn is_form_urlencoded_post(&self) -> bool { self.method == Method::POST && self @@ -534,4 +566,39 @@ mod tests { !RequestInfo::new(&Method::POST, "/", None, &headers, None).is_form_urlencoded_post() ); } + + #[test] + fn test_should_collect_form_body() { + let form_headers = |content_length: Option<&str>| { + let mut headers = http::HeaderMap::new(); + headers.insert( + "content-type", + "application/x-www-form-urlencoded".parse().unwrap(), + ); + if let Some(len) = content_length { + headers.insert("content-length", len.parse().unwrap()); + } + headers + }; + let collect = |headers: &http::HeaderMap| { + RequestInfo::new(&Method::POST, "/", None, headers, None).should_collect_form_body() + }; + + // Small and boundary declared lengths are collected. + assert!(collect(&form_headers(Some("1024")))); + assert!(collect(&form_headers(Some( + &FORM_BODY_MAX_BYTES.to_string() + )))); + // Oversized, missing, or unparseable Content-Length: leave the body + // alone — it cannot be a legitimate SDK STS request. + assert!(!collect(&form_headers(Some( + &(FORM_BODY_MAX_BYTES + 1).to_string() + )))); + assert!(!collect(&form_headers(None))); + assert!(!collect(&form_headers(Some("not-a-number")))); + // Not a form post at all. + let mut headers = form_headers(Some("1024")); + headers.insert("content-type", "application/xml".parse().unwrap()); + assert!(!collect(&headers)); + } } diff --git a/docs/auth/proxy-auth.md b/docs/auth/proxy-auth.md index f374cc3..2019394 100644 --- a/docs/auth/proxy-auth.md +++ b/docs/auth/proxy-auth.md @@ -96,7 +96,9 @@ Parameters are accepted either in the query string or as an send them, so unmodified SDK STS clients (e.g. a client constructed with a custom `endpoint_url`, or the SDK's built-in web-identity credential provider) work against the proxy. Parameters are read from exactly one source: the query -string wins if it contains an STS `Action`. +string wins if it contains an STS `Action`. Form bodies are only collected +when the declared `Content-Length` is at most 64 KiB (generous for a JWT plus +a few parameters); larger or length-less bodies are passed through untouched. | Parameter | Required | Description | |-----------|----------|-------------| diff --git a/examples/cf-workers/src/lib.rs b/examples/cf-workers/src/lib.rs index 9edc9aa..d29be9a 100644 --- a/examples/cf-workers/src/lib.rs +++ b/examples/cf-workers/src/lib.rs @@ -110,6 +110,8 @@ async fn fetch(req: web_sys::Request, env: Env, _ctx: Context) -> Result Result<(), Error> { async fn request_handler(req: Request) -> Result, Error> { let state = STATE.get().expect("state not initialized"); - let (parts, mut body) = req.into_parts(); + let (parts, body) = req.into_parts(); let method = parts.method; let uri = parts.uri; let path = uri.path().to_string(); @@ -122,17 +122,17 @@ async fn request_handler(req: Request) -> Result, Error> { tracing::debug!(method = %method, uri = %uri, "incoming request"); // AWS SDKs send STS AssumeRoleWithWebIdentity as a form-encoded POST body - // rather than query parameters; collect it so the STS handler sees it. - // Lambda bodies are already buffered, so this is a cheap move. + // rather than query parameters; collect a copy so the STS handler sees it. + // Content-Type is client-controlled, so leave `body` untouched — a + // mislabeled non-STS POST falls through with its payload intact. + // should_collect_form_body bounds the copy at 64 KiB. let req_info = RequestInfo::new(&method, &path, query.as_deref(), &headers, None); - let form_body = if req_info.is_form_urlencoded_post() { - let text = match &body { + let form_body = if req_info.should_collect_form_body() { + Some(match &body { Body::Text(s) => s.clone(), Body::Binary(b) => String::from_utf8_lossy(b).into_owned(), Body::Empty => String::new(), - }; - body = Body::Empty; - Some(text) + }) } else { None }; diff --git a/examples/server/src/server.rs b/examples/server/src/server.rs index 4c7d743..cb08780 100644 --- a/examples/server/src/server.rs +++ b/examples/server/src/server.rs @@ -9,7 +9,7 @@ use axum::Router; use multistore::backend::ForwardResponse; use multistore::proxy::{GatewayResponse, ProxyGateway}; use multistore::registry::{BucketRegistry, CredentialRegistry}; -use multistore::route_handler::RequestInfo; +use multistore::route_handler::{RequestInfo, FORM_BODY_MAX_BYTES}; use multistore::router::Router as ProxyRouter; use multistore_oidc_provider::backend_auth::MaybeOidcAuth; use multistore_oidc_provider::jwt::JwtSigner; @@ -162,14 +162,17 @@ async fn request_handler( // AWS SDKs send STS AssumeRoleWithWebIdentity as a form-encoded POST body // rather than query parameters; collect it so the STS handler sees it. - // Form bodies carry a JWT and a few short params, so 64 KiB is generous. - const FORM_BODY_MAX_BYTES: usize = 64 * 1024; + // Content-Type is client-controlled, so rebuild the body from the same + // bytes — a mislabeled non-STS POST falls through with its payload intact. + // should_collect_form_body bounds the declared Content-Length at 64 KiB; + // the to_bytes cap backstops it against a lying stream. let req_info = RequestInfo::new(&method, &path, query.as_deref(), &headers, None); - let form_body = if req_info.is_form_urlencoded_post() { + let form_body = if req_info.should_collect_form_body() { match axum::body::to_bytes(body, FORM_BODY_MAX_BYTES).await { Ok(bytes) => { - body = Body::empty(); - Some(String::from_utf8_lossy(&bytes).into_owned()) + let text = String::from_utf8_lossy(&bytes).into_owned(); + body = Body::from(bytes); + Some(text) } Err(_) => { return Response::builder()