Skip to content

feat(sts): accept AssumeRoleWithWebIdentity params from form-encoded POST bodies#112

Open
alukach wants to merge 4 commits into
mainfrom
feat/sts-form-encoded-body
Open

feat(sts): accept AssumeRoleWithWebIdentity params from form-encoded POST bodies#112
alukach wants to merge 4 commits into
mainfrom
feat/sts-form-encoded-body

Conversation

@alukach

@alukach alukach commented Jul 13, 2026

Copy link
Copy Markdown
Member

What I'm changing

AWS STS accepts AssumeRoleWithWebIdentity parameters in the query string or as an application/x-www-form-urlencoded POST body — and AWS SDKs exclusively send the latter. The STS handler parses the query string only, so an unmodified SDK STS client pointed at the proxy (via AWS_ENDPOINT_URL_STS), the SDKs' built-in web-identity credential providers (AWS_WEB_IDENTITY_TOKEN_FILE / AWS_ROLE_ARN), and aws-actions/configure-aws-credentials all fall through the router unhandled. This closes that gap so the STS endpoint is a drop-in AssumeRoleWithWebIdentity target for standard AWS tooling, with zero custom exchange code on the client.

Downstream motivation: source-cooperative/data.source.coop#184 (its /.sts is this handler).

How I did it

  • multistore::route_handlerRequestInfo gains an optional form_body field plus a with_form_body builder, and an is_form_urlencoded_post() predicate so every runtime shares one definition of when to collect a body. Form-encoded POSTs are not part of the S3 protocol, so collecting them never steals a payload the forwarding path needs. Router::dispatch threads the field through its RequestInfo reconstruction.
  • multistore-ststry_handle_sts takes form_body alongside query and tries try_parse_sts_request on the query first, then the body. Parameters are read from exactly one source, never merged across the two (matching AWS). The parser itself is untouched — it already parses a form-urlencoded string.
  • multistore-cf-workersRequestParts gains form_body and an absorb_form_body(JsBody) -> Result<JsBody> helper: a no-op passthrough for every other request shape, so integrators call it unconditionally between from_web_sys and dispatch. as_request_info() wires the field through.
  • examples (cf-workers, server, lambda) — all three runtimes mount /.sts, so all three now collect form bodies before building RequestInfo (the axum server bounds collection at 64 KiB; lambda bodies are already buffered).
  • docsauth/proxy-auth.md and reference/operations.md now state both parameter sources and the SDK-compatibility rationale.

Test plan

  • cargo test — new route-handler tests: a form-body POST with no query string is intercepted; a non-STS form body falls through; new is_form_urlencoded_post tests cover charset/casing, wrong method, wrong content type (S3 batch delete POST + XML), and no content type.
  • cargo check --all-targets
  • cargo check -p multistore-cf-workers --target wasm32-unknown-unknown
  • cargo fmt / cargo clippy --fix (pre-commit hook)

🤖 Generated with Claude Code

https://claude.ai/code/session_01Hk6VMBuKNcTK3boS8HrdQs

…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
@claude

claude Bot commented Jul 13, 2026

Copy link
Copy Markdown

Claude finished @alukach's task in 4m 8s —— View job


Review complete

  • Gather context (PR description, comments, changed files)
  • Review diff for correctness/security/best-practice issues
  • Run ponytail-review pass for over-engineering
  • Post final review

✅ No blocking issues — safe to merge.

Went through the full diff (git diff origin/main...HEAD) across core::route_handler/router, sts, cf-workers (body.rs/request.rs), and all three runtime examples.

Things I specifically checked and found sound:

  • Single/never-merged parameter sourcetry_handle_sts (crates/sts/src/lib.rs:64) does try_parse_sts_request(query).or_else(|| try_parse_sts_request(form_body)), so a query with a non-STS Action correctly falls through to the body rather than silently merging params from both sources.
  • No body destruction — all three runtimes rebuild an equivalent body after collection (cf-workers JsBody::from_bytes, axum Body::from(bytes), lambda copies without touching the original body), so a mislabeled non-STS form POST still reaches the S3 pipeline with its payload intact. Verified none of the three accidentally moves/consumes the original body before it's needed downstream.
  • Size bound is shared and consistentFORM_BODY_MAX_BYTES/should_collect_form_body in crates/core/src/route_handler.rs:220 is the single gate used by cf-workers, server, and lambda; a missing/oversized/unparseable Content-Length skips collection rather than buffering unbounded input pre-auth.
  • cf-workers Content-Length trustabsorb_form_body (crates/cf-workers/src/request.rs:121) relies on Cloudflare's edge to bound the actual stream to the declared length (documented invariant); the axum path additionally backstops with to_bytes(body, FORM_BODY_MAX_BYTES) in case that assumption is ever wrong. Reasonable, already discussed in the PR's own review history.
  • Routing — STS is mounted at a dedicated path (/.sts) in all three examples, so there's no ambiguity with S3 forwarding paths; Router::dispatch (crates/core/src/router.rs:62) correctly threads the new form_body field through RequestInfo reconstruction (only construction site outside RequestInfo::new, and it wasn't missed).
  • Test coverage (is_form_urlencoded_post, should_collect_form_body, the two new router-dispatch tests) matches the edge cases claimed in the PR description (charset/casing, wrong method/content-type, boundary/oversized/missing/unparseable Content-Length).

