CVE-2021-27736: Improper Restriction of XML External Entity Reference (CWE-611)
value: FusionAuth fusionauth-samlv2 before 0.5.4 allows XXE attacks via a forged AuthnRequest or LogoutRequest because parseFromBytes uses javax.xml.parsers.DocumentBuilderFactory unsafely.
CWE description: The product processes an XML document that can contain XML entities with URIs that resolve to documents outside of the intended sphere of control, causing the product to embed incorrect documents into its output.
CVSS v3 base score: 6.5
Published: 2021-04-22T14:15Z
Repository: https://github.com/FusionAuth/fusionauth-samlv2
Language: XML
Files changed: fusionauth-samlv2.iml, src/main/java/io/fusionauth/samlv2/service/DefaultSAMLv2Service.java, src/main/java/io/fusionauth/samlv2/util/SAMLTools.java, src/test/java/io/fusionauth/samlv2/service/DefaultSAMLv2ServiceTest.java, src/test/xml/authn-request-expanded-entity.xml, src/test/xml/authn-request-external-dtd.xml, src/test/xml/authn-request-has-doctype.xml, src/test/xml/metadata.xml
Commit date: 2021-02-06 19:10:15 -0700
Commit message: Refactor utility methods into SAMLTools, add tests.

Diff: diff --git a/fusionauth-samlv2.iml b/fusionauth-samlv2.iml
index d0777d1..cf87041 100644
--- a/fusionauth-samlv2.iml
+++ b/fusionauth-samlv2.iml
@@ -4,11 +4,11 @@
     <output url="file://$MODULE_DIR$/build/classes/main" />
     <output-test url="file://$MODULE_DIR$/build/classes/test" />
     <exclude-output />
     <content url="file://$MODULE_DIR$">
       <sourceFolder url="file://$MODULE_DIR$/src/main/java" isTestSource="false" />
-      <sourceFolder url="file://$MODULE_DIR$/src/main/resources" isTestSource="false" />
+      <sourceFolder url="file://$MODULE_DIR$/src/main/resources" type="java-resource" />
       <sourceFolder url="file://$MODULE_DIR$/src/test/java" isTestSource="true" />
       <sourceFolder url="file://$MODULE_DIR$/src/test/resources" isTestSource="true" />
       <excludeFolder url="file://$MODULE_DIR$/.gradle" />
       <excludeFolder url="file://$MODULE_DIR$/build" />
     </content>
@@ -134,7 +134,6 @@
           <root url="jar://$USER_HOME$/.savant/cache/org/jvnet/staxex/stax-ex/1.8.1/stax-ex-1.8.1-src.jar!/" />
         </SOURCES>
       </library>
     </orderEntry>
   </component>
-</module>
-
+</module>
\ No newline at end of file
diff --git a/src/main/java/io/fusionauth/samlv2/service/DefaultSAMLv2Service.java b/src/main/java/io/fusionauth/samlv2/service/DefaultSAMLv2Service.java
index 8b530b7..5b8e477 100644
--- a/src/main/java/io/fusionauth/samlv2/service/DefaultSAMLv2Service.java
+++ b/src/main/java/io/fusionauth/samlv2/service/DefaultSAMLv2Service.java
@@ -13,15 +13,11 @@
  * either express or implied. See the License for the specific
  * language governing permissions and limitations under the License.
  */
 package io.fusionauth.samlv2.service;
 
-import javax.xml.bind.JAXBContext;
 import javax.xml.bind.JAXBElement;
-import javax.xml.bind.JAXBException;
-import javax.xml.bind.Marshaller;
-import javax.xml.bind.Unmarshaller;
 import javax.xml.crypto.KeySelector;
 import javax.xml.crypto.MarshalException;
 import javax.xml.crypto.dsig.CanonicalizationMethod;
 import javax.xml.crypto.dsig.DigestMethod;
 import javax.xml.crypto.dsig.Reference;
@@ -35,34 +31,20 @@ import javax.xml.crypto.dsig.dom.DOMValidateContext;
 import javax.xml.crypto.dsig.keyinfo.KeyInfo;
 import javax.xml.crypto.dsig.keyinfo.KeyInfoFactory;
 import javax.xml.crypto.dsig.keyinfo.X509Data;
 import javax.xml.crypto.dsig.spec.C14NMethodParameterSpec;
 import javax.xml.crypto.dsig.spec.TransformParameterSpec;
-import javax.xml.datatype.XMLGregorianCalendar;
-import javax.xml.parsers.DocumentBuilder;
-import javax.xml.parsers.DocumentBuilderFactory;
-import javax.xml.parsers.ParserConfigurationException;
-import javax.xml.transform.Transformer;
 import javax.xml.transform.TransformerException;
-import javax.xml.transform.TransformerFactory;
-import javax.xml.transform.dom.DOMSource;
-import javax.xml.transform.stream.StreamResult;
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.StringWriter;
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.security.GeneralSecurityException;
 import java.security.InvalidAlgorithmParameterException;
 import java.security.NoSuchAlgorithmException;
 import java.security.PrivateKey;
 import java.security.Signature;
 import java.security.cert.Certificate;
-import java.security.cert.CertificateException;
-import java.security.cert.CertificateFactory;
 import java.security.cert.X509Certificate;
 import java.time.ZoneOffset;
 import java.time.ZonedDateTime;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -73,13 +55,10 @@ import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.UUID;
 import java.util.function.Function;
 import java.util.stream.Collectors;
-import java.util.zip.DataFormatException;
-import java.util.zip.Deflater;
-import java.util.zip.Inflater;
 
 import com.sun.org.apache.xerces.internal.jaxp.datatype.XMLGregorianCalendarImpl;
 import io.fusionauth.samlv2.domain.Algorithm;
 import io.fusionauth.samlv2.domain.AuthenticationRequest;
 import io.fusionauth.samlv2.domain.AuthenticationResponse;
@@ -87,11 +66,10 @@ import io.fusionauth.samlv2.domain.Binding;
 import io.fusionauth.samlv2.domain.Conditions;
 import io.fusionauth.samlv2.domain.ConfirmationMethod;
 import io.fusionauth.samlv2.domain.MetaData;
 import io.fusionauth.samlv2.domain.MetaData.IDPMetaData;
 import io.fusionauth.samlv2.domain.MetaData.SPMetaData;
-import io.fusionauth.samlv2.domain.NameID;
 import io.fusionauth.samlv2.domain.NameIDFormat;
 import io.fusionauth.samlv2.domain.ResponseStatus;
 import io.fusionauth.samlv2.domain.SAMLException;
 import io.fusionauth.samlv2.domain.SignatureLocation;
 import io.fusionauth.samlv2.domain.SignatureNotFoundException;
@@ -126,19 +104,29 @@ import io.fusionauth.samlv2.domain.jaxb.oasis.protocol.ObjectFactory;
 import io.fusionauth.samlv2.domain.jaxb.oasis.protocol.ResponseType;
 import io.fusionauth.samlv2.domain.jaxb.oasis.protocol.StatusCodeType;
 import io.fusionauth.samlv2.domain.jaxb.oasis.protocol.StatusType;
 import io.fusionauth.samlv2.domain.jaxb.w3c.xmldsig.KeyInfoType;
 import io.fusionauth.samlv2.domain.jaxb.w3c.xmldsig.X509DataType;
+import io.fusionauth.samlv2.util.SAMLTools;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.w3c.dom.Attr;
 import org.w3c.dom.Document;
 import org.w3c.dom.Element;
 import org.w3c.dom.NamedNodeMap;
 import org.w3c.dom.Node;
 import org.w3c.dom.NodeList;
