Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions crates/cf-workers/src/body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, String> {
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.
Expand Down
42 changes: 42 additions & 0 deletions crates/cf-workers/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ pub struct RequestParts {
pub query: Option<String>,
/// 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<String>,
}

impl RequestParts {
Expand Down Expand Up @@ -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<JsBody, String> {
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
Expand All @@ -102,5 +143,6 @@ impl RequestParts {
None,
)
.with_signing_path(&self.signing_path)
.with_form_body(self.form_body.as_deref())
}
}
148 changes: 148 additions & 0 deletions crates/core/src/route_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.).
Expand Down Expand Up @@ -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> {
Expand All @@ -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::<usize>().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
Expand Down Expand Up @@ -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));
}
}
1 change: 1 addition & 0 deletions crates/core/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions crates/sts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<C: CredentialRegistry>(
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 {
Expand Down
11 changes: 8 additions & 3 deletions crates/sts/src/request.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -15,9 +18,11 @@ pub struct StsRequest {
pub duration_seconds: Option<u64>,
}

/// 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<Result<StsRequest, ProxyError>> {
Expand Down
47 changes: 42 additions & 5 deletions crates/sts/src/route_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,14 @@ struct StsHandler<C> {
impl<C: CredentialRegistry> RouteHandler for StsHandler<C> {
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))
})
}
Expand All @@ -29,9 +35,12 @@ impl<C: CredentialRegistry> RouteHandler for StsHandler<C> {
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<C: CredentialRegistry + 'static>(
self,
path: &str,
Expand Down Expand Up @@ -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"
);
}
}
Loading
Loading