CVE-2021-27736 (published 2021-04-22T14:15Z)

FusionAuth fusionauth-samlv2 before 0.5.4 allows XXE attacks via a forged AuthnRequest or LogoutRequest because parseFromBytes uses javax.xml.parsers.DocumentBuilderFactory unsafely.

Severity: MEDIUM
CVSS v2 base score: 4.0 (AV:N/AC:L/Au:S/C:P/I:N/A:N)
CVSS v3 base score: 6.5, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-611
cwe_name: Improper Restriction of XML External Entity Reference
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.
extended_description: 
            XML documents optionally contain a Document Type Definition (DTD), which, among other features, enables the definition of XML entities. It is possible to define an entity by providing a substitution string in the form of a URI. The XML parser can access the contents of this URI and embed these contents back into the XML document for further processing.
            By submitting an XML file that defines an external entity with a file:// URI, an attacker can cause the processing application to read the contents of a local file. For example, a URI such as "file:///c:/winnt/win.ini" designates (in Windows) the file C:\Winnt\win.ini, or file:///etc/passwd designates the password file in Unix-based systems. Using URIs with other schemes such as http://, the attacker can force the application to make outgoing requests to servers that the attacker cannot reach directly, which can be used to bypass firewall restrictions or hide the source of attacks such as port scanning.
            Once the content of the URI is read, it is fed back into the application that is processing the XML. This application may echo back the data (e.g. in an error message), thereby exposing the file contents.
         
         

Fix information:
repo_url: https://github.com/FusionAuth/fusionauth-samlv2
commit_details: repo_url: https://github.com/FusionAuth/fusionauth-samlv2
author: Daniel DeGroff
author_date: 2021-02-06 19:10:15-07:00
msg: Refactor utility methods into SAMLTools, add tests.
repository: repo_url: https://github.com/FusionAuth/fusionauth-samlv2
repo_name: FusionAuth/fusionauth-samlv2
description: SAML v2.0 bindings in Java using JAXB
repo_language: Java
owner: FusionAuth
file_changes: filename: fusionauth-samlv2.iml
change_type: ModificationType.MODIFY
diff: @@ -6,7 +6,7 @@
     <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" />
@@ -136,5 +136,4 @@
       </library>
     </orderEntry>
   </component>
-</module>
-
+</module>
\ No newline at end of file

programming_language: Shell
filename: DefaultSAMLv2Service.java
change_type: ModificationType.MODIFY
diff: @@ -15,11 +15,7 @@
  */
 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;
@@ -37,19 +33,7 @@ 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;
@@ -59,8 +43,6 @@ 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;
@@ -75,9 +57,6 @@ 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;
@@ -89,7 +68,6 @@ 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;
@@ -128,6 +106,7 @@ 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;
@@ -136,7 +115,16 @@ 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.
@@ -411,7 +399,7 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
 
   @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();
@@ -450,7 +438,7 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
         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) {
@@ -532,7 +520,7 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
     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);
     }
@@ -613,7 +601,7 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
               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");
@@ -646,24 +634,6 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
     });
   }
 
-  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 {
@@ -687,7 +657,7 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
                                            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");
@@ -711,43 +681,6 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
     }
   }
 
-  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++) {
@@ -766,42 +699,6 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
     }
   }
 
-  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();
@@ -818,24 +715,6 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
     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()) {
@@ -843,7 +722,7 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
     }
 
     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;
@@ -907,60 +786,6 @@ public class DefaultSAMLv2Service implements SAMLv2Service {
     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());

programming_language: Java
filename: SAMLTools.java
change_type: ModificationType.ADD
diff: @@ -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);
+    }
+  }
+}

programming_language: Java
filename: DefaultSAMLv2ServiceTest.java
change_type: ModificationType.MODIFY
diff: @@ -24,13 +24,16 @@ 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;
@@ -60,6 +63,7 @@ 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;
@@ -256,6 +260,70 @@ public class DefaultSAMLv2ServiceTest {
     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")));

programming_language: Java
filename: authn-request-expanded-entity.xml
change_type: ModificationType.ADD
diff: @@ -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

programming_language: Ruby
filename: authn-request-external-dtd.xml
change_type: ModificationType.ADD
diff: @@ -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

programming_language: Ruby
filename: authn-request-has-doctype.xml
change_type: ModificationType.ADD
diff: @@ -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

programming_language: Ruby
filename: metadata.xml
change_type: ModificationType.MODIFY
diff: @@ -1,18 +1,3 @@
-<!--
-  ~ 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>

programming_language: PowerShell