Simplify (ponytail): nothing worth cutting — the per-runtime "collect + rebuild body" blocks look duplicated but can't be meaningfully unified (each runtime has a different body type: JsBody, axum::body::Body, lambda_http::Body), and the actual gating logic is already centralized in should_collect_form_body. The is_form_urlencoded_post/should_collect_form_body split is justified by both being independently tested/documented public API, not speculative.

Note: I wasn't able to execute cargo check/cargo test in this sandbox (command approval unavailable), so this is a static read-through; per the PR's own comments the author has already run cargo test, cargo check --all-targets, and the wasm32 check/clippy for multistore-cf-workers.


💰 Estimated review cost: $1.10 · 4m08s · 27 turns

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown

📖 Docs preview deployed to https://multistore-docs-pr-112.development-seed.workers.dev

  • Date: 2026-07-15T13:21:20Z
  • Commit: 41f7889

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown

🚀 Latest commit deployed to https://multistore-proxy-pr-112.development-seed.workers.dev

  • Date: 2026-07-15T13:21:20Z
  • Commit: 41f7889

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 <noreply@anthropic.com>
@alukach

alukach commented Jul 15, 2026

Copy link
Copy Markdown
Member Author

Addressed the review in c094faa:

  • Blocking (unbounded form-body buffering): absorb_form_body now requires a parseable Content-Length ≤ 64 KiB (FORM_BODY_MAX_BYTES) before collecting, and rejects the request otherwise. This is stricter than the suggested fix — a missing/unparseable Content-Length is rejected rather than collected, since a chunked body with no declared length could still be arbitrarily large. SDK STS clients always send an accurate Content-Length, so legitimate traffic is unaffected. The gate lives in a pure helper (form_body_within_limit) with unit tests covering in-bounds, oversized, missing, and unparseable lengths.
  • Nit (double RequestInfo construction): the server and lambda examples now build RequestInfo once and extend it via with_form_body.

Verified: cargo test (all green), cargo check --all-targets, cargo check/clippy for multistore-cf-workers on wasm32-unknown-unknown, cargo fmt.

@alukach
alukach marked this pull request as ready for review July 15, 2026 13:00
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 <noreply@anthropic.com>
@alukach

alukach commented Jul 15, 2026

Copy link
Copy Markdown
Member Author

Addressed the second round in d16727b:

  • Body destruction for mislabeled non-STS POSTs (blocking): collection no longer consumes the body in any runtime. Each runtime hands the same bytes downstream after copying them into form_body: cf-workers rebuilds the stream from the collected bytes (JsBody::from_bytes, the Response trick in reverse), axum rebuilds Body::from(bytes), and lambda simply copies without clearing the already-buffered body. A CompleteMultipartUpload/DeleteObjects mislabeled as form-urlencoded now falls through to the S3 pipeline with its payload intact — no path-based gating needed, so the fix holds regardless of where STS is mounted.
  • Declared-vs-actual length (cf-workers): kept the Content-Length gate and documented the invariant it relies on: Cloudflare's edge terminates HTTP, so the request stream is derived from message framing and cannot deliver more bytes than the declared length. The axum runtime keeps to_bytes(body, FORM_BODY_MAX_BYTES) as a backstop against the same hypothetical.
  • Lambda missing cap + triplicated constant (blocking + ponytail): the gate is now one core predicate, RequestInfo::should_collect_form_body() — form-urlencoded POST and declared Content-Length ≤ the shared multistore::route_handler::FORM_BODY_MAX_BYTES (64 KiB) — used by all three runtimes. Oversized or length-less form POSTs are passed through untouched rather than rejected (they can't be legitimate SDK STS requests, and rejecting would break the mislabeled-S3 case above). This also removes the cf-workers-local constant/helper added in the previous round; its tests moved to core (host-runnable) as test_should_collect_form_body.

Verified: cargo test (all green, 18 suites), cargo check --all-targets, cargo check/clippy/cargo test --no-run for multistore-cf-workers on wasm32-unknown-unknown, cargo fmt.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant