| 1 | //! Responses signed by signxml, and one canonicalized by libxml2 itself |
| 2 | //! with inclusive prefixes (saml_fixtures.json), so canonicalization and |
| 3 | //! signatures are checked against implementations other than g1t's. |
| 4 | |
| 5 | use super::*; |
| 6 | use serde_json::Value; |
| 7 | |
| 8 | const ACS: &str = "https://g1t.sh/auth/sso/acme/saml"; |
| 9 | const AUDIENCE: &str = "https://g1t.sh/auth/sso/acme/metadata"; |
| 10 | const IDP: &str = "http://idp.example.test/entity"; |
| 11 | /// 2026-10-17T08:01:00Z, a minute into the fixtures' five. |
| 12 | const NOW: u64 = 1_792_224_060_000; |
| 13 | |
| 14 | fn fixtures() -> Value { |
| 15 | serde_json::from_str(include_str!("saml_fixtures.json")).unwrap() |
| 16 | } |
| 17 | |
| 18 | fn certs(field: &str) -> Vec<Vec<u8>> { |
| 19 | certificates_from(fixtures()[field].as_str().unwrap()).unwrap() |
| 20 | } |
| 21 | |
| 22 | fn expect<'a>(certificates: &'a [Vec<u8>]) -> Expect<'a> { |
| 23 | Expect { idp_entity_id: IDP, certificates, acs_url: ACS, audience: AUDIENCE, request_id: "_req1", now_ms: NOW } |
| 24 | } |
| 25 | |
| 26 | fn response(field: &str) -> String { |
| 27 | fixtures()[field].as_str().unwrap().to_owned() |
| 28 | } |
| 29 | |
| 30 | /// The fixture's XML changed by `edit`, encoded again. |
| 31 | fn edited(field: &str, edit: impl Fn(String) -> String) -> String { |
| 32 | let xml = String::from_utf8(STANDARD.decode(response(field)).unwrap()).unwrap(); |
| 33 | STANDARD.encode(edit(xml)) |
| 34 | } |
| 35 | |
| 36 | #[test] |
| 37 | fn the_clock_in_these_tests_is_right() { |
| 38 | assert_eq!(rfc3339(NOW), "2026-10-17T08:01:00.000Z"); |
| 39 | } |
| 40 | |
| 41 | #[test] |
| 42 | fn canonicalizes_as_libxml2_does() { |
| 43 | for vector in fixtures()["c14n"].as_array().unwrap() { |
| 44 | let xml = vector["xml"].as_str().unwrap(); |
| 45 | let prefixes: Vec<String> = vector["prefixes"] |
| 46 | .as_array() |
| 47 | .unwrap() |
| 48 | .iter() |
| 49 | .map(|p| p.as_str().unwrap()) |
| 50 | .map(|p| if p == "#default" { String::new() } else { p.to_owned() }) |
| 51 | .collect(); |
| 52 | let doc = Document::parse(xml).unwrap(); |
| 53 | let root = doc.root_element(); |
| 54 | assert_eq!(canonicalize(xml, root, None, &prefixes, false), vector["apex"].as_str().unwrap(), "{xml}"); |
| 55 | let first = root.first_element_child().unwrap(); |
| 56 | assert_eq!(canonicalize(xml, first, None, &prefixes, false), vector["child"].as_str().unwrap(), "{xml}"); |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | #[test] |
| 61 | fn a_signed_assertion_names_who_signed_in() { |
| 62 | let certificates = certs("cert"); |
| 63 | for field in ["assertion_signed", "response_signed", "inclusive_signed"] { |
| 64 | let asserted = verify_response(&response(field), &expect(&certificates)).unwrap_or_else(|e| panic!("{field}: {e}")); |
| 65 | assert_eq!(asserted, Asserted { subject: "ada@acme.test".into(), email: Some("ada@acme.test".into()) }, "{field}"); |
| 66 | } |
| 67 | assert_eq!(in_response_to(&response("assertion_signed")).as_deref(), Some("_req1")); |
| 68 | } |
| 69 | |
| 70 | #[test] |
| 71 | fn another_certificate_is_refused() { |
| 72 | let other = certs("other_cert"); |
| 73 | let refused = verify_response(&response("assertion_signed"), &expect(&other)).unwrap_err(); |
| 74 | assert_eq!(refused, BAD_SIGNATURE); |
| 75 | // Rotation: the new and the old together. |
| 76 | let mut both = other.clone(); |
| 77 | both.extend(certs("cert")); |
| 78 | assert!(verify_response(&response("assertion_signed"), &expect(&both)).is_ok()); |
| 79 | } |
| 80 | |
| 81 | #[test] |
| 82 | fn a_changed_assertion_is_refused() { |
| 83 | let certificates = certs("cert"); |
| 84 | for field in ["assertion_signed", "response_signed", "inclusive_signed"] { |
| 85 | let changed = edited(field, |xml| xml.replace(">ada@acme.test<", ">eve@acme.test<")); |
| 86 | let refused = verify_response(&changed, &expect(&certificates)).unwrap_err(); |
| 87 | assert!(refused.contains("changed after it was signed"), "{field}: {refused}"); |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | #[test] |
| 92 | fn an_unsigned_response_is_refused() { |
| 93 | let certificates = certs("cert"); |
| 94 | let unsigned = edited("assertion_signed", |xml| { |
| 95 | let start = xml.find("<ds:Signature").unwrap(); |
| 96 | let end = xml.find("</ds:Signature>").unwrap() + "</ds:Signature>".len(); |
| 97 | format!("{}{}", &xml[..start], &xml[end..]) |
| 98 | }); |
| 99 | assert_eq!(verify_response(&unsigned, &expect(&certificates)).unwrap_err(), NOT_SIGNED); |
| 100 | } |
| 101 | |
| 102 | #[test] |
| 103 | fn a_second_assertion_cannot_ride_along() { |
| 104 | let certificates = certs("cert"); |
| 105 | // Signature wrapping: an unsigned assertion beside the signed one. |
| 106 | let wrapped = edited("response_signed", |xml| { |
| 107 | xml.replacen( |
| 108 | "<saml2:Assertion ", |
| 109 | "<saml2:Assertion ID=\"_evil\" Version=\"2.0\"><saml2:Issuer>http://idp.example.test/entity</saml2:Issuer></saml2:Assertion><saml2:Assertion ", |
| 110 | 1, |
| 111 | ) |
| 112 | }); |
| 113 | assert!(verify_response(&wrapped, &expect(&certificates)).unwrap_err().contains("more than one Assertion")); |
| 114 | // An id used twice. |
| 115 | let twice = edited("assertion_signed", |xml| { |
| 116 | xml.replacen("<saml2p:Status>", "<saml2p:Extensions><x ID=\"_assert1\"/></saml2p:Extensions><saml2p:Status>", 1) |
| 117 | }); |
| 118 | assert!(verify_response(&twice, &expect(&certificates)).unwrap_err().contains("uses an id twice")); |
| 119 | } |
| 120 | |
| 121 | #[test] |
| 122 | fn it_must_be_for_this_sign_in() { |
| 123 | let certificates = certs("cert"); |
| 124 | let signed = response("assertion_signed"); |
| 125 | let mut other = expect(&certificates); |
| 126 | other.request_id = "_req2"; |
| 127 | assert!(verify_response(&signed, &other).unwrap_err().contains("answers another sign-in")); |
| 128 | let mut other = expect(&certificates); |
| 129 | other.audience = "https://g1t.sh/auth/sso/globex/metadata"; |
| 130 | assert!(verify_response(&signed, &other).unwrap_err().contains("not for g1t")); |
| 131 | let mut other = expect(&certificates); |
| 132 | other.acs_url = "https://g1t.sh/auth/sso/globex/saml"; |
| 133 | assert!(verify_response(&signed, &other).unwrap_err().contains("assertion consumer service")); |
| 134 | let mut other = expect(&certificates); |
| 135 | other.idp_entity_id = "http://someone.else/entity"; |
| 136 | assert!(verify_response(&signed, &other).unwrap_err().contains("issued by")); |
| 137 | let mut later = expect(&certificates); |
| 138 | later.now_ms = NOW + 10 * 60 * 1000; |
| 139 | assert!(verify_response(&signed, &later).unwrap_err().contains("expired")); |
| 140 | let mut earlier = expect(&certificates); |
| 141 | earlier.now_ms = NOW - 10 * 60 * 1000; |
| 142 | assert!(verify_response(&signed, &earlier).unwrap_err().contains("not valid yet")); |
| 143 | } |
| 144 | |
| 145 | #[test] |
| 146 | fn a_provider_started_sign_in_is_refused() { |
| 147 | let certificates = certs("cert"); |
| 148 | // Unsigned at the response level, so the edit leaves the signature good. |
| 149 | let unasked = edited("assertion_signed", |xml| xml.replacen(" InResponseTo=\"_req1\"", "", 1)); |
| 150 | assert!(verify_response(&unasked, &expect(&certificates)).unwrap_err().contains("Start single sign-on from g1t")); |
| 151 | } |
| 152 | |
| 153 | #[test] |
| 154 | fn no_dtd_is_read() { |
| 155 | let certificates = certs("cert"); |
| 156 | let xml = "<!DOCTYPE r [<!ENTITY a \"aaaa\">]><samlp:Response xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\">&a;</samlp:Response>"; |
| 157 | assert!(verify_response(&STANDARD.encode(xml), &expect(&certificates)).unwrap_err().contains("not valid XML")); |
| 158 | } |
| 159 | |
| 160 | #[test] |
| 161 | fn certificates_are_read_as_pasted() { |
| 162 | let pem = fixtures()["cert"].as_str().unwrap().to_owned(); |
| 163 | let one = certificates_from(&pem).unwrap(); |
| 164 | assert_eq!(one.len(), 1); |
| 165 | // Bare base64, as some providers download it. |
| 166 | let bare: String = pem.lines().filter(|line| !line.starts_with("-----")).collect(); |
| 167 | assert_eq!(certificates_from(&bare).unwrap(), one); |
| 168 | assert_eq!(certificates_from(&format!("{pem}\n{pem}")).unwrap().len(), 2); |
| 169 | assert!(certificates_from("").is_err()); |
| 170 | assert!(certificates_from("-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----").is_err()); |
| 171 | assert_eq!(fingerprint(&one[0]).len(), 32 * 3 - 1); |
| 172 | } |
| 173 | |
| 174 | #[test] |
| 175 | fn the_request_is_deflated_onto_the_sign_in_url() { |
| 176 | let url = authn_request_url("https://idp.example.test/sso?app=1", "_abc", AUDIENCE, ACS, "state", NOW).unwrap(); |
| 177 | let parsed = worker::Url::parse(&url).unwrap(); |
| 178 | let pairs: std::collections::HashMap<String, String> = parsed.query_pairs().into_owned().collect(); |
| 179 | assert_eq!(pairs["app"], "1"); |
| 180 | assert_eq!(pairs["RelayState"], "state"); |
| 181 | let inflated = miniz_oxide::inflate::decompress_to_vec(&STANDARD.decode(&pairs["SAMLRequest"]).unwrap()).unwrap(); |
| 182 | let request = String::from_utf8(inflated).unwrap(); |
| 183 | assert!(request.contains("ID=\"_abc\"")); |
| 184 | assert!(request.contains("IssueInstant=\"2026-10-17T08:01:00Z\"")); |
| 185 | assert!(request.contains(&format!("AssertionConsumerServiceURL=\"{ACS}\""))); |
| 186 | assert!(request.contains(&format!("<saml:Issuer>{AUDIENCE}</saml:Issuer>"))); |
| 187 | assert!(authn_request_url("http://idp.example.test/sso", "_abc", AUDIENCE, ACS, "s", NOW).is_none()); |
| 188 | } |
| 189 | |
| 190 | #[test] |
| 191 | fn metadata_names_the_assertion_consumer_service() { |
| 192 | let xml = metadata(AUDIENCE, ACS); |
| 193 | let doc = Document::parse(&xml).unwrap(); |
| 194 | assert_eq!(doc.root_element().attribute("entityID"), Some(AUDIENCE)); |
| 195 | assert!(xml.contains(&format!("Location=\"{ACS}\""))); |
| 196 | } |
| 197 | |
| 198 | #[test] |
| 199 | fn a_comment_cannot_shorten_what_was_signed() { |
| 200 | let certificates = certs("cert"); |
| 201 | // The CVE-2017-11427 trick: the digest skips the comment, the reader |
| 202 | // would stop at it. |
| 203 | let injected = edited("assertion_signed", |xml| xml.replacen(">ada@acme.test<", ">ada@acme.test<!---->.evil.io<", 1)); |
| 204 | assert!(verify_response(&injected, &expect(&certificates)).unwrap_err().contains("comments")); |
| 205 | let pi = edited("assertion_signed", |xml| xml.replacen("<saml2:Subject>", "<?x y?><saml2:Subject>", 1)); |
| 206 | assert!(verify_response(&pi, &expect(&certificates)).unwrap_err().contains("processing instructions")); |
| 207 | } |
| 208 | |
| 209 | #[test] |
| 210 | fn utc_times_are_read_however_written() { |
| 211 | assert_eq!(parse_time("2026-10-17T08:01:00Z"), Some(NOW)); |
| 212 | assert_eq!(parse_time("2026-10-17T08:01:00+00:00"), Some(NOW)); |
| 213 | assert_eq!(parse_time("2026-10-17T08:01:00"), Some(NOW)); |
| 214 | assert_eq!(parse_time("2026-10-17T08:01:00.000Z"), Some(NOW)); |
| 215 | assert_eq!(parse_time("2026-10-17T08:01:00+02:00"), None); |
| 216 | } |
| 217 | |
| 218 | #[test] |
| 219 | fn text_is_read_whole() { |
| 220 | let xml = "<a>one<b>two</b> three</a>"; |
| 221 | let doc = Document::parse(xml).unwrap(); |
| 222 | assert_eq!(text(doc.root_element()), "onetwo three"); |
| 223 | } |
| 224 | |
| 225 | #[test] |
| 226 | fn deep_nesting_is_refused_before_canonicalization() { |
| 227 | let certificates = certs("cert"); |
| 228 | let deep = format!( |
| 229 | "<samlp:Response xmlns:samlp=\"{PROTOCOL}\">{}{}</samlp:Response>", |
| 230 | "<x>".repeat(100), |
| 231 | "</x>".repeat(100) |
| 232 | ); |
| 233 | assert!(verify_response(&STANDARD.encode(deep), &expect(&certificates)).unwrap_err().contains("nests too deeply")); |
| 234 | } |