OoxmlActiveContentValidator.java
package com.taxonomy.templates;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Fail-closed policy for executable, externally resolving and privacy-sensitive OOXML.
*
* <p>The generic OPC validator deliberately preserves ordinary Word features. This guard
* rejects constructs that can execute or load external content, retain unresolved review
* state, hide document content or distribute workstation and author metadata.</p>
*/
@Component
public final class OoxmlActiveContentValidator {
private static final String WORD_NS =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
private static final String CORE_PROPERTIES_NS =
"http://schemas.openxmlformats.org/package/2006/metadata/core-properties";
private static final String DC_NS = "http://purl.org/dc/elements/1.1/";
private static final String EXTENDED_PROPERTIES_NS =
"http://schemas.openxmlformats.org/officeDocument/2006/extended-properties";
private static final Set<String> ALLOWED_HYPERLINK_SCHEMES =
Set.of("https", "mailto");
private static final Set<String> CONTROLLED_IDENTITY_VALUES = Set.of(
"taxonomy",
"taxonomy architecture analyzer",
"taxonomy-bootstrap",
"taxonomy report service");
private static final Set<String> CONTROLLED_COMPANY_VALUES = Set.of(
"organisation",
"organization",
"taxonomy",
"taxonomy architecture analyzer");
private static final Set<String> PROHIBITED_REVISION_ELEMENTS = Set.of(
"commentRangeStart",
"commentRangeEnd",
"commentReference",
"ins",
"del",
"moveFrom",
"moveTo",
"moveFromRangeStart",
"moveFromRangeEnd",
"moveToRangeStart",
"moveToRangeEnd",
"customXml",
"customXmlPr",
"customXmlInsRangeStart",
"customXmlInsRangeEnd",
"customXmlDelRangeStart",
"customXmlDelRangeEnd",
"customXmlMoveFromRangeStart",
"customXmlMoveFromRangeEnd",
"customXmlMoveToRangeStart",
"customXmlMoveToRangeEnd",
"cellIns",
"cellDel",
"cellMerge",
"delText",
"delInstrText");
private static final Pattern URI_SCHEME = Pattern.compile(
"^([A-Za-z][A-Za-z0-9+.-]*):");
private static final Pattern UNSAFE_FIELD = Pattern.compile(
"(?i)(?<![A-Z0-9_])"
+ "(DDEAUTO|DDE|INCLUDETEXT|INCLUDEPICTURE|LINK|DATABASE|"
+ "HYPERLINK|AUTOTEXTLIST|AUTOTEXT)"
+ "(?![A-Z0-9_])");
public void validate(Map<String, byte[]> parts) {
if (parts == null) {
throw invalid("OOXML package is missing");
}
validateForbiddenParts(parts);
validateContentTypes(parts.get("[Content_Types].xml"));
validateCoreProperties(parts.get("docProps/core.xml"));
validateExtendedProperties(parts.get("docProps/app.xml"));
for (Map.Entry<String, byte[]> part : parts.entrySet()) {
String lower = part.getKey().toLowerCase(Locale.ROOT);
if (lower.endsWith(".rels")) {
validateRelationships(part.getKey(), part.getValue());
}
if (lower.startsWith("word/") && lower.endsWith(".xml")) {
Document document = parse(part.getKey(), part.getValue());
validateWordFields(part.getKey(), document);
validatePassiveWordContent(part.getKey(), document);
}
if (lower.endsWith(".htm") || lower.endsWith(".html")
|| lower.endsWith(".mht") || lower.endsWith(".mhtml")
|| lower.endsWith(".rtf")) {
throw invalid("alternative-format part is not permitted: " + part.getKey());
}
}
}
private static void validateForbiddenParts(Map<String, byte[]> parts) {
for (String path : parts.keySet()) {
String lower = path.toLowerCase(Locale.ROOT);
if ((lower.startsWith("word/comments") && lower.endsWith(".xml"))
|| "word/people.xml".equals(lower)) {
throw invalid("comments and reviewer identity are not permitted: " + path);
}
if (lower.startsWith("customxml/")
|| "docprops/custom.xml".equals(lower)) {
throw invalid("custom XML metadata is not permitted: " + path);
}
if (lower.startsWith("word/printersettings/")) {
throw invalid("printer-specific settings are not permitted: " + path);
}
if (lower.startsWith("docprops/thumbnail.")) {
throw invalid("stale document thumbnails are not permitted: " + path);
}
}
}
private static void validateContentTypes(byte[] content) {
if (content == null) {
return;
}
Document document = parse("[Content_Types].xml", content);
for (String elementName : Set.of("Default", "Override")) {
NodeList elements = document.getElementsByTagNameNS("*", elementName);
for (int index = 0; index < elements.getLength(); index++) {
String value = ((Element) elements.item(index))
.getAttribute("ContentType").toLowerCase(Locale.ROOT);
if (value.contains("html") || value.contains("xhtml")
|| value.contains("mhtml") || value.contains("rfc822")
|| value.contains("rtf")) {
throw invalid("active alternative-format content type is not permitted");
}
}
}
}
private static void validateRelationships(String path, byte[] content) {
Document document = parse(path, content);
NodeList relationships = document.getElementsByTagNameNS("*", "Relationship");
for (int index = 0; index < relationships.getLength(); index++) {
Element relationship = (Element) relationships.item(index);
String type = relationship.getAttribute("Type");
String target = relationship.getAttribute("Target");
boolean external = "External".equalsIgnoreCase(
relationship.getAttribute("TargetMode"));
if (type.endsWith("/aFChunk") || type.endsWith("/afChunk")) {
throw invalid("alternative-format relationship is not permitted in " + path);
}
if (external && type.endsWith("/hyperlink")) {
validateExternalHyperlink(path, target);
}
}
}
private static void validateExternalHyperlink(String path, String target) {
if (target == null || target.isBlank()
|| target.indexOf('\r') >= 0 || target.indexOf('\n') >= 0
|| target.indexOf('\0') >= 0) {
throw invalid("external hyperlink target is invalid in " + path);
}
Matcher schemeMatcher = URI_SCHEME.matcher(target);
if (!schemeMatcher.find()) {
throw invalid("external hyperlink target is invalid in " + path);
}
String declaredScheme = schemeMatcher.group(1).toLowerCase(Locale.ROOT);
if (!ALLOWED_HYPERLINK_SCHEMES.contains(declaredScheme)) {
throw invalid("external hyperlink scheme is not permitted in " + path);
}
try {
URI uri = URI.create(target);
String scheme = uri.getScheme() == null
? "" : uri.getScheme().toLowerCase(Locale.ROOT);
if (!declaredScheme.equals(scheme)) {
throw invalid("external hyperlink target is invalid in " + path);
}
if ("https".equals(scheme)
&& (uri.getHost() == null || uri.getHost().isBlank()
|| uri.getUserInfo() != null)) {
throw invalid("external HTTPS hyperlink is invalid in " + path);
}
if ("mailto".equals(scheme)
&& (uri.getSchemeSpecificPart() == null
|| uri.getSchemeSpecificPart().isBlank())) {
throw invalid("external mailto hyperlink is invalid in " + path);
}
} catch (IllegalArgumentException exception) {
if (exception.getMessage() != null
&& exception.getMessage().startsWith("Unsafe OOXML")) {
throw exception;
}
throw invalid("external hyperlink target is invalid in " + path, exception);
}
}
private static void validateWordFields(String path, Document document) {
StringBuilder combined = new StringBuilder();
NodeList instructionText = document.getElementsByTagNameNS(WORD_NS, "instrText");
for (int index = 0; index < instructionText.getLength(); index++) {
String instruction = instructionText.item(index).getTextContent();
validateInstruction(path, instruction);
combined.append(instruction == null ? "" : instruction);
}
validateInstruction(path, combined.toString());
NodeList simpleFields = document.getElementsByTagNameNS(WORD_NS, "fldSimple");
for (int index = 0; index < simpleFields.getLength(); index++) {
Element field = (Element) simpleFields.item(index);
String instruction = field.getAttributeNS(WORD_NS, "instr");
if (instruction == null || instruction.isBlank()) {
instruction = field.getAttribute("w:instr");
}
validateInstruction(path, instruction);
}
}
private static void validateInstruction(String path, String instruction) {
if (instruction == null || instruction.isBlank()) {
return;
}
Matcher matcher = UNSAFE_FIELD.matcher(instruction);
if (matcher.find()) {
throw invalid("Word field instruction "
+ matcher.group(1).toUpperCase(Locale.ROOT)
+ " is not permitted in " + path);
}
}
private static void validatePassiveWordContent(String path, Document document) {
for (String elementName : PROHIBITED_REVISION_ELEMENTS) {
if (document.getElementsByTagNameNS(WORD_NS, elementName).getLength() > 0) {
throw invalid("unresolved review or revision markup "
+ elementName + " is not permitted in " + path);
}
}
if (document.getElementsByTagNameNS(WORD_NS, "trackRevisions").getLength() > 0) {
throw invalid("tracked-revision mode is not permitted in " + path);
}
if (isTextBearingStory(path)
&& (document.getElementsByTagNameNS(WORD_NS, "vanish").getLength() > 0
|| document.getElementsByTagNameNS(WORD_NS, "webHidden").getLength() > 0)) {
throw invalid("hidden Word text is not permitted in " + path);
}
}
private static boolean isTextBearingStory(String path) {
String lower = path.toLowerCase(Locale.ROOT);
return "word/document.xml".equals(lower)
|| lower.matches("word/header[0-9]+\\.xml")
|| lower.matches("word/footer[0-9]+\\.xml")
|| "word/footnotes.xml".equals(lower)
|| "word/endnotes.xml".equals(lower)
|| lower.startsWith("word/glossary/");
}
private static void validateCoreProperties(byte[] content) {
if (content == null) {
return;
}
Document document = parse("docProps/core.xml", content);
validateControlledIdentity(document, DC_NS, "creator", "creator");
validateControlledIdentity(
document, CORE_PROPERTIES_NS, "lastModifiedBy", "last-modified-by");
rejectNonBlankProperty(
document, CORE_PROPERTIES_NS, "lastPrinted", "last-printed timestamp");
}
private static void validateExtendedProperties(byte[] content) {
if (content == null) {
return;
}
Document document = parse("docProps/app.xml", content);
rejectNonBlankProperty(document, EXTENDED_PROPERTIES_NS, "Manager", "manager");
rejectNonBlankProperty(
document, EXTENDED_PROPERTIES_NS, "HyperlinkBase", "hyperlink base");
NodeList companies = document.getElementsByTagNameNS(
EXTENDED_PROPERTIES_NS, "Company");
for (int index = 0; index < companies.getLength(); index++) {
String value = normalizedText(companies.item(index).getTextContent());
if (!value.isEmpty() && !CONTROLLED_COMPANY_VALUES.contains(value)) {
throw invalid("personal or organization-specific company metadata "
+ "is not permitted in docProps/app.xml");
}
}
NodeList templates = document.getElementsByTagNameNS(
EXTENDED_PROPERTIES_NS, "Template");
for (int index = 0; index < templates.getLength(); index++) {
String value = templates.item(index).getTextContent();
if (value != null && (value.contains("/") || value.contains("\\")
|| value.contains(":"))) {
throw invalid("workstation template paths are not permitted "
+ "in docProps/app.xml");
}
}
}
private static void validateControlledIdentity(
Document document,
String namespace,
String localName,
String description) {
NodeList values = document.getElementsByTagNameNS(namespace, localName);
for (int index = 0; index < values.getLength(); index++) {
String value = normalizedText(values.item(index).getTextContent());
if (!value.isEmpty() && !CONTROLLED_IDENTITY_VALUES.contains(value)) {
throw invalid("personal " + description
+ " metadata is not permitted in docProps/core.xml");
}
}
}
private static void rejectNonBlankProperty(
Document document,
String namespace,
String localName,
String description) {
NodeList values = document.getElementsByTagNameNS(namespace, localName);
for (int index = 0; index < values.getLength(); index++) {
String value = values.item(index).getTextContent();
if (value != null && !value.isBlank()) {
throw invalid(description + " metadata is not permitted");
}
}
}
private static String normalizedText(String value) {
return value == null ? "" : value.strip().toLowerCase(Locale.ROOT);
}
private static Document parse(String path, byte[] content) {
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature(
"http://xml.org/sax/features/external-general-entities", false);
factory.setFeature(
"http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature(
"http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
return factory.newDocumentBuilder().parse(new ByteArrayInputStream(content));
} catch (ParserConfigurationException | SAXException | IOException exception) {
throw invalid("invalid XML in " + path, exception);
}
}
private static IllegalArgumentException invalid(String detail) {
return new IllegalArgumentException("Unsafe OOXML template content: " + detail);
}
private static IllegalArgumentException invalid(String detail, Exception cause) {
return new IllegalArgumentException(
"Unsafe OOXML template content: " + detail, cause);
}
}