Skip to content

fix(auth): reject replayed SAML assertions#407

Merged
therealbrad merged 1 commit into
mainfrom
fix/saml-assertion-replay
Jun 5, 2026
Merged

fix(auth): reject replayed SAML assertions#407
therealbrad merged 1 commit into
mainfrom
fix/saml-assertion-replay

Conversation

@therealbrad
Copy link
Copy Markdown
Contributor

Description

Fast-follow to #406. Accepting IdP-initiated (no-RelayState) POSTs opened an assertion-replay vector: any captured SAMLResponse — including an SP-initiated one with its RelayState stripped — could be re-POSTed to the ACS and accepted until it expired, because the only remaining bound was the assertion's notBefore/notOnOrAfter window.

The ACS now records each validated assertion by its signed assertion ID in Valkey for the remainder of its validity window and rejects a second use — the same single-use pattern as the relay state (#405) and the completion token (#406). The ID is the dedup key (rather than a byte hash of the response) because it lives inside the signed element: an attacker can't change it without the IdP's key, whereas the surrounding bytes can be mutated (re-canonicalization, namespace/attribute reordering, the unsigned Response wrapper) while keeping a valid signature. Without Valkey the assertion's expiry window remains the only bound (consistent with the relay-state fallback).

Related Issue

Fast-follow to #406; closes the assertion-replay gap raised in review.

Type of Change

  • Bug fix / hardening (non-breaking)

Testing

  • Unit: registerSamlAssertion returns true on first use, false on replay (Valkey-backed); returns true without Valkey.
  • Validator: a replayed assertion returns 400 "SAML response has already been used".
  • Full SAML suite green; tsc + prettier clean for the changed files.

Checklist

  • Code follows the project's style guidelines
  • Added tests that prove the change is effective
  • New and existing unit tests pass locally

Accepting IdP-initiated (no-RelayState) POSTs in #406 opened a replay
vector: any captured assertion — including an SP-initiated one with its
RelayState stripped — could be re-POSTed and accepted until it expired,
since the only remaining bound was its notBefore/notOnOrAfter window.

The ACS now records each validated assertion by its signed ID in Valkey
for the remainder of its validity window and rejects a second use — the
same single-use pattern as the relay state and completion token. The ID
is the dedup key (not a byte hash) because it lives inside the signed
element: an attacker can't change it without the IdP's key, whereas the
surrounding bytes can be mutated (re-canonicalization, namespace/attribute
reordering, the unsigned Response wrapper) while keeping a valid signature.
Without Valkey the assertion's expiry window remains the only bound.
@therealbrad therealbrad merged commit ab75960 into main Jun 5, 2026
5 checks passed
@therealbrad therealbrad deleted the fix/saml-assertion-replay branch June 5, 2026 20:29
@therealbrad
Copy link
Copy Markdown
Contributor Author

🎉 This PR is included in version 0.35.3 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

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