-import org.xml.sax.SAXException;
+import static io.fusionauth.samlv2.util.SAMLTools.convertToZonedDateTime;
+import static io.fusionauth.samlv2.util.SAMLTools.decodeAndInflate;
+import static io.fusionauth.samlv2.util.SAMLTools.marshallToBytes;
+import static io.fusionauth.samlv2.util.SAMLTools.marshallToDocument;
+import static io.fusionauth.samlv2.util.SAMLTools.marshallToString;
+import static io.fusionauth.samlv2.util.SAMLTools.newDocumentFromBytes;
+import static io.fusionauth.samlv2.util.SAMLTools.parseNameId;
+import static io.fusionauth.samlv2.util.SAMLTools.toXMLGregorianCalendar;
+import static io.fusionauth.samlv2.util.SAMLTools.toZonedDateTime;
+import static io.fusionauth.samlv2.util.SAMLTools.unmarshallFromDocument;
 
 /**
  * Default implementation of the SAML service.
  *
  * @author Brian Pontarelli
@@ -409,11 +397,11 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
     return buildRedirectAuthnRequest(authnRequest, relayState, sign, key, algorithm);
   }
 
   @Override
   public MetaData parseMetaData(String metaDataXML) throws SAMLException {
-    Document document = parseFromBytes(metaDataXML.getBytes(StandardCharsets.UTF_8));
+    Document document = newDocumentFromBytes(metaDataXML.getBytes(StandardCharsets.UTF_8));
     EntityDescriptorType root = unmarshallFromDocument(document, EntityDescriptorType.class);
     MetaData metaData = new MetaData();
     metaData.id = root.getID();
     metaData.entityId = root.getEntityID();
 
@@ -448,11 +436,11 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
       // Extract the signing certificates
       try {
         metaData.idp.certificates = idp.getKeyDescriptor()
                                        .stream()
                                        .filter(kd -> kd.getUse() == KeyTypes.SIGNING)
-                                       .map(this::toCertificate)
+                                       .map(SAMLTools::toCertificate)
                                        .filter(Objects::nonNull)
                                        .collect(Collectors.toList());
       } catch (IllegalArgumentException e) {
         // toPublicKey might throw this and we want to translate it back to a known exception
         throw new SAMLException(e.getCause());
@@ -530,11 +518,11 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
 
     AuthenticationResponse response = new AuthenticationResponse();
     byte[] decodedResponse = Base64.getMimeDecoder().decode(encodedResponse);
     response.rawResponse = new String(decodedResponse, StandardCharsets.UTF_8);
 
-    Document document = parseFromBytes(decodedResponse);
+    Document document = newDocumentFromBytes(decodedResponse);
     if (verifySignature) {
       verifySignature(document, keySelector);
     }
 
     ResponseType jaxbResponse = unmarshallFromDocument(document, ResponseType.class);
@@ -611,11 +599,11 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
           for (Object attributeObject : attributeObjects) {
             if (attributeObject instanceof AttributeType) {
               AttributeType attributeType = (AttributeType) attributeObject;
               String name = attributeType.getName();
               List<Object> attributeValues = attributeType.getAttributeValue();
-              List<String> values = attributeValues.stream().map(this::attributeToString).collect(Collectors.toList());
+              List<String> values = attributeValues.stream().map(SAMLTools::attributeToString).collect(Collectors.toList());
               response.assertion.attributes.computeIfAbsent(name, k -> new ArrayList<>()).addAll(values);
             } else {
               throw new SAMLException("This library currently doesn't support encrypted attributes");
             }
           }
@@ -644,28 +632,10 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
         throw new IllegalArgumentException(e);
       }
     });
   }
 
-  private String attributeToString(Object attribute) {
-    if (attribute == null) {
-      return null;
-    }
-
-    if (attribute instanceof Number) {
-      return attribute.toString();
-    } else if (attribute instanceof String) {
-      return (String) attribute;
-    } else if (attribute instanceof Element) {
-      return ((Element) attribute).getTextContent();
-    } else {
-      logger.warn("This library currently doesn't handle attributes of type [" + attribute.getClass() + "]");
-    }
-
-    return null;
-  }
-
   private String buildPostAuthnRequest(AuthnRequestType authnRequest, boolean sign, PrivateKey privateKey,
                                        X509Certificate certificate,
                                        Algorithm algorithm, String xmlSignatureC14nMethod) throws SAMLException {
     Document document = marshallToDocument(PROTOCOL_OBJECT_FACTORY.createAuthnRequest(authnRequest), AuthnRequestType.class);
     try {
@@ -685,11 +655,11 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
 
   private String buildRedirectAuthnRequest(AuthnRequestType authnRequest, String relayState, boolean sign,
                                            PrivateKey key, Algorithm algorithm) throws SAMLException {
     try {
       byte[] xml = marshallToBytes(PROTOCOL_OBJECT_FACTORY.createAuthnRequest(authnRequest), AuthnRequestType.class);
-      String encodedResult = deflateAndEncode(xml);
+      String encodedResult = SAMLTools.deflateAndEncode(xml);
       String parameters = "SAMLRequest=" + URLEncoder.encode(encodedResult, "UTF-8");
       if (relayState != null) {
         parameters += "&RelayState=" + URLEncoder.encode(relayState, "UTF-8");
       }
 
@@ -709,47 +679,10 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
       // Not possible but freak out
       throw new SAMLException(e);
     }
   }
 
-  private ZonedDateTime convertToZonedDateTime(XMLGregorianCalendar cal) {
-    return cal != null ? cal.toGregorianCalendar().toZonedDateTime() : null;
-  }
-
-  private byte[] decodeAndInflate(String encodedRequest) throws SAMLException {
-    byte[] bytes = Base64.getMimeDecoder().decode(encodedRequest);
-    Inflater inflater = new Inflater(true);
-    inflater.setInput(bytes);
-    inflater.finished();
-
-    try {
-      ByteArrayOutputStream baos = new ByteArrayOutputStream();
-      byte[] result = new byte[bytes.length];
-      while (!inflater.finished()) {
-        int length = inflater.inflate(result);
-        if (length > 0) {
-          baos.write(result, 0, length);
-        }
-      }
-
-      return baos.toByteArray();
-    } catch (DataFormatException e) {
-      throw new SAMLException("Invalid AuthnRequest. Inflating the bytes failed.", e);
-    }
-  }
-
-  private String deflateAndEncode(byte[] result) {
-    Deflater deflater = new Deflater(Deflater.DEFLATED, true);
-    deflater.setInput(result);
-    deflater.finish();
-    byte[] deflatedResult = new byte[result.length];
-    int length = deflater.deflate(deflatedResult);
-    deflater.end();
-    byte[] src = Arrays.copyOf(deflatedResult, length);
-    return Base64.getEncoder().encodeToString(src);
-  }
-
   private void fixIDs(Element element) {
     NamedNodeMap attributes = element.getAttributes();
     for (int i = 0; i < attributes.getLength(); i++) {
       Attr attribute = (Attr) attributes.item(i);
       if (attribute.getLocalName().equalsIgnoreCase("id")) {
@@ -764,46 +697,10 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
         fixIDs((Element) child);
       }
     }
   }
 
-  private <T> byte[] marshallToBytes(JAXBElement<T> object, Class<T> type) throws SAMLException {
-    try {
-      JAXBContext context = JAXBContext.newInstance(type);
-      Marshaller marshaller = context.createMarshaller();
-      ByteArrayOutputStream baos = new ByteArrayOutputStream();
-      marshaller.marshal(object, baos);
-      return baos.toByteArray();
-    } catch (JAXBException e) {
-      throw new SAMLException("Unable to marshallRequest JAXB SAML object to bytes.", e);
-    }
-  }
-
-  @SuppressWarnings("SameParameterValue")
-  private <T> Document marshallToDocument(JAXBElement<T> object, Class<T> type) throws SAMLException {
-    try {
-      JAXBContext context = JAXBContext.newInstance(type);
-      Marshaller marshaller = context.createMarshaller();
-      DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
-      dbf.setNamespaceAware(true);
-      DocumentBuilder db = dbf.newDocumentBuilder();
-      Document document = db.newDocument();
-      marshaller.marshal(object, document);
-      return document;
-    } catch (JAXBException | ParserConfigurationException e) {
-      throw new SAMLException("Unable to marshallRequest JAXB SAML object to DOM.", e);
-    }
-  }
-
-  private String marshallToString(Document document) throws TransformerException {
-    StringWriter sw = new StringWriter();
-    TransformerFactory tf = TransformerFactory.newInstance();
-    Transformer transformer = tf.newTransformer();
-    transformer.transform(new DOMSource(document), new StreamResult(sw));
-    return sw.toString();
-  }
-
   private SubjectConfirmation parseConfirmation(SubjectConfirmationType subjectConfirmationType) {
     SubjectConfirmation subjectConfirmation = new SubjectConfirmation();
     SubjectConfirmationDataType data = subjectConfirmationType.getSubjectConfirmationData();
     if (data != null) {
       subjectConfirmation.address = data.getAddress();
@@ -816,36 +713,18 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
     subjectConfirmation.method = ConfirmationMethod.fromSAMLFormat(subjectConfirmationType.getMethod());
 
     return subjectConfirmation;
   }
 
-  private Document parseFromBytes(byte[] bytes) throws SAMLException {
-    DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
-    documentBuilderFactory.setNamespaceAware(true);
-    try {
-      DocumentBuilder builder = documentBuilderFactory.newDocumentBuilder();
-      return builder.parse(new ByteArrayInputStream(bytes));
-    } catch (ParserConfigurationException | SAXException | IOException e) {
-      throw new SAMLException("Unable to parse SAML v2.0 authentication response", e);
-    }
-  }
-
-  private NameID parseNameId(NameIDType element) {
-    NameID nameId = new NameID();
-    nameId.format = NameIDFormat.fromSAMLFormat(element.getFormat());
-    nameId.id = element.getValue();
-    return nameId;
-  }
-
   private AuthnRequestParseResult parseRequest(byte[] xmlBytes) throws SAMLException {
     String xml = new String(xmlBytes, StandardCharsets.UTF_8);
     if (logger.isDebugEnabled()) {
       logger.debug("SAMLRequest XML is\n{}", xml);
     }
 
     AuthnRequestParseResult result = new AuthnRequestParseResult();
-    result.document = parseFromBytes(xmlBytes);
+    result.document = newDocumentFromBytes(xmlBytes);
     result.authnRequest = unmarshallFromDocument(result.document, AuthnRequestType.class);
     result.request = new AuthenticationRequest();
     result.request.xml = xml;
     result.request.id = result.authnRequest.getID();
     result.request.issuer = result.authnRequest.getIssuer().getValue();
@@ -905,64 +784,10 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
     authnRequest.setVersion(version);
     authnRequest.setIssueInstant(new XMLGregorianCalendarImpl(GregorianCalendar.from(ZonedDateTime.now())));
     return authnRequest;
   }
 
-  private Certificate toCertificate(KeyDescriptorType keyDescriptorType) {
-    try {
-      List<Object> keyData = keyDescriptorType.getKeyInfo().getContent();
-      for (Object keyDatum : keyData) {
-        if (keyDatum instanceof JAXBElement<?>) {
-          JAXBElement<?> element = (JAXBElement<?>) keyDatum;
-          if (element.getDeclaredType() == X509DataType.class) {
-            X509DataType cert = (X509DataType) element.getValue();
-            List<Object> certData = cert.getX509IssuerSerialOrX509SKIOrX509SubjectName();
-            for (Object certDatum : certData) {
-              element = (JAXBElement<?>) certDatum;
-              if (element.getName().getLocalPart().equals("X509Certificate")) {
-                byte[] certBytes = (byte[]) element.getValue();
-                CertificateFactory cf = CertificateFactory.getInstance("X.509");
-                return cf.generateCertificate(new ByteArrayInputStream(certBytes));
-              }
-            }
-          }
-        }
-      }
-
-      return null;
-    } catch (CertificateException e) {
-      throw new IllegalArgumentException(e);
-    }
-  }
-
-  private XMLGregorianCalendar toXMLGregorianCalendar(ZonedDateTime instant) {
-    if (instant == null) {
-      return null;
-    }
-
-    return new XMLGregorianCalendarImpl(GregorianCalendar.from(instant));
-  }
-
-  private ZonedDateTime toZonedDateTime(XMLGregorianCalendar instant) {
-    if (instant == null) {
-      return null;
-    }
-
-    return instant.toGregorianCalendar().toZonedDateTime();
-  }
-
-  private <T> T unmarshallFromDocument(Document document, Class<T> type) throws SAMLException {
-    try {
-      JAXBContext context = JAXBContext.newInstance(type);
-      Unmarshaller unmarshaller = context.createUnmarshaller();
-      JAXBElement<T> element = unmarshaller.unmarshal(document, type);
-      return element.getValue();
-    } catch (JAXBException e) {
-      throw new SAMLException("Unable to unmarshall SAML response", e);
-    }
-  }
-
   private void verifySignature(Document document, KeySelector keySelector) throws SAMLException {
     // Fix the IDs in the entire document per the suggestions at http://stackoverflow.com/questions/17331187/xml-dig-sig-error-after-upgrade-to-java7u25
     fixIDs(document.getDocumentElement());
 
     NodeList nl = document.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature");
diff --git a/src/main/java/io/fusionauth/samlv2/util/SAMLTools.java b/src/main/java/io/fusionauth/samlv2/util/SAMLTools.java
new file mode 100644
index 0000000..75d56f4
--- /dev/null
+++ b/src/main/java/io/fusionauth/samlv2/util/SAMLTools.java
@@ -0,0 +1,442 @@
+/*
+ * Copyright (c) 2021, Inversoft Inc., All Rights Reserved
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the License.
+ */
+package io.fusionauth.samlv2.util;
+
+import javax.xml.XMLConstants;
+import javax.xml.bind.JAXBContext;
+import javax.xml.bind.JAXBElement;
+import javax.xml.bind.JAXBException;
+import javax.xml.bind.Marshaller;
+import javax.xml.bind.Unmarshaller;
+import javax.xml.datatype.XMLGregorianCalendar;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.transform.Source;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerException;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+import javax.xml.validation.Schema;
+import javax.xml.validation.SchemaFactory;
+import javax.xml.validation.Validator;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.StringWriter;
+import java.net.URL;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.GregorianCalendar;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.DataFormatException;
+import java.util.zip.Deflater;
+import java.util.zip.Inflater;
+
+import com.sun.org.apache.xerces.internal.jaxp.datatype.XMLGregorianCalendarImpl;
+import io.fusionauth.samlv2.domain.NameID;
+import io.fusionauth.samlv2.domain.NameIDFormat;
+import io.fusionauth.samlv2.domain.SAMLException;
+import io.fusionauth.samlv2.domain.jaxb.oasis.assertion.NameIDType;
+import io.fusionauth.samlv2.domain.jaxb.oasis.metadata.KeyDescriptorType;
+import io.fusionauth.samlv2.domain.jaxb.w3c.xmldsig.X509DataType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.xml.sax.ErrorHandler;
+import org.xml.sax.SAXException;
+import org.xml.sax.SAXNotRecognizedException;
+import org.xml.sax.SAXNotSupportedException;
+import org.xml.sax.SAXParseException;
+import static javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING;
+
+/**
+ * @author Daniel DeGroff
+ */
+public class SAMLTools {
+  private static final Map<String, Boolean> FactoryAttributes = new HashMap<>();
+
+  private static final Logger logger = LoggerFactory.getLogger(SAMLTools.class);
+
+  static {
+    FactoryAttributes.put("http://xml.org/sax/features/external-general-entities", false);
+    FactoryAttributes.put("http://xml.org/sax/features/external-parameter-entities", false);
+    FactoryAttributes.put("http://apache.org/xml/features/disallow-doctype-decl", true);
+    FactoryAttributes.put("http://javax.xml.XMLConstants/feature/secure-processing", true);
+    FactoryAttributes.put("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
+    FactoryAttributes.put("http://apache.org/xml/features/nonvalidating/load-dtd-grammar", false);
+  }
+
+  /**
+   * Convert an attribute to a string.
+   *
+   * @param attribute the attribute in object form
+   * @return a string version of the attribute.
+   */
+  public static String attributeToString(Object attribute) {
+    if (attribute == null) {
+      return null;
+    }
+
+    if (attribute instanceof Number) {
+      return attribute.toString();
+    } else if (attribute instanceof String) {
+      return (String) attribute;
+    } else if (attribute instanceof Element) {
+      return ((Element) attribute).getTextContent();
+    } else {
+      logger.warn("This library currently doesn't handle attributes of type [" + attribute.getClass() + "]");
+    }
+
+    return null;
+  }
+
+  /**
+   * Convert a calendar object of type <code>XMLGregorianCalendar</code> to a ZonedDateTime.
+   *
+   * @param cal a calendar object
+   * @return a zoned date time object
+   */
+  public static ZonedDateTime convertToZonedDateTime(XMLGregorianCalendar cal) {
+    return cal != null ? cal.toGregorianCalendar().toZonedDateTime() : null;
+  }
+
+  /**
+   * Decode and inflate the encoded request.
+   *
+   * @param encodedRequest the encoded request
+   * @return a decoded and inflated request as bytes
+   * @throws SAMLException if $%#! goes south
+   */
+  public static byte[] decodeAndInflate(String encodedRequest) throws SAMLException {
+    byte[] bytes = Base64.getMimeDecoder().decode(encodedRequest);
+    Inflater inflater = new Inflater(true);
+    inflater.setInput(bytes);
+    inflater.finished();
+
+    try {
+      ByteArrayOutputStream os = new ByteArrayOutputStream();
+      byte[] result = new byte[bytes.length];
+      while (!inflater.finished()) {
+        int length = inflater.inflate(result);
+        if (length > 0) {
+          os.write(result, 0, length);
+        }
+      }
+
+      return os.toByteArray();
+    } catch (DataFormatException e) {
+      throw new SAMLException("Invalid AuthnRequest. Inflating the bytes failed.", e);
+    }
+  }
+
+  /**
+   * Deflate and encode the provided byte array.
+   *
+   * @param bytes the byte array to deflate and encode.
+   * @return an encoded string
+   */
+  public static String deflateAndEncode(byte[] bytes) {
+    Deflater deflater = new Deflater(Deflater.DEFLATED, true);
+    deflater.setInput(bytes);
+    deflater.finish();
+    byte[] deflatedResult = new byte[bytes.length];
+    int length = deflater.deflate(deflatedResult);
+    deflater.end();
+    byte[] src = Arrays.copyOf(deflatedResult, length);
+    return Base64.getEncoder().encodeToString(src);
+  }
+
+  /**
+   * Serialize the JAXBElement to a byte array.
+   *
+   * @param object the JAXB element.
+   * @param type   the class of the element.
+   * @param <T>    the type of the element.
+   * @return a byte array
+   * @throws SAMLException if $%#! goes south.
+   */
+  public static <T> byte[] marshallToBytes(JAXBElement<T> object, Class<T> type) throws SAMLException {
+    try {
+      JAXBContext context = JAXBContext.newInstance(type);
+      Marshaller marshaller = context.createMarshaller();
+      ByteArrayOutputStream os = new ByteArrayOutputStream();
+      marshaller.marshal(object, os);
+      return os.toByteArray();
+    } catch (JAXBException e) {
+      throw new SAMLException("Unable to marshallRequest JAXB SAML object to bytes.", e);
+    }
+  }
+
+  /**
+   * Marshall the JAXB element to a Document.
+   *
+   * @param object the JAXB element.
+   * @param type   the class of the element.
+   * @param <T>    the type of the element.
+   * @return a document
+   * @throws SAMLException if $%#! goes south
+   */
+  public static <T> Document marshallToDocument(JAXBElement<T> object, Class<T> type) throws SAMLException {
+    try {
+      JAXBContext context = JAXBContext.newInstance(type);
+      Marshaller marshaller = context.createMarshaller();
+      Document document = newDocumentBuilder().newDocument();
+      marshaller.marshal(object, document);
+
+      return document;
+    } catch (JAXBException | SAMLException e) {
+      throw new SAMLException("Unable to marshallRequest JAXB SAML object to DOM.", e);
+    }
+  }
+
+  /**
+   * Serialize the provided document.
+   *
+   * @param document the document to serialize.
+   * @return a string form of the serialized document.
+   */
+  public static String marshallToString(Document document) throws TransformerException {
+    StringWriter sw = new StringWriter();
+    TransformerFactory tf = TransformerFactory.newInstance();
+    Transformer transformer = tf.newTransformer();
+    transformer.transform(new DOMSource(document), new StreamResult(sw));
+    return sw.toString();
+  }
+
+  /**
+   * Return a new document builder
+   *
+   * @return a document builder
+   * @throws SAMLException if $%#! goes south
+   */
+  public static DocumentBuilder newDocumentBuilder() throws SAMLException {
+    // https://cheatsheetseries.owasp.org/cheatsheets/SAML_Security_Cheat_Sheet.html
+    // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html#SAXTransformerFactory
+    // https://web-in-security.blogspot.com/2014/11/detecting-and-exploiting-xxe-in-saml.html
+
+    try {
+      DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+      dbf.setNamespaceAware(true);
+
+      // Do not expand entity references
+      dbf.setExpandEntityReferences(false);
+
+      // Set default attributes
+      for (String key : FactoryAttributes.keySet()) {
+        try {
+          dbf.setAttribute(key, FactoryAttributes.get(key));
+        } catch (IllegalArgumentException e) {
+          // The parser may not recognize this attribute.
+          logger.debug("Failed to set attribute [" + key + "=" + FactoryAttributes.get(key) + "]. This may be expected if the parser does not recognize this attribute.", e);
+        }
+      }
+
+      // Enabling 'secure processing' disables loading of external DTD and external Schema.
+      // - See constructor body on line 177 of DocumentBuilderImpl.
+      dbf.setFeature(FEATURE_SECURE_PROCESSING, true);
+
+      return dbf.newDocumentBuilder();
+    } catch (ParserConfigurationException e) {
+      throw new SAMLException("Unable to configure the DocumentBuilderFactory with feature [" + FEATURE_SECURE_PROCESSING + "].", e);
+    }
+  }
+
+  /**
+   * Parse the provided bytes into a document.
+   *
+   * @param bytes the bytes
+   * @return a document
+   * @throws SAMLException if $%#! goes south
+   */
+  public static Document newDocumentFromBytes(byte[] bytes) throws SAMLException {
+    try {
+      return newDocumentBuilder().parse(new ByteArrayInputStream(bytes));
+    } catch (SAXException | IOException e) {
+      throw new SAMLException("Unable to parse SAML v2.0 authentication response", e);
+    }
+  }
+
+  /**
+   * Parse a NameIdType element and return a NameID enum.
+   *
+   * @param element the nameId element
+   * @return a nameId enum value
+   */
+  public static NameID parseNameId(NameIDType element) {
+    NameID nameId = new NameID();
+    nameId.format = NameIDFormat.fromSAMLFormat(element.getFormat());
+    nameId.id = element.getValue();
+    return nameId;
+  }
+
+  /**
+   * Convert a key descriptor type to a certificate
+   *
+   * @param keyDescriptorType the key descriptor type
+   * @return a certificate or null if it could not be converted.
+   */
+  public static Certificate toCertificate(KeyDescriptorType keyDescriptorType) {
+    try {
+      List<Object> keyData = keyDescriptorType.getKeyInfo().getContent();
+      for (Object keyDatum : keyData) {
+        if (keyDatum instanceof JAXBElement<?>) {
+          JAXBElement<?> element = (JAXBElement<?>) keyDatum;
+          if (element.getDeclaredType() == X509DataType.class) {
+            X509DataType cert = (X509DataType) element.getValue();
+            List<Object> certData = cert.getX509IssuerSerialOrX509SKIOrX509SubjectName();
+            for (Object certDatum : certData) {
+              element = (JAXBElement<?>) certDatum;
+              if (element.getName().getLocalPart().equals("X509Certificate")) {
+                byte[] certBytes = (byte[]) element.getValue();
+                CertificateFactory cf = CertificateFactory.getInstance("X.509");
+                return cf.generateCertificate(new ByteArrayInputStream(certBytes));
+              }
+            }
+          }
+        }
+      }
+
+      return null;
+    } catch (CertificateException e) {
+      throw new IllegalArgumentException(e);
+    }
+  }
+
+  /**
+   * Convert a ZonedDateTime instant to an XMlGregorianCalendar object.
+   *
+   * @param instant the instant
+   * @return a calendar object.
+   */
+  public static XMLGregorianCalendar toXMLGregorianCalendar(ZonedDateTime instant) {
+    if (instant == null) {
+      return null;
+    }
+
+    return new XMLGregorianCalendarImpl(GregorianCalendar.from(instant));
+  }
+
+  /**
+   * Convert an XMLGregorianCalendar instant to a ZoneDateTime instant
+   *
+   * @param instant the instant
+   * @return a ZoneDateTime object
+   */
+  public static ZonedDateTime toZonedDateTime(XMLGregorianCalendar instant) {
+    if (instant == null) {
+      return null;
+    }
+
+    return instant.toGregorianCalendar().toZonedDateTime();
+  }
+
+  /**
+   * Convert a document to a JAXB Element
+   *
+   * @param document the XML document
+   * @param type     the class of the JAXB element to marshal the document to
+   * @param <T>      the type
+   * @return an object of type T
+   * @throws SAMLException if $%#! goes south
+   */
+  public static <T> T unmarshallFromDocument(Document document, Class<T> type) throws SAMLException {
+    try {
+      JAXBContext context = JAXBContext.newInstance(type);
+      Unmarshaller unmarshaller = context.createUnmarshaller();
+      JAXBElement<T> element = unmarshaller.unmarshal(document, type);
+      return element.getValue();
+    } catch (JAXBException e) {
+      throw new SAMLException("Unable to unmarshall SAML response", e);
+    }
+  }
+
+  /**
+   * Validate the document.
+   *
+   * @param document  the document
+   * @param schemaURI the schema URI
+   * @param errors    the errors object used to collect all errors from the validator.
+   * @return true if the validation was successful, false otherwise.
+   * @throws SAMLException if $%#! goes south
+   */
+  public static boolean validate(Document document, URL schemaURI, SchemaValidationErrors errors) throws SAMLException {
+    Schema schema;
+    try {
+      schema = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI).newSchema(schemaURI);
+    } catch (SAXException e) {
+      throw new SAMLException("An invalid schema was requested. Schema [" + schemaURI + "].", e);
+    }
+
+    Validator validator = schema.newValidator();
+    validator.setErrorHandler(errors);
+
+    // Disable external DTD while validating.
+    try {
+      validator.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
+    } catch (SAXNotRecognizedException | SAXNotSupportedException ignore) {
+      // Not supported by the parser
+    }
+
+    // Disable external Schema while validating.
+    try {
+      validator.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
+    } catch (SAXNotRecognizedException | SAXNotSupportedException ignore) {
+      // Not supported by the parser
+    }
+
+    Source source = new DOMSource(document);
+    try {
+      validator.validate(source);
+    } catch (IOException | SAXException e) {
+      throw new SAMLException("Failed to validate the document source.", e);
+    }
+
+    return errors.error.isEmpty() && errors.fatal.isEmpty() && errors.warning.isEmpty();
+  }
+
+  public static class SchemaValidationErrors implements ErrorHandler {
+    public final List<SAXParseException> error = new ArrayList<>();
+
+    public final List<SAXParseException> fatal = new ArrayList<>();
+
+    public final List<SAXParseException> warning = new ArrayList<>();
+
+    @Override
+    public void error(SAXParseException exception) {
+      error.add(exception);
+    }
+
+    @Override
+    public void fatalError(SAXParseException exception) {
+      fatal.add(exception);
+    }
+
+    @Override
+    public void warning(SAXParseException exception) {
+      warning.add(exception);
+    }
+  }
+}
diff --git a/src/test/java/io/fusionauth/samlv2/service/DefaultSAMLv2ServiceTest.java b/src/test/java/io/fusionauth/samlv2/service/DefaultSAMLv2ServiceTest.java
index 26ccd50..30c3fd3 100644
--- a/src/test/java/io/fusionauth/samlv2/service/DefaultSAMLv2ServiceTest.java
+++ b/src/test/java/io/fusionauth/samlv2/service/DefaultSAMLv2ServiceTest.java
@@ -22,17 +22,20 @@ import javax.xml.crypto.KeySelector;
 import javax.xml.crypto.dsig.CanonicalizationMethod;
 import javax.xml.crypto.dsig.XMLSignature;
 import javax.xml.parsers.DocumentBuilder;
 import javax.xml.parsers.DocumentBuilderFactory;
 import javax.xml.parsers.ParserConfigurationException;
+import java.io.BufferedWriter;
 import java.io.ByteArrayInputStream;
+import java.io.FileWriter;
 import java.io.IOException;
 import java.io.InputStream;
 import java.math.BigInteger;
 import java.net.URLDecoder;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
+import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.security.KeyFactory;
 import java.security.KeyPair;
 import java.security.KeyPairGenerator;
 import java.security.PublicKey;
@@ -58,10 +61,11 @@ import io.fusionauth.samlv2.domain.MetaData.SPMetaData;
 import io.fusionauth.samlv2.domain.NameIDFormat;
 import io.fusionauth.samlv2.domain.ResponseStatus;
 import io.fusionauth.samlv2.domain.SAMLException;
 import io.fusionauth.samlv2.domain.SignatureLocation;
 import io.fusionauth.samlv2.domain.jaxb.oasis.protocol.AuthnRequestType;
+import io.fusionauth.samlv2.util.SAMLTools;
 import org.testng.annotations.BeforeClass;
 import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 import org.w3c.dom.Document;
 import org.w3c.dom.Node;
@@ -254,10 +258,74 @@ public class DefaultSAMLv2ServiceTest {
     assertEquals(request.issuer, "urn:example:sp");
     assertEquals(request.nameIdFormat, NameIDFormat.EmailAddress);
     assertEquals(request.version, "2.0");
   }
 
+  @Test
+  public void parseRequest_expandedEntity() throws Exception {
+    // Expanded entity, fail. The entity definition is within the DOCTYPE, which is not allowed, the error will be with regards to the DOCTYPE.
+    try {
+      DefaultSAMLv2Service service = new DefaultSAMLv2Service();
+      byte[] xml = Files.readAllBytes(Paths.get("src/test/xml/authn-request-expanded-entity.xml"));
+      String deflated = SAMLTools.deflateAndEncode(xml);
+      AuthenticationRequest request = service.parseRequestRedirectBinding(deflated, null, authRequest -> new TestRedirectBindingSignatureHelper());
+      fail("Expected an exception because we are declaring a DOCTYPE and expanding an entity. The issuer is now set to [" + request.issuer + "] which is not good.");
+    } catch (SAMLException e) {
+      assertEquals(e.getMessage(), "Unable to parse SAML v2.0 authentication response");
+      assertEquals(e.getCause().getClass().getCanonicalName(), "org.xml.sax.SAXParseException");
+      assertEquals(e.getCause().getMessage(), "DOCTYPE is disallowed when the feature \"http://apache.org/xml/features/disallow-doctype-decl\" set to true.");
+    }
+  }
+
+  @Test
+  public void parseRequest_externalDTD() throws Exception {
+    // Load an external DTD, fail, this is defined within the DOCTYPE, so the error will be with regards to the DOCTYPE.
+    Path tempFile = null;
+    try {
+      tempFile = Files.createTempFile("readThisFile", ".tmp");
+      try (BufferedWriter writer = new BufferedWriter(new FileWriter(tempFile.toFile()))) {
+        writer.write("You've been pwned.");
+      }
+
+      DefaultSAMLv2Service service = new DefaultSAMLv2Service();
+      byte[] xml = Files.readAllBytes(Paths.get("src/test/xml/authn-request-external-dtd.xml"));
+
+      // Set the filename in the XML
+      String xmlString = new String(xml);
+      xmlString = xmlString.replace("{{tempFile}}", tempFile.toFile().getAbsolutePath());
+      xml = xmlString.getBytes(StandardCharsets.UTF_8);
+
+      String deflated = SAMLTools.deflateAndEncode(xml);
+      AuthenticationRequest request = service.parseRequestRedirectBinding(deflated, null, authRequest -> new TestRedirectBindingSignatureHelper());
+      fail("Expected an exception because we are declaring a DOCTYPE. The issuer is now set to [" + request.issuer + "] which is not good.");
+    } catch (SAMLException e) {
+      assertEquals(e.getMessage(), "Unable to parse SAML v2.0 authentication response");
+      assertEquals(e.getCause().getClass().getCanonicalName(), "org.xml.sax.SAXParseException");
+      assertEquals(e.getCause().getMessage(), "DOCTYPE is disallowed when the feature \"http://apache.org/xml/features/disallow-doctype-decl\" set to true.");
+    } finally {
+      if (tempFile != null) {
+        Files.deleteIfExists(tempFile);
+      }
+    }
+  }
+
+  @Test
+  public void parseRequest_hasDocType() throws Exception {
+    // Has DOCTYPE, fail. No DOCTYPE for you!
+    try {
+      DefaultSAMLv2Service service = new DefaultSAMLv2Service();
+      byte[] xml = Files.readAllBytes(Paths.get("src/test/xml/authn-request-has-doctype.xml"));
+      String deflated = SAMLTools.deflateAndEncode(xml);
+      service.parseRequestRedirectBinding(deflated, null, authRequest -> new TestRedirectBindingSignatureHelper());
+      fail("expected an exception because we are declaring a DOCTYPE");
+    } catch (SAMLException e) {
+      assertEquals(e.getMessage(), "Unable to parse SAML v2.0 authentication response");
+      assertEquals(e.getCause().getClass().getCanonicalName(), "org.xml.sax.SAXParseException");
+      assertEquals(e.getCause().getMessage(), "DOCTYPE is disallowed when the feature \"http://apache.org/xml/features/disallow-doctype-decl\" set to true.");
+    }
+  }
+
   @Test(dataProvider = "maxLineLength")
   public void parseRequest_includeLineReturns(int maxLineLength) throws Exception {
     String xml = new String(Files.readAllBytes(Paths.get("src/test/xml/authn-request-control.xml")));
     String encodedXML = new String(Files.readAllBytes(Paths.get("src/test/xml/deflated/authn-request-control.txt")));
 
diff --git a/src/test/xml/authn-request-expanded-entity.xml b/src/test/xml/authn-request-expanded-entity.xml
new file mode 100644
index 0000000..e4a3bfa
--- /dev/null
+++ b/src/test/xml/authn-request-expanded-entity.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE foo [ <!ENTITY x "You've been pwned." >]>
+<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
+                    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
+                    ID="_809707f0030a5d00620c9d9df97f627afe9dcc24"
+                    Version="2.0"
+                    ProviderName="SP test"
+                    IssueInstant="2014-07-16T23:52:45Z"
+                    Destination="http://idp.example.com/SSOService.php"
+                    ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+                    AssertionConsumerServiceURL="http://sp.example.com/demo1/index.php?acs">
+  <saml:Issuer>&x;</saml:Issuer>
+  <samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" AllowCreate="true"/>
+  <samlp:RequestedAuthnContext Comparison="exact">
+    <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
+  </samlp:RequestedAuthnContext>
+</samlp:AuthnRequest>
\ No newline at end of file
diff --git a/src/test/xml/authn-request-external-dtd.xml b/src/test/xml/authn-request-external-dtd.xml
new file mode 100644
index 0000000..131c0f5
--- /dev/null
+++ b/src/test/xml/authn-request-external-dtd.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "{{tempFile}}" >]>
+<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
+                    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
+                    ID="_809707f0030a5d00620c9d9df97f627afe9dcc24"
+                    Version="2.0"
+                    ProviderName="SP test"
+                    IssueInstant="2014-07-16T23:52:45Z"
+                    Destination="http://idp.example.com/SSOService.php"
+                    ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+                    AssertionConsumerServiceURL="http://sp.example.com/demo1/index.php?acs">
+  <saml:Issuer>&xxe;</saml:Issuer>
+  <samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" AllowCreate="true"/>
+  <samlp:RequestedAuthnContext Comparison="exact">
+    <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
+  </samlp:RequestedAuthnContext>
+</samlp:AuthnRequest>
\ No newline at end of file
diff --git a/src/test/xml/authn-request-has-doctype.xml b/src/test/xml/authn-request-has-doctype.xml
new file mode 100644
index 0000000..064b207
--- /dev/null
+++ b/src/test/xml/authn-request-has-doctype.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE MyDocType >
+<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
+                    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
+                    ID="_809707f0030a5d00620c9d9df97f627afe9dcc24"
+                    Version="2.0"
+                    ProviderName="SP test"
+                    IssueInstant="2014-07-16T23:52:45Z"
+                    Destination="http://idp.example.com/SSOService.php"
+                    ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+                    AssertionConsumerServiceURL="http://sp.example.com/demo1/index.php?acs">
+  <saml:Issuer>http://sp.example.com/demo1/metadata.php</saml:Issuer>
+  <samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" AllowCreate="true"/>
+  <samlp:RequestedAuthnContext Comparison="exact">
+    <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
+  </samlp:RequestedAuthnContext>
+</samlp:AuthnRequest>
\ No newline at end of file
diff --git a/src/test/xml/metadata.xml b/src/test/xml/metadata.xml
index aff7507..d9e5590 100644
--- a/src/test/xml/metadata.xml
+++ b/src/test/xml/metadata.xml
@@ -1,20 +1,5 @@
-<!--
-  ~ Copyright (c) 2019, Inversoft Inc., All Rights Reserved
-  ~
-  ~ Licensed under the Apache License, Version 2.0 (the "License");
-  ~ you may not use this file except in compliance with the License.
-  ~ You may obtain a copy of the License at
-  ~
-  ~   http://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing,
-  ~ software distributed under the License is distributed on an
-  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
-  ~ either express or implied. See the License for the specific
-  ~ language governing permissions and limitations under the License.
-  -->
 <EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" ID="_258d6b11-8d5b-4da6-9f05-9b481e4a5ea8" entityID="https://sts.windows.net/c2150111-3c44-4508-9f08-790cb4032a23/">
   <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
     <SignedInfo>
       <CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
       <SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>

