use uppsala::{Document, NodeId, NodeKind};
use crate::xml::error::XmlError;
pub trait SamlDeserialize<'a>: Sized {
fn from_xml(doc: &'a Document<'a>, node: NodeId) -> Result<Self, XmlError>;
}
pub fn parse_saml<'a, T: SamlDeserialize<'a>>(doc: &'a Document<'a>) -> Result<T, XmlError> {
let root = doc.document_element().ok_or(XmlError::EmptyDocument)?;
T::from_xml(doc, root)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SecureParseConfig {
pub max_depth: u32,
pub max_entity_expansion: usize,
pub forbid_dtd: bool,
pub forbid_entities: bool,
pub forbid_comments: bool,
pub forbid_pis: bool,
pub forbid_cdata: bool,
}
impl Default for SecureParseConfig {
fn default() -> Self {
Self {
max_depth: uppsala::parser::DEFAULT_MAX_DEPTH,
max_entity_expansion: uppsala::parser::DEFAULT_MAX_ENTITY_EXPANSION,
forbid_dtd: true,
forbid_entities: true,
forbid_comments: true,
forbid_pis: true,
forbid_cdata: true,
}
}
}
impl SecureParseConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_max_depth(mut self, max_depth: u32) -> Self {
self.max_depth = max_depth;
self
}
pub fn with_max_entity_expansion(mut self, max_bytes: usize) -> Self {
self.max_entity_expansion = max_bytes;
self
}
pub fn with_forbid_dtd(mut self, forbid: bool) -> Self {
self.forbid_dtd = forbid;
self
}
pub fn with_forbid_entities(mut self, forbid: bool) -> Self {
self.forbid_entities = forbid;
self
}
pub fn with_forbid_comments(mut self, forbid: bool) -> Self {
self.forbid_comments = forbid;
self
}
pub fn with_forbid_pis(mut self, forbid: bool) -> Self {
self.forbid_pis = forbid;
self
}
pub fn with_forbid_cdata(mut self, forbid: bool) -> Self {
self.forbid_cdata = forbid;
self
}
fn parser(self) -> uppsala::Parser {
uppsala::Parser::new()
.with_max_depth(self.max_depth)
.with_max_entity_expansion(self.max_entity_expansion)
.with_forbid_dtd(self.forbid_dtd)
.with_forbid_entities(self.forbid_entities)
}
}
pub fn parse_secure(xml: &str) -> Result<Document<'_>, uppsala::XmlError> {
parse_secure_with_config(xml, &SecureParseConfig::default())
}
pub fn parse_secure_metadata(xml: &str) -> Result<Document<'_>, uppsala::XmlError> {
let config = SecureParseConfig::default()
.with_forbid_comments(false)
.with_forbid_pis(false);
let doc = parse_secure_with_config(xml, &config)?;
reject_split_metadata_text(&doc)?;
Ok(doc)
}
fn reject_split_metadata_text(doc: &Document<'_>) -> Result<(), uppsala::XmlError> {
for parent in doc.descendants(doc.root()) {
let mut saw_meaningful_text = false;
let mut separator_after_text = false;
for child in doc.children(parent) {
match doc.node_kind(child) {
Some(NodeKind::Text(value) | NodeKind::CData(value))
if !value.trim().is_empty() =>
{
if separator_after_text {
return Err(uppsala::XmlError::well_formedness(
"metadata comment or processing instruction split element text",
0,
0,
));
}
saw_meaningful_text = true;
}
Some(NodeKind::Comment(_) | NodeKind::ProcessingInstruction(_))
if saw_meaningful_text =>
{
separator_after_text = true;
}
_ => {}
}
}
}
Ok(())
}
pub fn parse_secure_with_config<'a>(
xml: &'a str,
config: &SecureParseConfig,
) -> Result<Document<'a>, uppsala::XmlError> {
let doc = config.parser().parse(xml)?;
if config.forbid_comments || config.forbid_pis || config.forbid_cdata {
reject_forbidden_nodes(&doc, config)?;
}
Ok(doc)
}
fn reject_forbidden_nodes(
doc: &Document<'_>,
config: &SecureParseConfig,
) -> Result<(), uppsala::XmlError> {
for id in doc.descendants(doc.root()) {
match doc.node_kind(id) {
Some(NodeKind::Comment(_)) if config.forbid_comments => {
return Err(uppsala::XmlError::well_formedness(
"document contained illegal XML comments",
0,
0,
));
}
Some(NodeKind::ProcessingInstruction(_)) if config.forbid_pis => {
return Err(uppsala::XmlError::well_formedness(
"document contained illegal processing instructions",
0,
0,
));
}
Some(NodeKind::CData(_)) if config.forbid_cdata => {
return Err(uppsala::XmlError::well_formedness(
"document contained illegal CDATA sections",
0,
0,
));
}
_ => {}
}
}
Ok(())
}
#[cfg(test)]
mod parse_secure_tests {
use super::{parse_secure, parse_secure_metadata, parse_secure_with_config, SecureParseConfig};
#[test]
fn secure_config_defaults_to_saml_safe_policy() {
let config = SecureParseConfig::default();
assert_eq!(config.max_depth, uppsala::parser::DEFAULT_MAX_DEPTH);
assert_eq!(
config.max_entity_expansion,
uppsala::parser::DEFAULT_MAX_ENTITY_EXPANSION
);
assert!(config.forbid_dtd);
assert!(config.forbid_entities);
assert!(config.forbid_comments);
assert!(config.forbid_pis);
assert!(config.forbid_cdata);
}
#[test]
fn rejects_xml_comment() {
let xml = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"><!-- x --></samlp:Response>"#;
let err = parse_secure(xml).expect_err("comment-bearing document must be rejected");
assert!(
err.to_string().contains("illegal XML comments"),
"got: {err}"
);
}
#[test]
fn rejects_processing_instruction() {
let xml = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"><?php evil ?></samlp:Response>"#;
let err = parse_secure(xml).expect_err("PI-bearing document must be rejected");
assert!(
err.to_string().contains("illegal processing instructions"),
"got: {err}"
);
}
#[test]
fn rejects_embedded_comment_in_nameid() {
let xml = r#"<saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">victim@example.com<!---->.evil.com</saml:NameID>"#;
let err = parse_secure(xml).expect_err("comment in NameID must be rejected");
assert!(
err.to_string().contains("illegal XML comments"),
"got: {err}"
);
}
#[test]
fn rejects_cdata_section() {
let xml = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"><![CDATA[x]]></samlp:Response>"#;
let err = parse_secure(xml).expect_err("CDATA-bearing document must be rejected");
assert!(
err.to_string().contains("illegal CDATA sections"),
"got: {err}"
);
}
#[test]
fn rejects_embedded_cdata_in_nameid() {
let xml = r#"<saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">victim@example.com<![CDATA[.evil.com]]></saml:NameID>"#;
let err = parse_secure(xml).expect_err("CDATA in NameID must be rejected");
assert!(
err.to_string().contains("illegal CDATA sections"),
"got: {err}"
);
}
#[test]
fn accepts_xml_declaration_which_is_not_a_pi() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?><samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_1"/>"#;
assert!(parse_secure(xml).is_ok());
}
#[test]
fn metadata_allows_structural_comments_but_rejects_split_text() {
assert!(parse_secure_metadata("<Entities><!-- provenance --><Entity/></Entities>").is_ok());
let xml = "<AdditionalMetadataLocation>https://safe.example/<!--x-->evil</AdditionalMetadataLocation>";
let err = parse_secure_metadata(xml).expect_err("split metadata text must be rejected");
assert!(err.to_string().contains("split element text"));
}
#[test]
fn metadata_many_structural_comments_are_scanned_linearly() {
let comments = "<!--x-->".repeat(10_000);
let xml = format!(
r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://sp.example.com">{comments}</md:EntityDescriptor>"#
);
assert!(parse_secure_metadata(&xml).is_ok());
}
#[test]
fn explicit_policy_can_allow_comments_and_pis() {
let xml = r#"<Response><!-- ok --><?pi ok ?><![CDATA[ok]]></Response>"#;
assert!(parse_secure(xml).is_err());
let config = SecureParseConfig::new()
.with_forbid_comments(false)
.with_forbid_pis(false)
.with_forbid_cdata(false);
assert!(parse_secure_with_config(xml, &config).is_ok());
}
#[test]
fn rejects_doctype_declaration() {
let xml = r#"<?xml version="1.0"?>
<!DOCTYPE samlp:Response [ <!ENTITY x "expanded"> ]>
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">&x;</samlp:Response>"#;
assert!(
uppsala::parse(xml).is_ok(),
"precondition: the DTD-bearing document is itself well-formed"
);
assert!(
parse_secure(xml).is_err(),
"parse_secure must reject the document solely because of the DTD"
);
}
#[test]
fn rejects_internal_subset_without_entities() {
let xml = r#"<!DOCTYPE Response><Response/>"#;
assert!(parse_secure(xml).is_err());
}
#[test]
fn reports_doctype_position_from_parser() {
let err = parse_secure("<?xml version=\"1.0\"?>\n<!DOCTYPE x [ ]>\n<x/>")
.expect_err("DTD-bearing document must be rejected");
assert!(
err.to_string().contains("at 2:1"),
"error should point at the DOCTYPE declaration, got: {err}"
);
}
#[test]
fn accepts_well_formed_saml_without_dtd() {
let xml = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_1"/>"#;
let doc = parse_secure(xml).expect("DTD-free SAML must parse");
assert!(doc.document_element().is_some());
}
#[test]
fn explicit_policy_can_tighten_depth_limit() {
let xml = "<a><b/></a>";
assert!(parse_secure(xml).is_ok());
let config = SecureParseConfig::new().with_max_depth(1);
assert!(parse_secure_with_config(xml, &config).is_err());
}
#[test]
fn explicit_policy_can_allow_dtd_but_reject_entities() {
let xml = r#"<!DOCTYPE Response [ <!ENTITY x "expanded"> ]><Response/>"#;
let config = SecureParseConfig::new()
.with_forbid_dtd(false)
.with_forbid_entities(true);
assert!(parse_secure_with_config(xml, &config).is_err());
}
}