Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

234 linesCodeBlameRaw
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
5use super::*;
6use serde_json::Value;
7
8const ACS: &str = "https://g1t.sh/auth/sso/acme/saml";
9const AUDIENCE: &str = "https://g1t.sh/auth/sso/acme/metadata";
10const IDP: &str = "http://idp.example.test/entity";
11/// 2026-10-17T08:01:00Z, a minute into the fixtures' five.
12const NOW: u64 = 1_792_224_060_000;
13
14fn fixtures() -> Value {
15 serde_json::from_str(include_str!("saml_fixtures.json")).unwrap()
16}
17
18fn certs(field: &str) -> Vec<Vec<u8>> {
19 certificates_from(fixtures()[field].as_str().unwrap()).unwrap()
20}
21
22fn 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
26fn response(field: &str) -> String {
27 fixtures()[field].as_str().unwrap().to_owned()
28}
29
30/// The fixture's XML changed by `edit`, encoded again.
31fn 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]
37fn the_clock_in_these_tests_is_right() {
38 assert_eq!(rfc3339(NOW), "2026-10-17T08:01:00.000Z");
39}
40
41#[test]
42fn 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]
61fn 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]
71fn 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]
82fn 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]
92fn 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]
103fn 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]
122fn 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]
146fn 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]
154fn 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]
161fn 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]
175fn 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]
191fn 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]
199fn 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]
210fn 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]
219fn 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]
226fn 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}