Vulnerable code:       <sourceFolder url="file://$MODULE_DIR$/src/main/resources" isTestSource="false" />
</module>

import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Marshaller;
import javax.xml.bind.Unmarshaller;
import javax.xml.datatype.XMLGregorianCalendar;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.StringWriter;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.util.zip.DataFormatException;
import java.util.zip.Deflater;
import java.util.zip.Inflater;
import io.fusionauth.samlv2.domain.NameID;
import org.xml.sax.SAXException;
    Document document = parseFromBytes(metaDataXML.getBytes(StandardCharsets.UTF_8));
                                       .map(this::toCertificate)
    Document document = parseFromBytes(decodedResponse);
              List<String> values = attributeValues.stream().map(this::attributeToString).collect(Collectors.toList());
  private String attributeToString(Object attribute) {
    if (attribute == null) {
      return null;
    }

    if (attribute instanceof Number) {
      return attribute.toString();
    } else if (attribute instanceof String) {
      return (String) attribute;
    } else if (attribute instanceof Element) {
      return ((Element) attribute).getTextContent();
    } else {
      logger.warn("This library currently doesn't handle attributes of type [" + attribute.getClass() + "]");
    }

    return null;
  }

      String encodedResult = deflateAndEncode(xml);
  private ZonedDateTime convertToZonedDateTime(XMLGregorianCalendar cal) {
    return cal != null ? cal.toGregorianCalendar().toZonedDateTime() : null;
  }

  private byte[] decodeAndInflate(String encodedRequest) throws SAMLException {
    byte[] bytes = Base64.getMimeDecoder().decode(encodedRequest);
    Inflater inflater = new Inflater(true);
    inflater.setInput(bytes);
    inflater.finished();

    try {
      ByteArrayOutputStream baos = new ByteArrayOutputStream();
      byte[] result = new byte[bytes.length];
      while (!inflater.finished()) {
        int length = inflater.inflate(result);
        if (length > 0) {
          baos.write(result, 0, length);
        }
      }

      return baos.toByteArray();
    } catch (DataFormatException e) {
      throw new SAMLException("Invalid AuthnRequest. Inflating the bytes failed.", e);
    }
  }

  private String deflateAndEncode(byte[] result) {
    Deflater deflater = new Deflater(Deflater.DEFLATED, true);
    deflater.setInput(result);
    deflater.finish();
    byte[] deflatedResult = new byte[result.length];
    int length = deflater.deflate(deflatedResult);
    deflater.end();
    byte[] src = Arrays.copyOf(deflatedResult, length);
    return Base64.getEncoder().encodeToString(src);
  }

  private <T> byte[] marshallToBytes(JAXBElement<T> object, Class<T> type) throws SAMLException {
    try {
      JAXBContext context = JAXBContext.newInstance(type);
      Marshaller marshaller = context.createMarshaller();
      ByteArrayOutputStream baos = new ByteArrayOutputStream();
      marshaller.marshal(object, baos);
      return baos.toByteArray();
    } catch (JAXBException e) {
      throw new SAMLException("Unable to marshallRequest JAXB SAML object to bytes.", e);
    }
  }

  @SuppressWarnings("SameParameterValue")
  private <T> Document marshallToDocument(JAXBElement<T> object, Class<T> type) throws SAMLException {
    try {
      JAXBContext context = JAXBContext.newInstance(type);
      Marshaller marshaller = context.createMarshaller();
      DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
      dbf.setNamespaceAware(true);
      DocumentBuilder db = dbf.newDocumentBuilder();
      Document document = db.newDocument();
      marshaller.marshal(object, document);
      return document;
    } catch (JAXBException | ParserConfigurationException e) {
      throw new SAMLException("Unable to marshallRequest JAXB SAML object to DOM.", e);
    }
  }

  private String marshallToString(Document document) throws TransformerException {
    StringWriter sw = new StringWriter();
    TransformerFactory tf = TransformerFactory.newInstance();
    Transformer transformer = tf.newTransformer();
    transformer.transform(new DOMSource(document), new StreamResult(sw));
    return sw.toString();
  }

  private Document parseFromBytes(byte[] bytes) throws SAMLException {
    DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
    documentBuilderFactory.setNamespaceAware(true);
    try {
      DocumentBuilder builder = documentBuilderFactory.newDocumentBuilder();
      return builder.parse(new ByteArrayInputStream(bytes));
    } catch (ParserConfigurationException | SAXException | IOException e) {
      throw new SAMLException("Unable to parse SAML v2.0 authentication response", e);
    }
  }

  private NameID parseNameId(NameIDType element) {
    NameID nameId = new NameID();
    nameId.format = NameIDFormat.fromSAMLFormat(element.getFormat());
    nameId.id = element.getValue();
    return nameId;
  }

    result.document = parseFromBytes(xmlBytes);
  private Certificate toCertificate(KeyDescriptorType keyDescriptorType) {
    try {
      List<Object> keyData = keyDescriptorType.getKeyInfo().getContent();
      for (Object keyDatum : keyData) {
        if (keyDatum instanceof JAXBElement<?>) {
          JAXBElement<?> element = (JAXBElement<?>) keyDatum;
          if (element.getDeclaredType() == X509DataType.class) {
            X509DataType cert = (X509DataType) element.getValue();
            List<Object> certData = cert.getX509IssuerSerialOrX509SKIOrX509SubjectName();
            for (Object certDatum : certData) {
              element = (JAXBElement<?>) certDatum;
              if (element.getName().getLocalPart().equals("X509Certificate")) {
                byte[] certBytes = (byte[]) element.getValue();
                CertificateFactory cf = CertificateFactory.getInstance("X.509");
                return cf.generateCertificate(new ByteArrayInputStream(certBytes));
              }
            }
          }
        }
      }

      return null;
    } catch (CertificateException e) {
      throw new IllegalArgumentException(e);
    }
  }

  private XMLGregorianCalendar toXMLGregorianCalendar(ZonedDateTime instant) {
    if (instant == null) {
      return null;
    }

    return new XMLGregorianCalendarImpl(GregorianCalendar.from(instant));
  }

  private ZonedDateTime toZonedDateTime(XMLGregorianCalendar instant) {
    if (instant == null) {
      return null;
    }

    return instant.toGregorianCalendar().toZonedDateTime();
  }

  private <T> T unmarshallFromDocument(Document document, Class<T> type) throws SAMLException {
    try {
      JAXBContext context = JAXBContext.newInstance(type);
      Unmarshaller unmarshaller = context.createUnmarshaller();
      JAXBElement<T> element = unmarshaller.unmarshal(document, type);
      return element.getValue();
    } catch (JAXBException e) {
      throw new SAMLException("Unable to unmarshall SAML response", e);
    }
  }

