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 d79358e..97807a6 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,46 @@ impl RequestParts { signing_path, query, headers, + form_body: None, }, body, )) } + /// Collect a form-encoded `POST` body into [`form_body`](Self::form_body), + /// 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) + /// and dispatch: + /// + /// ```rust,ignore + /// let (mut parts, mut body) = RequestParts::from_web_sys(&req)?; + /// body = parts.absorb_form_body(body).await?; + /// ``` + /// + /// `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. + /// + /// 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().should_collect_form_body() { + return Ok(body); + } + let bytes = crate::body::collect_js_body(body).await?; + self.form_body = Some(String::from_utf8_lossy(&bytes).into_owned()); + JsBody::from_bytes(&bytes) + } + /// Borrow this struct as a [`RequestInfo`] for gateway dispatch. /// /// Sets the signing path to the raw, percent-encoded @@ -102,5 +143,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..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.). @@ -257,6 +262,24 @@ 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 [`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>, } impl<'a> RequestInfo<'a> { @@ -277,9 +300,65 @@ 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 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`. + /// + /// 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 + .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 +532,73 @@ 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() + ); + } + + #[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/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..2019394 100644 --- a/docs/auth/proxy-auth.md +++ b/docs/auth/proxy-auth.md @@ -91,6 +91,15 @@ 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`. 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 | |-----------|----------|-------------| | `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..d29be9a 100644 --- a/examples/cf-workers/src/lib.rs +++ b/examples/cf-workers/src/lib.rs @@ -105,9 +105,18 @@ async fn fetch(req: web_sys::Request, env: Env, _ctx: Context) -> Result 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 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.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(), + }) + } else { + None + }; + + 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 5d671e0..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; @@ -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,32 @@ async fn request_handler( "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. + // 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.should_collect_form_body() { + match axum::body::to_bytes(body, FORM_BODY_MAX_BYTES).await { + Ok(bytes) => { + let text = String::from_utf8_lossy(&bytes).into_owned(); + body = Body::from(bytes); + Some(text) + } + Err(_) => { + return Response::builder() + .status(400) + .body(Body::from("form body too large or unreadable")) + .unwrap(); + } + } + } else { + None + }; + + let req_info = req_info.with_form_body(form_body.as_deref()); match state .handler