diff --git a/pom.xml b/pom.xml
index 0256d69..c7bd109 100644
--- a/pom.xml
+++ b/pom.xml
@@ -164,6 +164,12 @@
jar
provided
+
+ org.apache.tomcat
+ tomcat-catalina
+ 9.0.118
+ provided
+
org.dataone
d1_test_resources
diff --git a/src/main/java/org/dataone/security/XmlSecurityValidationValve.java b/src/main/java/org/dataone/security/XmlSecurityValidationValve.java
new file mode 100644
index 0000000..88491e4
--- /dev/null
+++ b/src/main/java/org/dataone/security/XmlSecurityValidationValve.java
@@ -0,0 +1,189 @@
+package org.dataone.security;
+
+import org.apache.catalina.connector.Request;
+import org.apache.catalina.connector.Response;
+import org.apache.catalina.valves.ValveBase;
+import org.apache.coyote.InputBuffer;
+import org.apache.tomcat.util.net.ApplicationBufferHandler;
+import org.apache.commons.fileupload.FileItem;
+import org.apache.commons.fileupload.disk.DiskFileItemFactory;
+import org.apache.commons.fileupload.servlet.ServletFileUpload;
+import org.apache.commons.fileupload.servlet.ServletRequestContext;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletResponse;
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
+import org.xml.sax.InputSource;
+import org.xml.sax.XMLReader;
+import org.xml.sax.ext.DefaultHandler2;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.util.List;
+
+/**
+ * Temporary mitigation Valve to reject multipart XML parts that declare DTDs/entities (XXE defense).
+ * Added 2026-07-10.
+ * Enable via the {@code } element under {@code } in server.xml, e.g.:
+ * {@code }
+ */
+public class XmlSecurityValidationValve extends ValveBase {
+
+ @Override
+ public void invoke(Request request, Response response) throws IOException, ServletException {
+ String contentType = request.getContentType();
+
+ // Only inspect if it's a multipart request
+ if (contentType != null && contentType.toLowerCase().startsWith("multipart/form-data")) {
+ try {
+ // 1. Buffer the raw input stream (bounded to avoid memory exhaustion)
+ final long maxRequestBytes = 10 * 1024 * 1024L; // 10 MiB
+ long declaredLength = request.getContentLengthLong();
+ if (declaredLength > maxRequestBytes) {
+ response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "Request payload too large.");
+ return;
+ }
+
+ InputStream rawInputStream = request.getInputStream();
+ ByteArrayOutputStream baos = new ByteArrayOutputStream(
+ declaredLength > 0 ? (int) declaredLength : 1024);
+ byte[] buffer = new byte[8192];
+ int len;
+ long totalRead = 0;
+ while ((len = rawInputStream.read(buffer)) > -1) {
+ totalRead += len;
+ if (totalRead > maxRequestBytes) {
+ response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "Request payload too large.");
+ return;
+ }
+ baos.write(buffer, 0, len);
+ }
+ byte[] requestBytes = baos.toByteArray();
+
+ // 2. Parse the multipart data using Tomcat's built-in FileUpload utilities
+ ServletRequestContext requestContext = new ServletRequestContext(request) {
+ @Override
+ public InputStream getInputStream() {
+ return new ByteArrayInputStream(requestBytes);
+ }
+ };
+
+ DiskFileItemFactory factory = new DiskFileItemFactory();
+ ServletFileUpload upload = new ServletFileUpload(factory);
+ List items = upload.parseRequest(requestContext);
+
+ for (FileItem item : items) {
+ // Check if the part is an XML content type or looks like XML
+ String partContentType = item.getContentType();
+ if (isXmlType(partContentType, item.getName())) {
+
+ // 3. Inspect for DTD / External Entities
+ if (containsForbiddenXmlStructures(item.getInputStream())) {
+ response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Malicious XML content detected.");
+ return; // Halt processing immediately
+ }
+ }
+ }
+
+ // 4. Re-inject the buffered bytes back into Tomcat's pipeline for downstream processing
+ request.getCoyoteRequest().setInputBuffer(new InputBuffer() {
+ private final ByteArrayInputStream bais = new ByteArrayInputStream(requestBytes);
+
+ @Override
+ public int doRead(ApplicationBufferHandler handler) throws IOException {
+ byte[] buf = new byte[8192];
+ int read = bais.read(buf);
+ if (read > 0) {
+ handler.setByteBuffer(ByteBuffer.wrap(buf, 0, read));
+ }
+ return read;
+ }
+
+ @Override
+ public int available() {
+ return bais.available();
+ }
+ });
+
+ } catch (Exception e) {
+ // Handle parsing errors or malicious attempts gracefully
+ response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid request payload.");
+ return;
+ }
+ }
+
+ // If safe or not multipart, pass to the next valve in the chain
+ getNext().invoke(request, response);
+ }
+
+ private boolean isXmlType(String contentType, String fileName) {
+ if (contentType != null) {
+ String ct = contentType.toLowerCase();
+ if (ct.contains("text/xml") || ct.contains("application/xml")) {
+ return true;
+ }
+ }
+ return fileName != null && fileName.toLowerCase().endsWith(".xml");
+ }
+
+ private boolean containsForbiddenXmlStructures(InputStream xmlStream) {
+ try {
+ SAXParserFactory spf = SAXParserFactory.newInstance();
+ spf.setNamespaceAware(true);
+
+ // 1. DO NOT disallow DOCTYPE completely.
+ spf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false);
+
+ // 2. Enable external general entities & parameter entities processing
+ // so our custom resolver can catch them if they are present.
+ spf.setFeature("http://xml.org/sax/features/external-general-entities", true);
+ spf.setFeature("http://xml.org/sax/features/external-parameter-entities", true);
+ spf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", true);
+
+ SAXParser saxParser = spf.newSAXParser();
+ XMLReader xmlReader = saxParser.getXMLReader();
+
+ // 3. Create a strict interceptor handler
+ DefaultHandler2 strictSecurityHandler = new DefaultHandler2() {
+
+ // Catch External DTDs and External General Entities
+ @Override
+ public InputSource resolveEntity(String name, String publicId, String baseURI, String systemId) throws org.xml.sax.SAXException {
+ if (systemId != null || publicId != null) {
+ throw new org.xml.sax.SAXException("Malicious XML: External entity or DTD resolution blocked: " + systemId);
+ }
+ return null;
+ }
+
+ // Catch External Parameter Entities inside the DOCTYPE declaration
+ @Override
+ public InputSource getExternalSubset(String name, String baseURI) throws org.xml.sax.SAXException {
+ throw new org.xml.sax.SAXException("Malicious XML: External DTD subset blocked.");
+ }
+
+ // Catch Entity Declarations (like SYSTEM "file:///") before they can even be resolved
+ @Override
+ public void externalEntityDecl(String name, String publicId, String systemId) throws org.xml.sax.SAXException {
+ throw new org.xml.sax.SAXException("Malicious XML: External entity declaration detected.");
+ }
+ };
+
+ // Register the handler for both resolution and advanced lexical intercepting
+ xmlReader.setEntityResolver(strictSecurityHandler);
+ xmlReader.setProperty("http://xml.org/sax/properties/lexical-handler", strictSecurityHandler);
+
+ // Parse the stream to trigger the interceptor if anything malicious is declared
+ xmlReader.parse(new InputSource(xmlStream));
+
+ return false; // Safe! No external definitions or resolutions were triggered.
+ } catch (Exception e) {
+ // Exception thrown by our security handler means we intercepted an attack vector
+ return true;
+ }
+ }
+
+}
diff --git a/src/test/java/org/dataone/security/XmlSecurityValidationValveTest.java b/src/test/java/org/dataone/security/XmlSecurityValidationValveTest.java
new file mode 100644
index 0000000..b35e605
--- /dev/null
+++ b/src/test/java/org/dataone/security/XmlSecurityValidationValveTest.java
@@ -0,0 +1,103 @@
+package org.dataone.security;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.Test;
+
+public class XmlSecurityValidationValveTest {
+
+ private final XmlSecurityValidationValve valve = new XmlSecurityValidationValve();
+
+ @Test
+ public void identifiesXmlFromContentType() {
+ assertTrue(isXmlType("application/xml", null));
+ assertTrue(isXmlType("text/xml; charset=UTF-8", null));
+ }
+
+ @Test
+ public void identifiesXmlFromFilename() {
+ assertTrue(isXmlType("application/octet-stream", "payload.xml"));
+ }
+
+ @Test
+ public void doesNotIdentifyNonXmlPayload() {
+ assertFalse(isXmlType("application/json", "payload.txt"));
+ }
+
+ @Test
+ public void rejectsXmlWithExternalEntityDeclaration() {
+ String maliciousXml =
+ ""
+ + "]>"
+ + "&xxe;";
+
+ assertTrue(containsForbiddenXmlStructures(streamOf(maliciousXml)));
+ }
+
+ @Test
+ public void rejectsXmlWithExternalDtdDeclaration() {
+ String maliciousXml =
+ ""
+ + ""
+ + "ok";
+
+ assertTrue(containsForbiddenXmlStructures(streamOf(maliciousXml)));
+ }
+
+ @Test
+ public void allowsXmlWithoutDtdOrEntityDeclarations() {
+ String safeXml = "ok";
+ assertFalse(containsForbiddenXmlStructures(streamOf(safeXml)));
+ }
+
+ @Test
+ public void rejectsMalformedXmlAsUnexpectedFailure() {
+ assertTrue(containsForbiddenXmlStructures(streamOf("")));
+ assertTrue(containsForbiddenXmlStructures(streamOf("broken")));
+ }
+
+ @Test
+ public void rejectsXmlWithUndefinedEntityReference() {
+ String unresolvedEntityXml =
+ ""
+ + "]>"
+ + "&missing;";
+
+ assertTrue(containsForbiddenXmlStructures(streamOf(unresolvedEntityXml)));
+ }
+
+ private boolean isXmlType(String contentType, String fileName) {
+ try {
+ Method method = XmlSecurityValidationValve.class.getDeclaredMethod("isXmlType", String.class, String.class);
+ method.setAccessible(true);
+ return (Boolean) method.invoke(valve, contentType, fileName);
+ } catch (NoSuchMethodException | IllegalAccessException e) {
+ throw new AssertionError("Failed to access XmlSecurityValidationValve.isXmlType", e);
+ } catch (InvocationTargetException e) {
+ throw new AssertionError("Unexpected exception from XmlSecurityValidationValve.isXmlType", e);
+ }
+ }
+
+ private boolean containsForbiddenXmlStructures(InputStream xmlStream) {
+ try {
+ Method method = XmlSecurityValidationValve.class.getDeclaredMethod("containsForbiddenXmlStructures", InputStream.class);
+ method.setAccessible(true);
+ return (Boolean) method.invoke(valve, xmlStream);
+ } catch (NoSuchMethodException | IllegalAccessException e) {
+ throw new AssertionError("Failed to access XmlSecurityValidationValve.containsForbiddenXmlStructures", e);
+ } catch (InvocationTargetException e) {
+ throw new AssertionError("Unexpected exception from XmlSecurityValidationValve.containsForbiddenXmlStructures", e);
+ }
+ }
+
+ private InputStream streamOf(String value) {
+ return new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8));
+ }
+}