1use std::path::Path;
8
9use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
10use crate::document::html::{HtmlBlock, render_blocks_to_pages};
11use crate::error::{Error, Result};
12use crate::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
13use crate::table::{TableAlign, TableData};
14
15const MAX_SAML_BYTES: u64 = 128 * 1024 * 1024;
16const MAX_SAML_EVENTS: usize = 1_000_000;
17const MAX_SAML_NODES: usize = 500_000;
18const MAX_SAML_DEPTH: usize = 128;
19const MAX_SAML_TEXT_BYTES: usize = 32 * 1024 * 1024;
20const MAX_SAML_ROWS: usize = 200_000;
21const MAX_SAML_DISPLAY_BYTES: usize = 512;
22const SAML_NAMESPACE: &str = "urn:oasis:names:tc:SAML:2.0:metadata";
23
24#[derive(Default)]
25struct Summary {
26 entities: usize,
27 idp_descriptors: usize,
28 sp_descriptors: usize,
29 role_descriptors: usize,
30 endpoints: usize,
31 keys: usize,
32 certificates: usize,
33 attributes: usize,
34 organizations: usize,
35 contacts: usize,
36 signatures: usize,
37 rows: Vec<Vec<String>>,
38}
39
40struct SamlPageSink<'a> {
41 inner: &'a mut dyn PageConsumer,
42 warnings: &'a [String],
43}
44impl PageConsumer for SamlPageSink<'_> {
45 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
46 page.source_format = "saml".into();
47 if page.title.is_empty() {
48 page.title = "SAML metadata".into();
49 }
50 page.description = "SAML identity-provider/service-provider metadata is rendered as bounded inert structure; endpoints and certificates are not exposed".into();
51 for warning in self.warnings {
52 page.warn(warning.clone());
53 }
54 self.inner.consume(page)
55 }
56}
57
58pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
59 let text = String::from_utf8_lossy(prefix).to_ascii_lowercase();
60 (text.contains("entitydescriptor") || text.contains("entitiesdescriptor"))
61 && text.contains("saml:2.0:metadata")
62}
63
64pub(crate) fn convert(
65 path: &Path,
66 options: &ConvertOptions,
67 sink: &mut dyn PageConsumer,
68) -> Result<Vec<String>> {
69 let bytes = read_limited_file(
70 path,
71 options.max_input_bytes.min(MAX_SAML_BYTES),
72 "SAML input",
73 )?;
74 let root = parse_xml_tree(
75 &bytes,
76 &XmlLimits {
77 max_events: options.max_xml_events.min(MAX_SAML_EVENTS),
78 max_nodes: MAX_SAML_NODES,
79 max_depth: MAX_SAML_DEPTH,
80 max_text_bytes: MAX_SAML_TEXT_BYTES,
81 },
82 "SAML",
83 )?;
84 if !root.name.eq_ignore_ascii_case("EntityDescriptor")
85 && !root.name.eq_ignore_ascii_case("EntitiesDescriptor")
86 {
87 return Err(Error::InvalidInput(
88 "SAML root must be EntityDescriptor or EntitiesDescriptor".into(),
89 ));
90 }
91 if root.namespace.as_deref() != Some(SAML_NAMESPACE) {
92 return Err(Error::InvalidInput(
93 "SAML root uses an unsupported metadata namespace".into(),
94 ));
95 }
96 let mut summary = Summary {
97 entities: count_named(&root, "EntityDescriptor")
98 + usize::from(root.name.eq_ignore_ascii_case("EntityDescriptor")),
99 idp_descriptors: count_named(&root, "IDPSSODescriptor"),
100 sp_descriptors: count_named(&root, "SPSSODescriptor"),
101 role_descriptors: count_named(&root, "RoleDescriptor"),
102 endpoints: count_named(&root, "SingleSignOnService")
103 + count_named(&root, "SingleLogoutService")
104 + count_named(&root, "AssertionConsumerService")
105 + count_named(&root, "ArtifactResolutionService"),
106 keys: count_named(&root, "KeyDescriptor") + count_named(&root, "KeyInfo"),
107 certificates: count_named(&root, "X509Certificate"),
108 attributes: count_named(&root, "Attribute") + count_named(&root, "RequestedAttribute"),
109 organizations: count_named(&root, "Organization"),
110 contacts: count_named(&root, "ContactPerson"),
111 signatures: count_named(&root, "Signature"),
112 ..Summary::default()
113 };
114 push_row(
115 &mut summary.rows,
116 "Entities",
117 &format!("entities={}", summary.entities),
118 &format!(
119 "IdP={} SP={} roles={}",
120 summary.idp_descriptors, summary.sp_descriptors, summary.role_descriptors
121 ),
122 )?;
123 push_row(
124 &mut summary.rows,
125 "Endpoints",
126 &summary.endpoints.to_string(),
127 &format!(
128 "keys={} certificates={}",
129 summary.keys, summary.certificates
130 ),
131 )?;
132 push_row(
133 &mut summary.rows,
134 "Attributes",
135 &summary.attributes.to_string(),
136 &format!(
137 "organizations={} contacts={}",
138 summary.organizations, summary.contacts
139 ),
140 )?;
141 push_row(
142 &mut summary.rows,
143 "Integrity",
144 &summary.signatures.to_string(),
145 "signatures and entity IDs omitted",
146 )?;
147 let blocks = vec![HtmlBlock::Heading { level: 1, text: "SAML metadata".into() }, HtmlBlock::Paragraph { text: "SAML identity metadata structure is summarized without exposing endpoints, certificates or authentication material.".into() }, HtmlBlock::Table(TableData { headers: vec!["Kind".into(), "Value".into(), "Detail".into()], rows: summary.rows, alignments: vec![TableAlign::Left; 3], raw_source: String::new() })];
148 let warnings = vec!["SAML entity IDs, endpoint URLs, certificates, bindings, organization/contact values, attributes and signatures are omitted or redacted".into(), "SAML metadata imports, schema locations, signature verification, IdP/SP discovery, authentication and network requests never run".into()];
149 let mut page_sink = SamlPageSink {
150 inner: sink,
151 warnings: &warnings,
152 };
153 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
154 Ok(warnings)
155}
156
157fn count_named(element: &XmlElement, name: &str) -> usize {
158 element
159 .children
160 .iter()
161 .map(|child| usize::from(child.name.eq_ignore_ascii_case(name)) + count_named(child, name))
162 .sum()
163}
164fn push_row(rows: &mut Vec<Vec<String>>, kind: &str, value: &str, detail: &str) -> Result<()> {
165 if rows.len() >= MAX_SAML_ROWS {
166 return Err(Error::LimitExceeded(format!(
167 "SAML rows exceed {MAX_SAML_ROWS}"
168 )));
169 }
170 rows.push(vec![truncate(kind), truncate(value), truncate(detail)]);
171 Ok(())
172}
173fn truncate(value: &str) -> String {
174 if value.len() <= MAX_SAML_DISPLAY_BYTES {
175 return value.to_owned();
176 }
177 let mut end = MAX_SAML_DISPLAY_BYTES;
178 while end > 0 && !value.is_char_boundary(end) {
179 end -= 1;
180 }
181 format!("{}…", &value[..end])
182}