Skip to content

PPT-2536: fix SAML authentication bypass (unsigned assertions accepted) - #14

Merged
camreeves merged 3 commits into
masterfrom
PPT-2536-saml-signature
Jul 31, 2026
Merged

PPT-2536: fix SAML authentication bypass (unsigned assertions accepted)#14
camreeves merged 3 commits into
masterfrom
PPT-2536-saml-signature

Conversation

@camreeves

Copy link
Copy Markdown
Contributor

Security fix

A SAML Response with no signature at all was accepted: 303/, with a UserAuthLookup provisioned. Anyone able to POST to the ACS could log in as an identity of their choosing.

Nothing in the stack prevented it:

  • crystal-saml's validate_signature does return true when the document contains no <ds:Signature>
  • want_assertions_signed is only used to advertise WantAssertionsSigned in our SP metadata — no code reads it during validation
  • want_signature_validated gates the crypto check, but that is only reached once a signature exists, and we derive it from cert/fingerprint presence — so a strat with neither disabled verification entirely

This was a regression against the service being replaced. ruby-saml 1.18.1 rejects on two independent grounds: validate_signed_elements refuses zero signature nodes unconditionally (!signed_elements.empty?), and validate_signature bails when get_fingerprint is nil (no cert and no fingerprint configured).

The fix

Two guards before the assertion reaches the engine:

  1. Fail closed with no trust anchor — a strat with neither idp_cert nor idp_cert_fingerprint refuses to authenticate. With nothing to verify against, "accept" means "trust anyone who reaches the ACS".
  2. Require a signature to be present — capped at two nodes, covering the Response or Assertion, mirroring ruby-saml's validate_signed_elements.

Equal to ruby-saml on every axis; stricter on one (ruby-saml will proceed to attempt validation with no anchor, we refuse outright).

Coverage

New spec/spec_helpers/saml_fixtures.cr mints SAML 2.0 Responses with current timestamps and signs them via SAML::XMLSecurity.sign_document, with two throwaway keypairs (legitimate IdP + attacker).

Generated rather than canned because crystal-saml's fixtures expired years ago and its own specs only use them with a 10-year allowed_clock_drift that auth.cr does not set.

Case Expected
correctly signed by configured cert accepted
signed by a different valid keypair rejected
entirely unsigned rejected
no cert/fingerprint configured rejected even when signed

172 examples, 0 failures. CI and Build both green.

Operational note

Both dev-server SAML strats have idp_cert=ABSENT and idp_cert_fingerprint=ABSENT. They are already non-functional under Ruby (which rejects for the same reason), so this changes nothing operationally — it stops auth.cr succeeding insecurely where Ruby correctly fails.

Worth reporting upstream: crystal-saml treating an unsigned document as validly signed affects anything built on it.

🤖 Generated with Claude Code

camreeves and others added 3 commits July 30, 2026 15:09
Real clients use SAML, so forged-assertion handling cannot rest on
assumption. Adds spec/spec_helpers/saml_fixtures.cr — a factory that mints
SAML 2.0 Responses with CURRENT timestamps and signs them via
SAML::XMLSecurity.sign_document, plus two throwaway self-signed keypairs in
spec/fixtures/saml/ (a legitimate IdP and a different "attacker" one).

Generated rather than canned because crystal-saml's shipped responses expired
years ago; its own specs only use them with allowed_clock_drift of 10 years,
which auth.cr does not set. Loosening production validation to make a test
pass would defeat the point.

Why this is auth.cr's problem and not the shard's: crystal-saml's
validate_signature does `return true` when the document has no <ds:Signature>
at all, and the crypto check only runs when want_signature_validated is set.
auth.cr gates BOTH flags on the strat carrying a cert/fingerprint:

  want_assertions_signed:   strat.idp_cert.presence || ... ? true : false
  want_signature_validated: strat.idp_cert.presence || ... ? true : false

so whether a forgery is refused depends on our configuration, not the library.

Three cases against a cert-configured strat:
  * correctly signed by the IdP cert  -> accepted
  * signed by a different valid keypair -> rejected, no user linked
  * entirely unsigned                 -> rejected, no user linked

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first cut asserted that a forged assertion redirects to /auth/failure.
That is only one valid way to reject: a 4xx is equally correct, and the
assertion produced `nil` (no Location) for the attacker case and `false`
(some other Location) for the unsigned case — so the failures said nothing
about whether a forgery actually succeeded.

Assert the load-bearing invariant instead: no UserAuthLookup is created and
the response is not a success. On failure, report status, Location, whether a
lookup appeared, and a body excerpt, so CI distinguishes "my test was wrong"
from "a forged assertion was accepted" without guesswork.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes an authentication bypass. A SAML Response with NO signature was
accepted: status 303, redirect to /, and a UserAuthLookup provisioned — i.e.
anyone able to POST to the ACS could log in as an identity of their choosing.

Nothing in the stack prevented it:
  * crystal-saml's validate_signature does `return true` when the document
    contains no <ds:Signature> at all
  * want_assertions_signed is only used to advertise WantAssertionsSigned in
    our SP metadata; no code reads it during validation
  * want_signature_validated gates the crypto check, but that is only reached
    once a signature exists, and we derive it from cert/fingerprint presence —
    so a strat with neither disabled verification completely

This is a REGRESSION against the service being replaced: ruby-saml's
validate_signed_elements rejects zero signature nodes unconditionally
(`!signed_elements.empty?`), regardless of want_assertions_signed, and
separately requires the Assertion to be signed when the SP asks for it.

Two guards before the assertion reaches the engine:
  1. fail closed when the strat has no idp_cert and no idp_cert_fingerprint —
     with no trust anchor, "accept" means "trust anyone who reaches the ACS".
     BOTH dev-server SAML strats are currently configured this way.
  2. require a signature to be present, capped at two nodes, and covering the
     Response or the Assertion — mirroring ruby-saml.

Four specs cover it: correctly signed accepted; attacker-signed rejected;
unsigned rejected; no-trust-anchor rejected even when signed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@camreeves
camreeves merged commit 4e1d709 into master Jul 31, 2026
7 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