<!--
  ~ Copyright (c) 2019, Inversoft Inc., All Rights Reserved
  ~
  ~ Licensed under the Apache License, Version 2.0 (the "License");
  ~ you may not use this file except in compliance with the License.
  ~ You may obtain a copy of the License at
  ~
  ~   http://www.apache.org/licenses/LICENSE-2.0
  ~
  ~ Unless required by applicable law or agreed to in writing,
  ~ software distributed under the License is distributed on an
  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
  ~ either express or implied. See the License for the specific
  ~ language governing permissions and limitations under the License.
  -->
Fixed code:       <sourceFolder url="file://$MODULE_DIR$/src/main/resources" type="java-resource" />
</module>
import io.fusionauth.samlv2.util.SAMLTools;
import static io.fusionauth.samlv2.util.SAMLTools.convertToZonedDateTime;
import static io.fusionauth.samlv2.util.SAMLTools.decodeAndInflate;
import static io.fusionauth.samlv2.util.SAMLTools.marshallToBytes;
import static io.fusionauth.samlv2.util.SAMLTools.marshallToDocument;
import static io.fusionauth.samlv2.util.SAMLTools.marshallToString;
import static io.fusionauth.samlv2.util.SAMLTools.newDocumentFromBytes;
import static io.fusionauth.samlv2.util.SAMLTools.parseNameId;
import static io.fusionauth.samlv2.util.SAMLTools.toXMLGregorianCalendar;
import static io.fusionauth.samlv2.util.SAMLTools.toZonedDateTime;
import static io.fusionauth.samlv2.util.SAMLTools.unmarshallFromDocument;
    Document document = newDocumentFromBytes(metaDataXML.getBytes(StandardCharsets.UTF_8));
                                       .map(SAMLTools::toCertificate)
    Document document = newDocumentFromBytes(decodedResponse);
              List<String> values = attributeValues.stream().map(SAMLTools::attributeToString).collect(Collectors.toList());
      String encodedResult = SAMLTools.deflateAndEncode(xml);
    result.document = newDocumentFromBytes(xmlBytes);
/*
 * Copyright (c) 2021, Inversoft Inc., All Rights Reserved
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
 * either express or implied. See the License for the specific
 * language governing permissions and limitations under the License.
 */
package io.fusionauth.samlv2.util;

import javax.xml.XMLConstants;
import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBElement;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Marshaller;
import javax.xml.bind.Unmarshaller;
import javax.xml.datatype.XMLGregorianCalendar;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Source;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import javax.xml.validation.Validator;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.StringWriter;
import java.net.URL;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.DataFormatException;
import java.util.zip.Deflater;
import java.util.zip.Inflater;

import com.sun.org.apache.xerces.internal.jaxp.datatype.XMLGregorianCalendarImpl;
import io.fusionauth.samlv2.domain.NameID;
import io.fusionauth.samlv2.domain.NameIDFormat;
import io.fusionauth.samlv2.domain.SAMLException;
import io.fusionauth.samlv2.domain.jaxb.oasis.assertion.NameIDType;
import io.fusionauth.samlv2.domain.jaxb.oasis.metadata.KeyDescriptorType;
import io.fusionauth.samlv2.domain.jaxb.w3c.xmldsig.X509DataType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.xml.sax.ErrorHandler;
import org.xml.sax.SAXException;
import org.xml.sax.SAXNotRecognizedException;
import org.xml.sax.SAXNotSupportedException;
import org.xml.sax.SAXParseException;
import static javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING;

/**
 * @author Daniel DeGroff
 */
public class SAMLTools {
  private static final Map<String, Boolean> FactoryAttributes = new HashMap<>();

  private static final Logger logger = LoggerFactory.getLogger(SAMLTools.class);

  static {
    FactoryAttributes.put("http://xml.org/sax/features/external-general-entities", false);
    FactoryAttributes.put("http://xml.org/sax/features/external-parameter-entities", false);
    FactoryAttributes.put("http://apache.org/xml/features/disallow-doctype-decl", true);
    FactoryAttributes.put("http://javax.xml.XMLConstants/feature/secure-processing", true);
    FactoryAttributes.put("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
    FactoryAttributes.put("http://apache.org/xml/features/nonvalidating/load-dtd-grammar", false);
  }

  /**
   * Convert an attribute to a string.
   *
   * @param attribute the attribute in object form
   * @return a string version of the attribute.
   */
  public static String attributeToString(Object attribute) {
    if (attribute == null) {
      return null;
    }

    if (attribute instanceof Number) {
      return attribute.toString();
    } else if (attribute instanceof String) {
      return (String) attribute;
    } else if (attribute instanceof Element) {
      return ((Element) attribute).getTextContent();
    } else {
      logger.warn("This library currently doesn't handle attributes of type [" + attribute.getClass() + "]");
    }

    return null;
  }

  /**
   * Convert a calendar object of type <code>XMLGregorianCalendar</code> to a ZonedDateTime.
   *
   * @param cal a calendar object
   * @return a zoned date time object
   */
  public static ZonedDateTime convertToZonedDateTime(XMLGregorianCalendar cal) {
    return cal != null ? cal.toGregorianCalendar().toZonedDateTime() : null;
  }

  /**
   * Decode and inflate the encoded request.
   *
   * @param encodedRequest the encoded request
   * @return a decoded and inflated request as bytes
   * @throws SAMLException if $%#! goes south
   */
  public static byte[] decodeAndInflate(String encodedRequest) throws SAMLException {
    byte[] bytes = Base64.getMimeDecoder().decode(encodedRequest);
    Inflater inflater = new Inflater(true);
    inflater.setInput(bytes);
    inflater.finished();

    try {
      ByteArrayOutputStream os = new ByteArrayOutputStream();
      byte[] result = new byte[bytes.length];
      while (!inflater.finished()) {
        int length = inflater.inflate(result);
        if (length > 0) {
          os.write(result, 0, length);
        }
      }

      return os.toByteArray();
    } catch (DataFormatException e) {
      throw new SAMLException("Invalid AuthnRequest. Inflating the bytes failed.", e);
    }
  }

  /**
   * Deflate and encode the provided byte array.
   *
   * @param bytes the byte array to deflate and encode.
   * @return an encoded string
   */
  public static String deflateAndEncode(byte[] bytes) {
    Deflater deflater = new Deflater(Deflater.DEFLATED, true);
    deflater.setInput(bytes);
    deflater.finish();
    byte[] deflatedResult = new byte[bytes.length];
    int length = deflater.deflate(deflatedResult);
    deflater.end();
    byte[] src = Arrays.copyOf(deflatedResult, length);
    return Base64.getEncoder().encodeToString(src);
  }

  /**
   * Serialize the JAXBElement to a byte array.
   *
   * @param object the JAXB element.
   * @param type   the class of the element.
   * @param <T>    the type of the element.
   * @return a byte array
   * @throws SAMLException if $%#! goes south.
   */
  public static <T> byte[] marshallToBytes(JAXBElement<T> object, Class<T> type) throws SAMLException {
    try {
      JAXBContext context = JAXBContext.newInstance(type);
      Marshaller marshaller = context.createMarshaller();
      ByteArrayOutputStream os = new ByteArrayOutputStream();
      marshaller.marshal(object, os);
      return os.toByteArray();
    } catch (JAXBException e) {
      throw new SAMLException("Unable to marshallRequest JAXB SAML object to bytes.", e);
    }
  }

  /**
   * Marshall the JAXB element to a Document.
   *
   * @param object the JAXB element.
   * @param type   the class of the element.
   * @param <T>    the type of the element.
   * @return a document
   * @throws SAMLException if $%#! goes south
   */
  public static <T> Document marshallToDocument(JAXBElement<T> object, Class<T> type) throws SAMLException {
    try {
      JAXBContext context = JAXBContext.newInstance(type);
      Marshaller marshaller = context.createMarshaller();
      Document document = newDocumentBuilder().newDocument();
      marshaller.marshal(object, document);

      return document;
    } catch (JAXBException | SAMLException e) {
      throw new SAMLException("Unable to marshallRequest JAXB SAML object to DOM.", e);
    }
  }

  /**
   * Serialize the provided document.
   *
   * @param document the document to serialize.
   * @return a string form of the serialized document.
   */
  public static String marshallToString(Document document) throws TransformerException {
    StringWriter sw = new StringWriter();
    TransformerFactory tf = TransformerFactory.newInstance();
    Transformer transformer = tf.newTransformer();
    transformer.transform(new DOMSource(document), new StreamResult(sw));
    return sw.toString();
  }

  /**
   * Return a new document builder
   *
   * @return a document builder
   * @throws SAMLException if $%#! goes south
   */
  public static DocumentBuilder newDocumentBuilder() throws SAMLException {
    // https://cheatsheetseries.owasp.org/cheatsheets/SAML_Security_Cheat_Sheet.html
    // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html#SAXTransformerFactory
    // https://web-in-security.blogspot.com/2014/11/detecting-and-exploiting-xxe-in-saml.html

    try {
      DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
      dbf.setNamespaceAware(true);

      // Do not expand entity references
      dbf.setExpandEntityReferences(false);

      // Set default attributes
      for (String key : FactoryAttributes.keySet()) {
        try {
          dbf.setAttribute(key, FactoryAttributes.get(key));
        } catch (IllegalArgumentException e) {
          // The parser may not recognize this attribute.
          logger.debug("Failed to set attribute [" + key + "=" + FactoryAttributes.get(key) + "]. This may be expected if the parser does not recognize this attribute.", e);
        }
      }

      // Enabling 'secure processing' disables loading of external DTD and external Schema.
      // - See constructor body on line 177 of DocumentBuilderImpl.
      dbf.setFeature(FEATURE_SECURE_PROCESSING, true);

      return dbf.newDocumentBuilder();
    } catch (ParserConfigurationException e) {
      throw new SAMLException("Unable to configure the DocumentBuilderFactory with feature [" + FEATURE_SECURE_PROCESSING + "].", e);
    }
  }

  /**
   * Parse the provided bytes into a document.
   *
   * @param bytes the bytes
   * @return a document
   * @throws SAMLException if $%#! goes south
   */
  public static Document newDocumentFromBytes(byte[] bytes) throws SAMLException {
    try {
      return newDocumentBuilder().parse(new ByteArrayInputStream(bytes));
    } catch (SAXException | IOException e) {
      throw new SAMLException("Unable to parse SAML v2.0 authentication response", e);
    }
  }

  /**
   * Parse a NameIdType element and return a NameID enum.
   *
   * @param element the nameId element
   * @return a nameId enum value
   */
  public static NameID parseNameId(NameIDType element) {
    NameID nameId = new NameID();
    nameId.format = NameIDFormat.fromSAMLFormat(element.getFormat());
    nameId.id = element.getValue();
    return nameId;
  }

  /**
   * Convert a key descriptor type to a certificate
   *
   * @param keyDescriptorType the key descriptor type
   * @return a certificate or null if it could not be converted.
   */
  public static Certificate toCertificate(KeyDescriptorType keyDescriptorType) {
    try {
      List<Object> keyData = keyDescriptorType.getKeyInfo().getContent();
      for (Object keyDatum : keyData) {
        if (keyDatum instanceof JAXBElement<?>) {
          JAXBElement<?> element = (JAXBElement<?>) keyDatum;
          if (element.getDeclaredType() == X509DataType.class) {
            X509DataType cert = (X509DataType) element.getValue();
            List<Object> certData = cert.getX509IssuerSerialOrX509SKIOrX509SubjectName();
            for (Object certDatum : certData) {
              element = (JAXBElement<?>) certDatum;
              if (element.getName().getLocalPart().equals("X509Certificate")) {
                byte[] certBytes = (byte[]) element.getValue();
                CertificateFactory cf = CertificateFactory.getInstance("X.509");
                return cf.generateCertificate(new ByteArrayInputStream(certBytes));
              }
            }
          }
        }
      }

      return null;
    } catch (CertificateException e) {
      throw new IllegalArgumentException(e);
    }
  }

  /**
   * Convert a ZonedDateTime instant to an XMlGregorianCalendar object.
   *
   * @param instant the instant
   * @return a calendar object.
   */
  public static XMLGregorianCalendar toXMLGregorianCalendar(ZonedDateTime instant) {
    if (instant == null) {
      return null;
    }

    return new XMLGregorianCalendarImpl(GregorianCalendar.from(instant));
  }

  /**
   * Convert an XMLGregorianCalendar instant to a ZoneDateTime instant
   *
   * @param instant the instant
   * @return a ZoneDateTime object
   */
  public static ZonedDateTime toZonedDateTime(XMLGregorianCalendar instant) {
    if (instant == null) {
      return null;
    }

    return instant.toGregorianCalendar().toZonedDateTime();
  }

  /**
   * Convert a document to a JAXB Element
   *
   * @param document the XML document
   * @param type     the class of the JAXB element to marshal the document to
   * @param <T>      the type
   * @return an object of type T
   * @throws SAMLException if $%#! goes south
   */
  public static <T> T unmarshallFromDocument(Document document, Class<T> type) throws SAMLException {
    try {
      JAXBContext context = JAXBContext.newInstance(type);
      Unmarshaller unmarshaller = context.createUnmarshaller();
      JAXBElement<T> element = unmarshaller.unmarshal(document, type);
      return element.getValue();
    } catch (JAXBException e) {
      throw new SAMLException("Unable to unmarshall SAML response", e);
    }
  }

  /**
   * Validate the document.
   *
   * @param document  the document
   * @param schemaURI the schema URI
   * @param errors    the errors object used to collect all errors from the validator.
   * @return true if the validation was successful, false otherwise.
   * @throws SAMLException if $%#! goes south
   */
  public static boolean validate(Document document, URL schemaURI, SchemaValidationErrors errors) throws SAMLException {
    Schema schema;
    try {
      schema = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI).newSchema(schemaURI);
    } catch (SAXException e) {
      throw new SAMLException("An invalid schema was requested. Schema [" + schemaURI + "].", e);
    }

    Validator validator = schema.newValidator();
    validator.setErrorHandler(errors);

    // Disable external DTD while validating.
    try {
      validator.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
    } catch (SAXNotRecognizedException | SAXNotSupportedException ignore) {
      // Not supported by the parser
    }

    // Disable external Schema while validating.
    try {
      validator.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
    } catch (SAXNotRecognizedException | SAXNotSupportedException ignore) {
      // Not supported by the parser
    }

    Source source = new DOMSource(document);
    try {
      validator.validate(source);
    } catch (IOException | SAXException e) {
      throw new SAMLException("Failed to validate the document source.", e);
    }

    return errors.error.isEmpty() && errors.fatal.isEmpty() && errors.warning.isEmpty();
  }

