Skip to content

fix(aws-sigv4): sign the bytes the transport sends for form/multipart bodies#401

Merged
rejifald merged 2 commits into
mainfrom
fix/aws-sigv4-signbody-encoding
Jul 1, 2026
Merged

fix(aws-sigv4): sign the bytes the transport sends for form/multipart bodies#401
rejifald merged 2 commits into
mainfrom
fix/aws-sigv4-signbody-encoding

Conversation

@rejifald

@rejifald rejifald commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Problem

awsSigV4() with signBody: true hashed JSON.stringify(body) for every non-string body. But core's transport (encodeRequestBody, packages/core/src/http-adapter.ts) serialises by bodyType:

  • bodyType: 'form'application/x-www-form-urlencoded (URLSearchParams), e.g. a=1&b=two+words
  • bodyType: 'multipart'FormData (boundary generated by the transport at send time)

So for a form/multipart body with signBody: true, the signed x-amz-content-sha256 was computed over the JSON encoding while the transport sent different bytes → AWS 403 SignatureDoesNotMatch. The strategy's own doc-comment even asserted the JSON hash "must match what the transport sends" — silently violated.

Found via a review-sweep dry-run; both premises verified against aws-sigv4/src/index.ts and core/src/http-adapter.ts. Correctness bug, not a vulnerability.

Fix

Branch the payload hash on req.bodyType (the engine threads it onto the signed request and cloneReq preserves it):

bodyType signBody: true behaviour
string / json / unset hash exact bytes — unchanged (already correct)
form hash the application/x-www-form-urlencoded encoding via a byte-for-byte mirror of core's encodeRequestBody → payload signing now works (SQS/SNS/STS query protocol)
multipart throws — the transport picks a non-deterministic boundary at send time, so the payload can never be signed. Default (no signBody) still sends UNSIGNED-PAYLOAD, unchanged.

Deliberately not mirroring "verbatim bytes for binary": core's default adapter JSON.stringifys a non-string/non-form/non-multipart body, which the strategy already matches — so binary is not a mismatch, and hashing raw bytes would have introduced one. encodeRequestBody is mirrored (not imported) because it isn't in the public stitchapi entry; the coupling is called out in a code comment.

Tests

Added regression tests to packages/aws-sigv4/test/sigv4.spec.ts that fail before / pass after:

  • form + signBody: true signs the URL-encoded wire bytes (parity with the equivalent string body; ≠ the buggy JSON hash)
  • multipart + signBody: true throws
  • form/multipart without signBody stay UNSIGNED-PAYLOAD (guards against over-throwing)

Docs

Rewrote the "Payload signing" section in the README and apps/docs/content/docs/integrations/aws-sigv4.mdx to document the per-bodyType behaviour and the multipart restriction.

Verification

Full pre-push gate green: format, lint, contract, typecheck, typecheck-d, test (whole tree), exports, build-docs (full Next build — validates the new docs link), yakir drift check. Package: 13/13 tests, check:types clean, tsup build clean.

🤖 Generated with Claude Code

rejifald and others added 2 commits July 1, 2026 20:37
… bodies

`signBody: true` hashed `JSON.stringify(body)` for every non-string body, but
core's transport serialises `bodyType: 'form'` as application/x-www-form-urlencoded
(URLSearchParams) and `'multipart'` as FormData. The signed x-amz-content-sha256
therefore never matched the sent bytes, so AWS returned 403 SignatureDoesNotMatch.

Branch the payload hash on `req.bodyType` (which the engine threads onto the signed
request and cloneReq preserves):

- form: hash the exact URL-encoded bytes via a byte-for-byte mirror of core's
  encodeRequestBody, so payload signing works for the SQS/SNS/STS query protocol.
- multipart: throw — the transport picks a non-deterministic boundary at send
  time, so the payload cannot be signed. The default (no signBody) still sends
  UNSIGNED-PAYLOAD.
- json / unset: unchanged (JSON.stringify already matches the wire bytes).

Add regression tests that fail before / pass after, and document the per-bodyType
behaviour and the multipart restriction in the README and docs "Payload signing"
sections.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The signed x-amz-content-sha256 must equal SHA-256 of the exact bytes the
transport sends (insertion-order JSON.stringify on the same object), so a
canonical/sorted "stable" object hash would itself cause SignatureDoesNotMatch.
Guard against a well-meaning future refactor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rejifald

rejifald commented Jul 1, 2026

Copy link
Copy Markdown
Owner Author

Follow-up for the architectural hardening (sign the transport's actual bytes instead of re-deriving them, so formEncode/the JSON mirror can be deleted and drift is impossible): tracked in #402.

@rejifald rejifald merged commit 3694d75 into main Jul 1, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant