Skip to main content

document_svg/document/
saml.rs

1//! Bounded SAML 2.0 metadata previews.
2//!
3//! SAML metadata describes identity-provider and service-provider roles. This
4//! adapter reports descriptor and endpoint structure while keeping entity IDs,
5//! certificates, URLs and authentication material inert.
6
7use 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}