  public static class SchemaValidationErrors implements ErrorHandler {
    public final List<SAXParseException> error = new ArrayList<>();

    public final List<SAXParseException> fatal = new ArrayList<>();

    public final List<SAXParseException> warning = new ArrayList<>();

    @Override
    public void error(SAXParseException exception) {
      error.add(exception);
    }

    @Override
    public void fatalError(SAXParseException exception) {
      fatal.add(exception);
    }

    @Override
    public void warning(SAXParseException exception) {
      warning.add(exception);
    }
  }
}
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.nio.file.Path;
import io.fusionauth.samlv2.util.SAMLTools;
  @Test
  public void parseRequest_expandedEntity() throws Exception {
    // Expanded entity, fail. The entity definition is within the DOCTYPE, which is not allowed, the error will be with regards to the DOCTYPE.
    try {
      DefaultSAMLv2Service service = new DefaultSAMLv2Service();
      byte[] xml = Files.readAllBytes(Paths.get("src/test/xml/authn-request-expanded-entity.xml"));
      String deflated = SAMLTools.deflateAndEncode(xml);
      AuthenticationRequest request = service.parseRequestRedirectBinding(deflated, null, authRequest -> new TestRedirectBindingSignatureHelper());
      fail("Expected an exception because we are declaring a DOCTYPE and expanding an entity. The issuer is now set to [" + request.issuer + "] which is not good.");
    } catch (SAMLException e) {
      assertEquals(e.getMessage(), "Unable to parse SAML v2.0 authentication response");
      assertEquals(e.getCause().getClass().getCanonicalName(), "org.xml.sax.SAXParseException");
      assertEquals(e.getCause().getMessage(), "DOCTYPE is disallowed when the feature \"http://apache.org/xml/features/disallow-doctype-decl\" set to true.");
    }
  }

  @Test
  public void parseRequest_externalDTD() throws Exception {
    // Load an external DTD, fail, this is defined within the DOCTYPE, so the error will be with regards to the DOCTYPE.
    Path tempFile = null;
    try {
      tempFile = Files.createTempFile("readThisFile", ".tmp");
      try (BufferedWriter writer = new BufferedWriter(new FileWriter(tempFile.toFile()))) {
        writer.write("You've been pwned.");
      }

      DefaultSAMLv2Service service = new DefaultSAMLv2Service();
      byte[] xml = Files.readAllBytes(Paths.get("src/test/xml/authn-request-external-dtd.xml"));

      // Set the filename in the XML
      String xmlString = new String(xml);
      xmlString = xmlString.replace("{{tempFile}}", tempFile.toFile().getAbsolutePath());
      xml = xmlString.getBytes(StandardCharsets.UTF_8);

      String deflated = SAMLTools.deflateAndEncode(xml);
      AuthenticationRequest request = service.parseRequestRedirectBinding(deflated, null, authRequest -> new TestRedirectBindingSignatureHelper());
      fail("Expected an exception because we are declaring a DOCTYPE. The issuer is now set to [" + request.issuer + "] which is not good.");
    } catch (SAMLException e) {
      assertEquals(e.getMessage(), "Unable to parse SAML v2.0 authentication response");
      assertEquals(e.getCause().getClass().getCanonicalName(), "org.xml.sax.SAXParseException");
      assertEquals(e.getCause().getMessage(), "DOCTYPE is disallowed when the feature \"http://apache.org/xml/features/disallow-doctype-decl\" set to true.");
    } finally {
      if (tempFile != null) {
        Files.deleteIfExists(tempFile);
      }
    }
  }

  @Test
  public void parseRequest_hasDocType() throws Exception {
    // Has DOCTYPE, fail. No DOCTYPE for you!
    try {
      DefaultSAMLv2Service service = new DefaultSAMLv2Service();
      byte[] xml = Files.readAllBytes(Paths.get("src/test/xml/authn-request-has-doctype.xml"));
      String deflated = SAMLTools.deflateAndEncode(xml);
      service.parseRequestRedirectBinding(deflated, null, authRequest -> new TestRedirectBindingSignatureHelper());
      fail("expected an exception because we are declaring a DOCTYPE");
    } catch (SAMLException e) {
      assertEquals(e.getMessage(), "Unable to parse SAML v2.0 authentication response");
      assertEquals(e.getCause().getClass().getCanonicalName(), "org.xml.sax.SAXParseException");
      assertEquals(e.getCause().getMessage(), "DOCTYPE is disallowed when the feature \"http://apache.org/xml/features/disallow-doctype-decl\" set to true.");
    }
  }

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY x "You've been pwned." >]>
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
                    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
                    ID="_809707f0030a5d00620c9d9df97f627afe9dcc24"
                    Version="2.0"
                    ProviderName="SP test"
                    IssueInstant="2014-07-16T23:52:45Z"
                    Destination="http://idp.example.com/SSOService.php"
                    ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
                    AssertionConsumerServiceURL="http://sp.example.com/demo1/index.php?acs">
  <saml:Issuer>&x;</saml:Issuer>
  <samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" AllowCreate="true"/>
  <samlp:RequestedAuthnContext Comparison="exact">
    <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
  </samlp:RequestedAuthnContext>
</samlp:AuthnRequest>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "{{tempFile}}" >]>
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
                    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
                    ID="_809707f0030a5d00620c9d9df97f627afe9dcc24"
                    Version="2.0"
                    ProviderName="SP test"
                    IssueInstant="2014-07-16T23:52:45Z"
                    Destination="http://idp.example.com/SSOService.php"
                    ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
                    AssertionConsumerServiceURL="http://sp.example.com/demo1/index.php?acs">
  <saml:Issuer>&xxe;</saml:Issuer>
  <samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" AllowCreate="true"/>
  <samlp:RequestedAuthnContext Comparison="exact">
    <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
  </samlp:RequestedAuthnContext>
</samlp:AuthnRequest>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE MyDocType >
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
                    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
                    ID="_809707f0030a5d00620c9d9df97f627afe9dcc24"
                    Version="2.0"
                    ProviderName="SP test"
                    IssueInstant="2014-07-16T23:52:45Z"
                    Destination="http://idp.example.com/SSOService.php"
                    ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
                    AssertionConsumerServiceURL="http://sp.example.com/demo1/index.php?acs">
  <saml:Issuer>http://sp.example.com/demo1/metadata.php</saml:Issuer>
  <samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" AllowCreate="true"/>
  <samlp:RequestedAuthnContext Comparison="exact">
    <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
  </samlp:RequestedAuthnContext>
</samlp:AuthnRequest>