crates/nornir-auth-saml/tests/sp_rules.rs
//! **Every rule `sp` enforces, each as a refusal with its kind** — the XML
//! signature-wrapping (XSW) battery first, then the signature's shape, then the
//! SAML conditions, then metadata, certificates and the request.
//!
//! The wrapping shapes are built by surgery on a response libxml2 signed
//! (`fixtures/a_assertion_signed.xml`, `fixtures/b_response_signed.xml`), so
//! the signature in each is genuinely valid for the content it covers — the
//! attack is only ever in WHERE that content sits or what sits beside it.
#![cfg(all(feature = "sp", feature = "test-support"))]
use base64::Engine as _;
use nornir_auth_saml::sp::{self, Expect, SpError};
use nornir_auth_saml::test_support::{self as ts, ResponseParams, Sign};
const FIX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
/// 2024-01-01T00:01:00Z.
const NOW: i64 = 1_704_067_260;
const IDP: &str = "https://idp.example.com/";
const SP: &str = "https://sp.example.com/metadata";
const ACS: &str = "https://sp.example.com/acs";
fn fixture(name: &str) -> String {
std::fs::read_to_string(format!("{FIX}/{name}")).unwrap()
}
fn fixture_cert() -> Vec<Vec<u8>> {
vec![base64::engine::general_purpose::STANDARD
.decode(fixture("idp_rsa.cert.b64").trim())
.unwrap()]
}
fn expect(certs: &[Vec<u8>]) -> Expect<'_> {
Expect {
idp_entity_id: IDP,
sp_entity_id: SP,
acs_url: ACS,
request_id: Some("_req1"),
now_unix: NOW,
skew_secs: 120,
certificates_der: certs,
}
}
fn verify(xml: &str) -> Result<sp::Assertion, SpError> {
let certs = fixture_cert();
sp::verify_response_xml(xml, &expect(&certs))
}
#[track_caller]
fn refused(xml: &str, kind: fn(&SpError) -> bool, what: &str) -> SpError {
match verify(xml) {
Ok(a) => panic!("{what}: ACCEPTED (name_id {:?})", a.name_id),
Err(e) => {
assert!(kind(&e), "{what}: refused with the wrong kind: {e:?}");
e
}
}
}
fn malformed(e: &SpError) -> bool {
matches!(e, SpError::Malformed(_))
}
fn signature(e: &SpError) -> bool {
matches!(e, SpError::Signature(_))
}
fn condition(e: &SpError) -> bool {
matches!(e, SpError::Condition(_))
}
fn unsupported(e: &SpError) -> bool {
matches!(e, SpError::Unsupported(_))
}
/// The signed Assertion element of fixture (a), whole.
fn signed_assertion(xml: &str) -> String {
let start = xml.find("<saml:Assertion ").unwrap();
let end = xml.find("</saml:Assertion>").unwrap() + "</saml:Assertion>".len();
xml[start..end].to_owned()
}
/// An unsigned evil twin of the signed assertion: another ID, mallory.
fn evil(xml: &str) -> String {
let signed = signed_assertion(xml);
let s = signed.find("<ds:Signature").unwrap();
let e = signed.find("</ds:Signature>").unwrap() + "</ds:Signature>".len();
format!("{}{}", &signed[..s], &signed[e..])
.replace("ID=\"_assert1\"", "ID=\"_evil\"")
.replace("alice@example.com", "mallory@evil.example")
}
#[test]
fn the_untouched_fixtures_verify() {
verify(&fixture("a_assertion_signed.xml")).expect("a verifies");
verify(&fixture("b_response_signed.xml")).expect("b verifies");
}
// ── XSW: signature wrapping ────────────────────────────────────────────────
#[test]
fn xsw1_an_evil_assertion_beside_the_signed_response_is_refused() {
// Response-signed (b): the attacker adds an unsigned assertion as a second
// child. The response signature no longer verifies — but the count is what
// refuses it, before any reader could pick the wrong one.
let b = fixture("b_response_signed.xml");
let at = b.find("<saml:Assertion ").unwrap();
let xml = format!(
"{}{}{}",
&b[..at],
evil(&fixture("a_assertion_signed.xml")),
&b[at..]
);
refused(&xml, malformed, "XSW1");
}
#[test]
fn xsw2_the_signed_response_wrapped_inside_an_evil_one_is_refused() {
let b = fixture("b_response_signed.xml");
let inner = b.replace("<samlp:Response ", "<samlp:Response xmlns:x=\"urn:x\" ");
let start = inner.find("<samlp:Response ").unwrap();
let wrapped = format!(
"<samlp:Response xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" ID=\"_outer\" Version=\"2.0\" IssueInstant=\"2024-01-01T00:00:00Z\"><saml:Issuer>{IDP}</saml:Issuer><samlp:Status><samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"/></samlp:Status>{}<samlp:Extensions>{}</samlp:Extensions></samlp:Response>",
evil(&fixture("a_assertion_signed.xml")),
&inner[start..]
);
refused(&wrapped, malformed, "XSW2");
}
#[test]
fn xsw3_an_evil_assertion_before_the_signed_one_is_refused() {
let a = fixture("a_assertion_signed.xml");
let at = a.find("<saml:Assertion ").unwrap();
let xml = format!("{}{}{}", &a[..at], evil(&a), &a[at..]);
refused(&xml, malformed, "XSW3");
}
#[test]
fn xsw4_the_signed_assertion_inside_an_evil_one_is_refused() {
let a = fixture("a_assertion_signed.xml");
let signed = signed_assertion(&a);
let twin = evil(&a).replace("</saml:Assertion>", &format!("{signed}</saml:Assertion>"));
let xml = a.replace(&signed, &twin);
refused(&xml, malformed, "XSW4");
}
#[test]
fn xsw5_an_evil_assertion_after_the_signed_one_is_refused() {
let a = fixture("a_assertion_signed.xml");
let end = a.find("</saml:Assertion>").unwrap() + "</saml:Assertion>".len();
let xml = format!("{}{}{}", &a[..end], evil(&a), &a[end..]);
refused(&xml, malformed, "XSW5");
}
#[test]
fn xsw6_the_signed_assertion_in_the_evil_ones_signature_object_is_refused() {
let a = fixture("a_assertion_signed.xml");
let signed = signed_assertion(&a);
let twin = evil(&a).replace(
"<saml:Subject>",
&format!(
"<ds:Signature xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"><ds:Object>{signed}</ds:Object></ds:Signature><saml:Subject>"
),
);
let xml = a.replace(&signed, &twin);
refused(&xml, malformed, "XSW6");
}
#[test]
fn xsw7_the_signed_assertion_moved_into_extensions_is_refused() {
// One assertion only — but not where an assertion belongs.
let a = fixture("a_assertion_signed.xml");
let signed = signed_assertion(&a);
let without = a.replace(&signed, "");
let at = without.find("<samlp:Status>").unwrap();
let xml = format!(
"{}<samlp:Extensions>{signed}</samlp:Extensions>{}",
&without[..at],
&without[at..]
);
let e = refused(&xml, malformed, "XSW7 (moved)");
assert!(e.to_string().contains("direct child"), "{e}");
// …and with an evil one in its place: two.
let xml = format!(
"{}<samlp:Extensions>{signed}</samlp:Extensions>{}{}",
&without[..at],
evil(&a),
&without[at..]
);
refused(&xml, malformed, "XSW7 (replaced)");
}
#[test]
fn xsw8_the_signature_lifted_to_the_response_pointing_at_the_assertion_is_refused() {
// The assertion's (valid) signature moved up to be the Response's: its
// Reference names #_assert1, not the element it sits on.
let a = fixture("a_assertion_signed.xml");
let s = a.find("<ds:Signature").unwrap();
let e = a.find("</ds:Signature>").unwrap() + "</ds:Signature>".len();
let sig = &a[s..e];
let without = format!("{}{}", &a[..s], &a[e..]);
let at = without.find("</saml:Issuer>").unwrap() + "</saml:Issuer>".len();
let xml = format!("{}{sig}{}", &without[..at], &without[at..]);
refused(&xml, signature, "XSW8");
}
#[test]
fn a_signature_anywhere_else_is_refused() {
let a = fixture("a_assertion_signed.xml");
let xml = a.replace(
"<samlp:Status>",
"<samlp:Extensions><ds:Signature xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"/></samlp:Extensions><samlp:Status>",
);
refused(&xml, malformed, "a stray Signature");
}
#[test]
fn a_reference_to_the_whole_document_is_refused() {
let a = fixture("a_assertion_signed.xml").replace("URI=\"#_assert1\"", "URI=\"\"");
refused(&a, signature, "Reference URI=\"\"");
}
/// Re-sign the (only) `SignedInfo` with the test IdP's key, so a test can change
/// the Reference and still present a signature that genuinely verifies over it —
/// the refusal must then come from the RULE, not from a broken signature.
fn resign(xml: &str) -> String {
use rsa::pkcs8::DecodePrivateKey as _;
use rsa::signature::{SignatureEncoding as _, Signer as _};
let doc = roxmltree::Document::parse(xml).unwrap();
let signed_info = doc
.descendants()
.find(|n| n.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "SignedInfo")))
.unwrap();
let canonical = sp::c14n::exclusive(&doc, signed_info, &[], None);
let (key, _) = ts::idp_key();
let key = rsa::RsaPrivateKey::from_pkcs8_der(&key).unwrap();
let value = rsa::pkcs1v15::SigningKey::<sha2::Sha256>::new(key)
.sign(canonical.as_bytes())
.to_vec();
let s = xml.find("<ds:SignatureValue>").unwrap() + "<ds:SignatureValue>".len();
let e = xml.find("</ds:SignatureValue>").unwrap();
format!("{}{}{}", &xml[..s], b64(&value), &xml[e..])
}
#[test]
fn a_reference_to_anything_but_the_signed_element_is_refused_even_when_signed() {
let mut p = ResponseParams::new(IDP, "alice@example.com", SP, ACS, NOW);
p.in_response_to = Some("_req1".into());
let signed = ts::sign_response(&ts::response_xml(&p), Sign::Assertion);
// The helper is sound: re-signing an unchanged SignedInfo still verifies.
verify_ts(&resign(&signed), |_| {}).expect("a re-signed, unchanged response verifies");
let aid = format!("URI=\"#{}\"", p.assertion_id);
for other in ["URI=\"\"".to_owned(), format!("URI=\"#{}\"", p.response_id)] {
let xml = resign(&signed.replace(&aid, &other));
let err = verify_ts(&xml, |_| {}).unwrap_err();
assert!(
signature(&err) && err.to_string().contains("Reference"),
"{other}: {err:?}"
);
}
}
#[test]
fn a_duplicate_id_is_refused() {
let a = fixture("a_assertion_signed.xml").replace(
"<samlp:Status>",
"<samlp:Extensions><x ID=\"_assert1\"/></samlp:Extensions><samlp:Status>",
);
refused(&a, malformed, "a duplicate ID");
}
#[test]
fn a_comment_cannot_split_the_signed_name_id() {
// The IdP signed "user@evil.example.com". C14N drops comments, so a comment
// spliced in keeps the signature valid — the reader must still see the
// WHOLE signed text, never "user@evil.example" (the first text node).
let mut p = ResponseParams::new(IDP, "user@evil.example.com", SP, ACS, NOW);
p.in_response_to = Some("_req1".into());
let signed = ts::sign_response(&ts::response_xml(&p), Sign::Assertion);
let spliced = signed.replace(
"user@evil.example.com</saml:NameID>",
"user@evil.example<!---->.com</saml:NameID>",
);
assert_ne!(spliced, signed);
let certs = vec![ts::idp_cert_der()];
let a = sp::verify_response_xml(&spliced, &expect(&certs)).expect("the signature still holds");
assert_eq!(a.name_id, "user@evil.example.com");
}
#[test]
fn the_messages_key_info_is_ignored() {
// An attacker's certificate in KeyInfo changes nothing: the configured key
// still verifies the IdP's signature…
let (_, attacker_cert) = ts::attacker_key();
let attacker_b64 = base64::engine::general_purpose::STANDARD.encode(&attacker_cert);
let a = fixture("a_assertion_signed.xml");
let s = a.find("<ds:X509Certificate>").unwrap() + "<ds:X509Certificate>".len();
let e = a.find("</ds:X509Certificate>").unwrap();
let swapped = format!("{}{attacker_b64}{}", &a[..s], &a[e..]);
verify(&swapped).expect("KeyInfo is not where the key comes from");
// …and a response the attacker signed, carrying their own certificate, fails.
let mut p = ResponseParams::new(IDP, "mallory@evil.example", SP, ACS, NOW);
p.in_response_to = Some("_req1".into());
let (key, cert) = ts::attacker_key();
let forged = ts::sign_response_with_key(&ts::response_xml(&p), Sign::Assertion, &key, &cert);
let certs = vec![ts::idp_cert_der()];
let err = sp::verify_response_xml(&forged, &expect(&certs)).unwrap_err();
assert!(signature(&err), "{err:?}");
}
// ── the signature itself ───────────────────────────────────────────────────
#[test]
fn changed_signed_content_is_refused() {
let a = fixture("a_assertion_signed.xml").replace(
">alice@example.com</saml:NameID>",
">mallory@example.com</saml:NameID>",
);
let e = refused(&a, signature, "a changed NameID");
assert!(e.to_string().contains("digest"), "{e}");
}
#[test]
fn a_changed_signature_value_is_refused() {
let a = fixture("a_assertion_signed.xml");
let s = a.find("<ds:SignatureValue>").unwrap() + "<ds:SignatureValue>".len();
let mut bytes = a.into_bytes();
bytes[s + 10] = if bytes[s + 10] == b'A' { b'B' } else { b'A' };
refused(
&String::from_utf8(bytes).unwrap(),
signature,
"a changed SignatureValue",
);
}
#[test]
fn an_unsigned_response_is_refused() {
let p = ResponseParams::new(IDP, "alice@example.com", SP, ACS, NOW);
let certs = vec![ts::idp_cert_der()];
let mut e = expect(&certs);
e.request_id = None;
let err = sp::verify_response_xml(&ts::response_xml(&p), &e).unwrap_err();
assert!(signature(&err), "{err:?}");
}
#[test]
fn sha1_is_refused() {
let a = fixture("a_assertion_signed.xml").replace(
"http://www.w3.org/2001/04/xmlenc#sha256",
"http://www.w3.org/2000/09/xmldsig#sha1",
);
let e = refused(&a, unsupported, "a SHA-1 digest");
assert!(e.to_string().contains("SHA-1"), "{e}");
let a = fixture("a_assertion_signed.xml").replace(
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
"http://www.w3.org/2000/09/xmldsig#rsa-sha1",
);
refused(&a, unsupported, "an RSA-SHA1 signature");
}
#[test]
fn transforms_other_than_enveloped_then_exclusive_c14n_are_refused() {
let a = fixture("a_assertion_signed.xml");
let inclusive = a.replace(
"<ds:Transform Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/>",
"<ds:Transform Algorithm=\"http://www.w3.org/TR/2001/REC-xml-c14n-20010315\"/>",
);
refused(&inclusive, unsupported, "inclusive C14N");
let xpath = a.replace(
"<ds:Transform Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/>",
"<ds:Transform Algorithm=\"http://www.w3.org/TR/1999/REC-xpath-19991116\"><ds:XPath>/</ds:XPath></ds:Transform>",
);
refused(&xpath, unsupported, "an XPath transform");
let three = a.replace(
"<ds:Transform Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/>",
"<ds:Transform Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/><ds:Transform Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/>",
);
refused(&three, unsupported, "three transforms");
let comments = a.replace(
"<ds:CanonicalizationMethod Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/>",
"<ds:CanonicalizationMethod Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#WithComments\"/>",
);
refused(&comments, unsupported, "C14N with comments");
}
#[test]
fn a_signature_with_an_object_or_two_references_is_refused() {
let a = fixture("a_assertion_signed.xml");
let object = a.replace("</ds:Signature>", "<ds:Object/></ds:Signature>");
refused(&object, malformed, "a ds:Object");
let s = a.find("<ds:Reference ").unwrap();
let e = a.find("</ds:Reference>").unwrap() + "</ds:Reference>".len();
let two = format!("{}{}{}", &a[..e], &a[s..e], &a[e..]);
refused(&two, malformed, "two References");
let twice = a.replace(
"<saml:Subject>",
"<ds:Signature xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"/><saml:Subject>",
);
refused(&twice, malformed, "two Signatures on the Assertion");
}
#[test]
fn a_doctype_is_refused() {
let a = fixture("a_assertion_signed.xml");
let xml = format!("<!DOCTYPE r [<!ENTITY x \"alice@example.com\">]>{a}");
refused(&xml, malformed, "a DOCTYPE");
let bomb = "<!DOCTYPE r [<!ENTITY a \"aaaaaaaaaa\"><!ENTITY b \"&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;\">]><r>&b;</r>";
refused(bomb, malformed, "entity expansion");
}
#[test]
fn an_encrypted_assertion_is_refused() {
let xml = fixture("a_assertion_signed.xml").replace(
"</samlp:Status>",
"</samlp:Status><saml:EncryptedAssertion/>",
);
refused(&xml, unsupported, "an EncryptedAssertion");
}
#[test]
fn a_status_other_than_success_is_refused() {
let a = fixture("a_assertion_signed.xml").replace(
"urn:oasis:names:tc:SAML:2.0:status:Success",
"urn:oasis:names:tc:SAML:2.0:status:Requester",
);
let e = verify(&a).unwrap_err();
assert!(matches!(e, SpError::Status(_)), "{e:?}");
}
// ── the conditions ─────────────────────────────────────────────────────────
fn signed_with(edit: impl FnOnce(&mut ResponseParams)) -> String {
let mut p = ResponseParams::new(IDP, "alice@example.com", SP, ACS, NOW);
p.in_response_to = Some("_req1".into());
edit(&mut p);
ts::sign_response(&ts::response_xml(&p), Sign::Assertion)
}
fn verify_ts(xml: &str, edit: impl FnOnce(&mut Expect)) -> Result<sp::Assertion, SpError> {
let certs = vec![ts::idp_cert_der()];
let mut e = expect(&certs);
edit(&mut e);
sp::verify_response_xml(xml, &e)
}
#[test]
fn the_audience_must_be_this_sp() {
let err = verify_ts(&signed_with(|_| {}), |e| {
e.sp_entity_id = "https://other-sp.example/"
})
.unwrap_err();
assert!(condition(&err), "{err:?}");
let err = verify_ts(
&signed_with(|p| p.audience = "https://other-sp.example/".into()),
|_| {},
)
.unwrap_err();
assert!(condition(&err), "{err:?}");
}
#[test]
fn the_recipient_must_be_this_acs() {
let xml = signed_with(|p| {
p.destination = None;
p.recipient = "https://evil.example/acs".into();
});
let err = verify_ts(&xml, |_| {}).unwrap_err();
assert!(
condition(&err) && err.to_string().contains("bearer"),
"{err:?}"
);
}
#[test]
fn the_destination_must_be_this_acs() {
let err = verify_ts(
&signed_with(|p| p.destination = Some("https://evil.example/acs".into())),
|_| {},
)
.unwrap_err();
assert!(
condition(&err) && err.to_string().contains("Response is for"),
"{err:?}"
);
// No Destination at all is allowed (it is optional on a signed assertion).
verify_ts(&signed_with(|p| p.destination = None), |_| {}).expect("no Destination");
}
#[test]
fn an_expired_assertion_is_refused_and_skew_is_honoured() {
let xml = signed_with(|_| {});
// Valid NOW-60 .. NOW+300.
let err = verify_ts(&xml, |e| e.now_unix = NOW + 300 + 121).unwrap_err();
assert!(
condition(&err) && err.to_string().contains("expired"),
"{err:?}"
);
verify_ts(&xml, |e| e.now_unix = NOW + 300 + 119).expect("inside the skew after NotOnOrAfter");
let err = verify_ts(&xml, |e| e.now_unix = NOW - 60 - 121).unwrap_err();
assert!(
condition(&err) && err.to_string().contains("not valid yet"),
"{err:?}"
);
verify_ts(&xml, |e| e.now_unix = NOW - 60 - 119).expect("inside the skew before NotBefore");
}
#[test]
fn the_issuer_must_be_the_idp() {
let err = verify_ts(&signed_with(|_| {}), |e| {
e.idp_entity_id = "https://other-idp.example/"
})
.unwrap_err();
assert!(condition(&err), "{err:?}");
let err = verify_ts(
&signed_with(|p| p.issuer = "https://other-idp.example/".into()),
|_| {},
)
.unwrap_err();
assert!(condition(&err), "{err:?}");
}
#[test]
fn in_response_to_must_answer_this_request() {
let xml = signed_with(|_| {});
let err = verify_ts(&xml, |e| e.request_id = Some("_another")).unwrap_err();
assert!(condition(&err), "{err:?}");
verify_ts(&xml, |e| e.request_id = None).expect("an IdP-initiated SP accepts it");
let unsolicited = signed_with(|p| p.in_response_to = None);
let err = verify_ts(&unsolicited, |_| {}).unwrap_err();
assert!(
condition(&err),
"an answer to no request, when one was sent: {err:?}"
);
}
#[test]
fn attributes_and_the_replay_window_come_back() {
let xml =
signed_with(|p| p.attributes = vec![("groups".into(), vec!["eng".into(), "a & b".into()])]);
let a = verify_ts(&xml, |_| {}).unwrap();
assert_eq!(
a.attributes["groups"],
vec!["eng".to_owned(), "a & b".to_owned()]
);
assert_eq!(a.not_on_or_after_unix, NOW + 300);
assert_eq!(a.assertion_id, format!("_a{NOW:x}"));
}
// ── test_support round trip ────────────────────────────────────────────────
#[test]
fn every_signing_shape_round_trips() {
for shape in [Sign::Assertion, Sign::Response, Sign::Both] {
let mut p = ResponseParams::new(IDP, "alice@example.com", SP, ACS, NOW);
p.in_response_to = Some("_req1".into());
let xml = ts::sign_response(&ts::response_xml(&p), shape);
let a = verify_ts(&xml, |_| {}).unwrap_or_else(|e| panic!("{shape:?}: {e}"));
assert_eq!(a.name_id, "alice@example.com", "{shape:?}");
let b64 = ts::encode(&xml);
let certs = vec![ts::idp_cert_der()];
sp::verify_response_b64(&b64, &expect(&certs))
.unwrap_or_else(|e| panic!("{shape:?} b64: {e}"));
}
}
#[test]
fn a_tampered_response_signature_is_refused_when_both_are_signed() {
let mut p = ResponseParams::new(IDP, "alice@example.com", SP, ACS, NOW);
p.in_response_to = Some("_req1".into());
let xml = ts::sign_response(&ts::response_xml(&p), Sign::Both);
// The Response's IssueInstant is covered only by the Response signature.
let first = xml.find("IssueInstant=\"").unwrap() + "IssueInstant=\"".len();
let mut bytes = xml.into_bytes();
bytes[first + 3] = if bytes[first + 3] == b'5' { b'6' } else { b'5' };
let err = verify_ts(&String::from_utf8(bytes).unwrap(), |_| {}).unwrap_err();
assert!(signature(&err), "{err:?}");
}
#[test]
fn an_oversized_response_is_refused_before_parsing() {
let huge = "A".repeat(sp::MAX_RESPONSE_BYTES * 2);
let certs = vec![ts::idp_cert_der()];
let err = sp::verify_response_b64(&huge, &expect(&certs)).unwrap_err();
assert!(malformed(&err), "{err:?}");
}
// ── metadata, certificates, the request ────────────────────────────────────
fn b64(der: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(der)
}
#[test]
fn okta_style_metadata_parses() {
let cert = b64(&ts::idp_cert_der());
let xml = format!(
r#"<?xml version="1.0" encoding="UTF-8"?><md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://www.okta.com/exk1abc"><md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><md:KeyDescriptor use="signing"><ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:X509Data><ds:X509Certificate>
{cert}
</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor><md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://acme.okta.com/app/acme_gunnar_1/exk1abc/sso/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://acme.okta.com/app/acme_gunnar_1/exk1abc/sso/saml"/></md:IDPSSODescriptor></md:EntityDescriptor>"#
);
let m = sp::parse_idp_metadata(&xml).unwrap();
assert_eq!(m.entity_id, "http://www.okta.com/exk1abc");
assert_eq!(
m.sso_redirect_url,
"https://acme.okta.com/app/acme_gunnar_1/exk1abc/sso/saml"
);
assert_eq!(m.certificates_der, vec![ts::idp_cert_der()]);
}
#[test]
fn entra_style_metadata_with_two_signing_certificates_parses() {
let (_, second) = ts::attacker_key();
let (one, two) = (b64(&ts::idp_cert_der()), b64(&second));
let xml = format!(
r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" ID="_e1" entityID="https://sts.windows.net/1234/"><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo/></Signature><RoleDescriptor xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:fed="http://docs.oasis-open.org/wsfed/federation/200706" xsi:type="fed:SecurityTokenServiceType" protocolSupportEnumeration="http://docs.oasis-open.org/wsfed/federation/200706"><KeyDescriptor use="signing"><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><X509Data><X509Certificate>{two}</X509Certificate></X509Data></KeyInfo></KeyDescriptor></RoleDescriptor><IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><KeyDescriptor use="signing"><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><X509Data><X509Certificate>{one}</X509Certificate></X509Data></KeyInfo></KeyDescriptor><KeyDescriptor use="signing"><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><X509Data><X509Certificate>{two}</X509Certificate></X509Data></KeyInfo></KeyDescriptor><KeyDescriptor use="encryption"><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><X509Data><X509Certificate>{one}</X509Certificate></X509Data></KeyInfo></KeyDescriptor><SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://login.microsoftonline.com/1234/saml2"/><SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://login.microsoftonline.com/1234/saml2"/><SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://login.microsoftonline.com/1234/saml2"/></IDPSSODescriptor></EntityDescriptor>"#
);
let m = sp::parse_idp_metadata(&xml).unwrap();
assert_eq!(m.entity_id, "https://sts.windows.net/1234/");
assert_eq!(
m.sso_redirect_url,
"https://login.microsoftonline.com/1234/saml2"
);
assert_eq!(
m.certificates_der,
vec![ts::idp_cert_der(), second],
"two signing certificates, no duplicate"
);
}
#[test]
fn metadata_without_what_an_sp_needs_is_refused() {
let cert = b64(&ts::idp_cert_der());
let no_redirect = format!(
r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x"><md:IDPSSODescriptor><md:KeyDescriptor><ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:X509Data><ds:X509Certificate>{cert}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://idp/sso"/></md:IDPSSODescriptor></md:EntityDescriptor>"#
);
assert!(matches!(
sp::parse_idp_metadata(&no_redirect),
Err(SpError::Unsupported(_))
));
let no_cert = r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x"><md:IDPSSODescriptor><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp/sso"/></md:IDPSSODescriptor></md:EntityDescriptor>"#;
assert!(matches!(
sp::parse_idp_metadata(no_cert),
Err(SpError::Malformed(_))
));
let two = format!(
"<md:EntitiesDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\">{}{}</md:EntitiesDescriptor>",
ts::idp_metadata_xml("https://a/", "https://a/sso").replace(" xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\"", ""),
ts::idp_metadata_xml("https://b/", "https://b/sso").replace(" xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\"", ""),
);
assert!(
matches!(sp::parse_idp_metadata(&two), Err(SpError::Malformed(_))),
"two IdPs"
);
let m = sp::parse_idp_metadata(&ts::idp_metadata_xml("https://a/", "https://a/sso")).unwrap();
assert_eq!(
(m.entity_id.as_str(), m.sso_redirect_url.as_str()),
("https://a/", "https://a/sso")
);
assert!(matches!(
sp::parse_idp_metadata("<!DOCTYPE x><md:EntityDescriptor/>"),
Err(SpError::Malformed(_))
));
}
#[test]
fn a_pasted_certificate_parses_as_pem_or_base64() {
let der = base64::engine::general_purpose::STANDARD
.decode(fixture("idp_rsa.cert.b64").trim())
.unwrap();
assert_eq!(
sp::parse_certificate_pem_or_b64(&fixture("idp_rsa.cert.pem")).unwrap(),
der
);
assert_eq!(
sp::parse_certificate_pem_or_b64(fixture("idp_rsa.cert.b64").trim()).unwrap(),
der
);
let ec = sp::parse_certificate_pem_or_b64(fixture("idp_ec.cert.b64").trim()).unwrap();
assert!(!ec.is_empty());
assert!(sp::parse_certificate_pem_or_b64("not a certificate").is_err());
assert!(sp::parse_certificate_pem_or_b64("").is_err());
assert!(sp::parse_certificate_pem_or_b64(&b64(b"\x30\x03\x02\x01\x01")).is_err());
}
fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < b.len() {
if b[i] == b'%' {
out.push(u8::from_str_radix(&s[i + 1..i + 3], 16).unwrap());
i += 3;
} else {
out.push(b[i]);
i += 1;
}
}
String::from_utf8(out).unwrap()
}
#[test]
fn the_authn_request_goes_out_deflated_over_http_redirect() {
use std::io::Read as _;
let xml = sp::authn_request_xml(
"_req1",
&sp::format_instant(NOW),
SP,
ACS,
"https://idp.example.com/sso?tenant=a&b=c",
);
let url =
sp::redirect_url("https://idp.example.com/sso?tenant=a&b=c", &xml, "st&te/1").unwrap();
assert!(
url.starts_with("https://idp.example.com/sso?tenant=a&b=c&SAMLRequest="),
"{url}"
);
let query = url.split_once("&SAMLRequest=").unwrap().1;
let (request, relay) = query.split_once("&RelayState=").unwrap();
assert_eq!(percent_decode(relay), "st&te/1");
let packed = base64::engine::general_purpose::STANDARD
.decode(percent_decode(request))
.unwrap();
let mut inflated = String::new();
flate2::read::DeflateDecoder::new(&packed[..])
.read_to_string(&mut inflated)
.unwrap();
assert_eq!(inflated, xml);
let doc = roxmltree::Document::parse(&inflated).unwrap();
let root = doc.root_element();
assert_eq!(root.tag_name().name(), "AuthnRequest");
assert_eq!(root.attribute("ID"), Some("_req1"));
assert_eq!(root.attribute("AssertionConsumerServiceURL"), Some(ACS));
assert_eq!(
root.attribute("Destination"),
Some("https://idp.example.com/sso?tenant=a&b=c")
);
assert_eq!(
root.attribute("ProtocolBinding"),
Some("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST")
);
let issuer = root.children().find(|n| n.has_tag_name("Issuer")).unwrap();
assert_eq!(issuer.text(), Some(SP));
assert_eq!(
sp::parse_instant(root.attribute("IssueInstant").unwrap()).unwrap(),
NOW
);
assert!(sp::redirect_url("javascript:alert(1)", &xml, "").is_err());
assert!(sp::redirect_url("https://idp/sso#frag", &xml, "").is_err());
assert!(sp::redirect_url("https://idp/sso", &xml, &"x".repeat(81)).is_err());
let no_relay = sp::redirect_url("https://idp/sso", &xml, "").unwrap();
assert!(
no_relay.starts_with("https://idp/sso?SAMLRequest=") && !no_relay.contains("RelayState")
);
}
#[test]
fn values_in_the_request_are_escaped() {
let xml = sp::authn_request_xml(
"_r\"<",
"2024-01-01T00:00:00Z",
"urn:sp&<x>",
"https://sp/acs?a=1&b=2",
"https://idp/sso",
);
let doc = roxmltree::Document::parse(&xml).expect("still well-formed");
assert_eq!(doc.root_element().attribute("ID"), Some("_r\"<"));
assert_eq!(
doc.root_element().attribute("AssertionConsumerServiceURL"),
Some("https://sp/acs?a=1&b=2")
);
}
#[test]
fn sp_metadata_names_the_acs() {
let xml = sp::sp_metadata_xml(SP, ACS);
let doc = roxmltree::Document::parse(&xml).unwrap();
assert_eq!(doc.root_element().attribute("entityID"), Some(SP));
let acs = doc
.descendants()
.find(|n| {
n.has_tag_name((
"urn:oasis:names:tc:SAML:2.0:metadata",
"AssertionConsumerService",
))
})
.unwrap();
assert_eq!(acs.attribute("Location"), Some(ACS));
assert_eq!(
acs.attribute("Binding"),
Some("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST")
);
let sp_desc = doc
.descendants()
.find(|n| n.has_tag_name(("urn:oasis:names:tc:SAML:2.0:metadata", "SPSSODescriptor")))
.unwrap();
assert_eq!(sp_desc.attribute("WantAssertionsSigned"), Some("true"));
}
// ── A34-34/35/36 (lane NIGHT-SAML) ─────────────────────────────────────────
/// A response from `p`, edited as XML before it is signed, so the signature
/// covers the edit.
fn signed_edited(
edit_params: impl FnOnce(&mut ResponseParams),
edit_xml: impl FnOnce(String) -> String,
shape: Sign,
) -> String {
let mut p = ResponseParams::new(IDP, "alice@example.com", SP, ACS, NOW);
p.in_response_to = Some("_req1".into());
edit_params(&mut p);
ts::sign_response(&edit_xml(ts::response_xml(&p)), shape)
}
/// A34-34: a SubjectConfirmationData NotBefore that is not an xs:dateTime is
/// refused by name, as a malformed Conditions time is; it is never ignored.
#[test]
fn a_malformed_confirmation_not_before_is_refused_by_name() {
for bad in ["tomorrow", "2024-01-01T00:00:00", "2024-13-01T00:00:00Z"] {
let xml = signed_edited(
|_| {},
|x| {
x.replace(
"<saml:SubjectConfirmationData NotOnOrAfter=",
&format!("<saml:SubjectConfirmationData NotBefore=\"{bad}\" NotOnOrAfter="),
)
},
Sign::Assertion,
);
assert!(xml.contains(&format!("NotBefore=\"{bad}\"")), "the edit took");
let err = verify_ts(&xml, |_| {}).unwrap_err();
assert!(malformed(&err), "{bad:?}: {err:?}");
let words = err.to_string();
assert!(
words.contains("SubjectConfirmationData") && words.contains("NotBefore"),
"{bad:?}: the refusal names the field: {words}"
);
}
// A well-formed NotBefore in the window still verifies.
let xml = signed_edited(
|_| {},
|x| {
x.replace(
"<saml:SubjectConfirmationData NotOnOrAfter=",
"<saml:SubjectConfirmationData NotBefore=\"2024-01-01T00:00:00Z\" NotOnOrAfter=",
)
},
Sign::Assertion,
);
verify_ts(&xml, |_| {}).expect("a good NotBefore");
}
/// A34-35: the clock skew is capped at 300 s whatever the caller asks for.
#[test]
fn the_skew_is_capped_at_five_minutes() {
let xml = signed_with(|_| {});
// Valid NOW-60 .. NOW+300; a day of skew asked for, 300 s given.
let day = 86_400;
verify_ts(&xml, |e| {
e.skew_secs = day;
e.now_unix = NOW + 300 + 299;
})
.expect("inside the 300 s cap after NotOnOrAfter");
let err = verify_ts(&xml, |e| {
e.skew_secs = day;
e.now_unix = NOW + 300 + 301;
})
.unwrap_err();
assert!(
condition(&err) && err.to_string().contains("expired"),
"past the cap: {err:?}"
);
let err = verify_ts(&xml, |e| {
e.skew_secs = day;
e.now_unix = NOW - 60 - 301;
})
.unwrap_err();
assert!(
condition(&err) && err.to_string().contains("not valid yet"),
"before the cap: {err:?}"
);
// A negative skew is no skew.
let err = verify_ts(&xml, |e| {
e.skew_secs = -day;
e.now_unix = NOW + 300;
})
.unwrap_err();
assert!(condition(&err), "{err:?}");
}
/// A34-35: a signed Response must carry a Destination (SAML profiles
/// 4.1.4.5); an assertion signed alone may still leave it out.
#[test]
fn a_signed_response_must_carry_a_destination() {
for shape in [Sign::Response, Sign::Both] {
let xml = signed_edited(|p| p.destination = None, |x| x, shape);
let err = verify_ts(&xml, |_| {}).unwrap_err();
assert!(
condition(&err) && err.to_string().contains("Destination"),
"{shape:?}: {err:?}"
);
let ok = signed_edited(|_| {}, |x| x, shape);
verify_ts(&ok, |_| {}).unwrap_or_else(|e| panic!("{shape:?} with Destination: {e}"));
}
let xml = signed_edited(|p| p.destination = None, |x| x, Sign::Assertion);
verify_ts(&xml, |_| {}).expect("a signed assertion without Destination");
}
/// A34-36: the unsigned StatusMessage (and any unknown status URI) never
/// reaches the words an admin or a user sees: a fixed message by code.
#[test]
fn a_refusal_status_says_only_its_code() {
let lure = "Your account is locked. Call +1 555 0100 or visit https://evil.example/";
let a = fixture("a_assertion_signed.xml").replace(
"<samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"/>",
&format!(
"<samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Requester\">\
<samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:AuthnFailed\"/>\
</samlp:StatusCode><samlp:StatusMessage>{lure}</samlp:StatusMessage>"
),
);
assert!(a.contains(lure), "the edit took");
let e = verify(&a).unwrap_err();
assert!(matches!(e, SpError::Status(_)), "{e:?}");
let words = e.to_string();
assert!(!words.contains("locked") && !words.contains("evil"), "{words}");
assert!(
words.contains("Requester") && words.contains("AuthnFailed"),
"{words}"
);
// A status value that is no SAML status is not echoed either.
let b = fixture("a_assertion_signed.xml").replace(
"urn:oasis:names:tc:SAML:2.0:status:Success",
"https://evil.example/call-us",
);
let words = verify(&b).unwrap_err().to_string();
assert!(!words.contains("evil"), "{words}");
assert!(words.contains("unknown"), "{words}");
}