mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Minor: Office doc changes (#6571)
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
import javax.xml.transform.OutputKeys;
|
||||
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 org.springframework.stereotype.Component;
|
||||
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 io.github.pixee.security.ZipSecurity;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.SsrfProtectionService;
|
||||
|
||||
// Strips external refs from OOXML/ODF uploads so LibreOffice can't be made to fetch them.
|
||||
@Component
|
||||
@Slf4j
|
||||
public class OfficeDocumentSanitizer {
|
||||
|
||||
private static final Set<String> OOXML_EXTENSIONS =
|
||||
Set.of(
|
||||
"docx", "docm", "dotx", "dotm", "xlsx", "xlsm", "xltx", "xltm", "pptx", "pptm",
|
||||
"potx", "potm", "ppsx", "ppsm");
|
||||
|
||||
private static final Set<String> ODF_EXTENSIONS =
|
||||
Set.of(
|
||||
"odt", "ott", "ods", "ots", "odp", "otp", "odg", "otg", "odf", "odc", "odi",
|
||||
"odm");
|
||||
|
||||
private static final Set<String> ODF_XML_PARTS =
|
||||
Set.of("content.xml", "styles.xml", "meta.xml", "settings.xml");
|
||||
|
||||
private final SsrfProtectionService ssrfProtectionService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public OfficeDocumentSanitizer(
|
||||
SsrfProtectionService ssrfProtectionService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.ssrfProtectionService = ssrfProtectionService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public boolean isSanitizableExtension(String extension) {
|
||||
if (extension == null) {
|
||||
return false;
|
||||
}
|
||||
String lower = extension.toLowerCase(Locale.ROOT);
|
||||
return OOXML_EXTENSIONS.contains(lower) || ODF_EXTENSIONS.contains(lower);
|
||||
}
|
||||
|
||||
public byte[] sanitize(byte[] documentBytes, String extension) throws IOException {
|
||||
if (documentBytes == null || documentBytes.length == 0) {
|
||||
throw new IOException("Office document input is empty or null");
|
||||
}
|
||||
if (applicationProperties.getSystem().isDisableSanitize()) {
|
||||
log.debug("Office document sanitization disabled by configuration");
|
||||
return documentBytes;
|
||||
}
|
||||
if (!isSanitizableExtension(extension)) {
|
||||
return documentBytes;
|
||||
}
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(documentBytes.length);
|
||||
try (ZipInputStream zipIn =
|
||||
ZipSecurity.createHardenedInputStream(
|
||||
new ByteArrayInputStream(documentBytes));
|
||||
ZipOutputStream zipOut = new ZipOutputStream(out)) {
|
||||
|
||||
ZipEntry entry;
|
||||
while ((entry = zipIn.getNextEntry()) != null) {
|
||||
String name = entry.getName();
|
||||
byte[] bytes = entry.isDirectory() ? new byte[0] : zipIn.readAllBytes();
|
||||
|
||||
if (!entry.isDirectory()) {
|
||||
bytes = sanitizeEntry(name, bytes);
|
||||
}
|
||||
|
||||
ZipEntry outEntry = new ZipEntry(name);
|
||||
if (entry.getComment() != null) {
|
||||
outEntry.setComment(entry.getComment());
|
||||
}
|
||||
if (entry.getExtra() != null) {
|
||||
outEntry.setExtra(entry.getExtra());
|
||||
}
|
||||
zipOut.putNextEntry(outEntry);
|
||||
if (!entry.isDirectory()) {
|
||||
zipOut.write(bytes);
|
||||
}
|
||||
zipOut.closeEntry();
|
||||
}
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private byte[] sanitizeEntry(String entryName, byte[] entryBytes) {
|
||||
String lower = entryName.toLowerCase(Locale.ROOT);
|
||||
try {
|
||||
if (lower.endsWith(".rels")) {
|
||||
return sanitizeOoxmlRels(entryBytes);
|
||||
}
|
||||
if (isOdfXmlPart(lower)) {
|
||||
return sanitizeOdfXml(entryBytes);
|
||||
}
|
||||
} catch (ParserConfigurationException
|
||||
| SAXException
|
||||
| IOException
|
||||
| TransformerException e) {
|
||||
log.warn(
|
||||
"Failed to parse XML part '{}' for sanitization, leaving as-is: {}",
|
||||
entryName,
|
||||
e.getMessage());
|
||||
}
|
||||
return entryBytes;
|
||||
}
|
||||
|
||||
private boolean isOdfXmlPart(String lowerName) {
|
||||
int slash = lowerName.lastIndexOf('/');
|
||||
String base = slash >= 0 ? lowerName.substring(slash + 1) : lowerName;
|
||||
return ODF_XML_PARTS.contains(base);
|
||||
}
|
||||
|
||||
private byte[] sanitizeOoxmlRels(byte[] xmlBytes)
|
||||
throws IOException, ParserConfigurationException, SAXException, TransformerException {
|
||||
Document doc = parseSecurely(xmlBytes);
|
||||
Element root = doc.getDocumentElement();
|
||||
if (root == null) {
|
||||
return xmlBytes;
|
||||
}
|
||||
NodeList relationships = root.getElementsByTagNameNS("*", "Relationship");
|
||||
List<Node> toRemove = new ArrayList<>();
|
||||
for (int i = 0; i < relationships.getLength(); i++) {
|
||||
Node node = relationships.item(i);
|
||||
NamedNodeMap attrs = node.getAttributes();
|
||||
if (attrs == null) {
|
||||
continue;
|
||||
}
|
||||
Node targetMode = attrs.getNamedItem("TargetMode");
|
||||
if (targetMode == null || !"external".equalsIgnoreCase(targetMode.getNodeValue())) {
|
||||
continue;
|
||||
}
|
||||
Node target = attrs.getNamedItem("Target");
|
||||
String targetValue = target == null ? "" : target.getNodeValue();
|
||||
if (isAdminAllowed(targetValue)) {
|
||||
continue;
|
||||
}
|
||||
log.warn(
|
||||
"Stripping OOXML external relationship target: {}",
|
||||
truncateForLog(targetValue));
|
||||
toRemove.add(node);
|
||||
}
|
||||
if (toRemove.isEmpty()) {
|
||||
return xmlBytes;
|
||||
}
|
||||
for (Node n : toRemove) {
|
||||
n.getParentNode().removeChild(n);
|
||||
}
|
||||
return serializeDocument(doc);
|
||||
}
|
||||
|
||||
private byte[] sanitizeOdfXml(byte[] xmlBytes)
|
||||
throws IOException, ParserConfigurationException, SAXException, TransformerException {
|
||||
Document doc = parseSecurely(xmlBytes);
|
||||
Element root = doc.getDocumentElement();
|
||||
if (root == null) {
|
||||
return xmlBytes;
|
||||
}
|
||||
boolean modified = stripExternalHrefs(root);
|
||||
if (!modified) {
|
||||
return xmlBytes;
|
||||
}
|
||||
return serializeDocument(doc);
|
||||
}
|
||||
|
||||
private boolean stripExternalHrefs(Node node) {
|
||||
boolean modified = false;
|
||||
if (node.getNodeType() == Node.ELEMENT_NODE) {
|
||||
NamedNodeMap attrs = node.getAttributes();
|
||||
List<String> hrefAttrsToRemove = new ArrayList<>();
|
||||
for (int i = 0; i < attrs.getLength(); i++) {
|
||||
Node attr = attrs.item(i);
|
||||
String name = attr.getNodeName();
|
||||
if (name == null) {
|
||||
continue;
|
||||
}
|
||||
String lower = name.toLowerCase(Locale.ROOT);
|
||||
if (!(lower.equals("xlink:href")
|
||||
|| lower.endsWith(":href")
|
||||
|| lower.equals("href"))) {
|
||||
continue;
|
||||
}
|
||||
String value = attr.getNodeValue();
|
||||
if (!isExternalUrl(value)) {
|
||||
continue;
|
||||
}
|
||||
if (isAdminAllowed(value)) {
|
||||
continue;
|
||||
}
|
||||
log.warn(
|
||||
"Stripping ODF external href attribute ({}): {}",
|
||||
name,
|
||||
truncateForLog(value));
|
||||
hrefAttrsToRemove.add(name);
|
||||
}
|
||||
Element element = (Element) node;
|
||||
for (String attrName : hrefAttrsToRemove) {
|
||||
element.removeAttribute(attrName);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
NodeList children = node.getChildNodes();
|
||||
for (int i = 0; i < children.getLength(); i++) {
|
||||
if (stripExternalHrefs(children.item(i))) {
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
}
|
||||
|
||||
private boolean isExternalUrl(String url) {
|
||||
if (url == null) {
|
||||
return false;
|
||||
}
|
||||
String trimmed = url.trim().toLowerCase(Locale.ROOT);
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("../")) {
|
||||
return false;
|
||||
}
|
||||
return trimmed.startsWith("http://")
|
||||
|| trimmed.startsWith("https://")
|
||||
|| trimmed.startsWith("ftp://")
|
||||
|| trimmed.startsWith("ftps://")
|
||||
|| trimmed.startsWith("file:")
|
||||
|| trimmed.startsWith("smb:")
|
||||
|| trimmed.startsWith("\\\\")
|
||||
|| trimmed.startsWith("//");
|
||||
}
|
||||
|
||||
// Preserved only with an explicit allowedDomains entry; MEDIUM default would admit public URLs.
|
||||
private boolean isAdminAllowed(String url) {
|
||||
if (ssrfProtectionService == null || url == null || url.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
ApplicationProperties.Html.UrlSecurity config =
|
||||
applicationProperties.getSystem().getHtml().getUrlSecurity();
|
||||
if (config == null
|
||||
|| config.getAllowedDomains() == null
|
||||
|| config.getAllowedDomains().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return ssrfProtectionService.isUrlAllowed(url);
|
||||
}
|
||||
|
||||
private Document parseSecurely(byte[] xmlBytes)
|
||||
throws ParserConfigurationException, SAXException, IOException {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
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.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
factory.setNamespaceAware(true);
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
return builder.parse(new ByteArrayInputStream(xmlBytes));
|
||||
}
|
||||
|
||||
private byte[] serializeDocument(Document doc) throws TransformerException {
|
||||
TransformerFactory tf = TransformerFactory.newInstance();
|
||||
tf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
Transformer transformer = tf.newTransformer();
|
||||
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
|
||||
transformer.setOutputProperty(OutputKeys.INDENT, "no");
|
||||
transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
transformer.transform(new DOMSource(doc), new StreamResult(baos));
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private String truncateForLog(String value) {
|
||||
if (value == null) {
|
||||
return "null";
|
||||
}
|
||||
return value.length() > 80 ? value.substring(0, 80) + "..." : value;
|
||||
}
|
||||
}
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.SsrfProtectionService;
|
||||
|
||||
class OfficeDocumentSanitizerTest {
|
||||
|
||||
private static final String EXTERNAL_URL = "https://webhook.site/ssrf-callback";
|
||||
private static final String INTERNAL_TARGET = "media/image1.png";
|
||||
|
||||
private static final String DOCX_RELS =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
|
||||
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\" TargetMode=\"External\"/>"
|
||||
+ "<Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
+ INTERNAL_TARGET
|
||||
+ "\"/>"
|
||||
+ "</Relationships>";
|
||||
|
||||
private static final String DOCX_DOCUMENT =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">"
|
||||
+ "<w:body><w:p/></w:body></w:document>";
|
||||
|
||||
private static final String ODF_CONTENT_EXTERNAL =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<office:document-content"
|
||||
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
|
||||
+ " xmlns:draw=\"urn:oasis:names:tc:opendocument:xmlns:drawing:1.0\""
|
||||
+ " xmlns:xlink=\"http://www.w3.org/1999/xlink\">"
|
||||
+ "<office:body><office:text>"
|
||||
+ "<draw:frame><draw:image xlink:href=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\" xlink:type=\"simple\"/></draw:frame>"
|
||||
+ "<draw:frame><draw:image xlink:href=\"Pictures/image1.png\" xlink:type=\"simple\"/></draw:frame>"
|
||||
+ "</office:text></office:body></office:document-content>";
|
||||
|
||||
private SsrfProtectionService ssrfProtectionService;
|
||||
private ApplicationProperties applicationProperties;
|
||||
private OfficeDocumentSanitizer sanitizer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
ssrfProtectionService = mock(SsrfProtectionService.class);
|
||||
sanitizer = new OfficeDocumentSanitizer(ssrfProtectionService, applicationProperties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSanitizableExtension_recognizesOoxmlAndOdf() {
|
||||
assertTrue(sanitizer.isSanitizableExtension("docx"));
|
||||
assertTrue(sanitizer.isSanitizableExtension("DOCX"));
|
||||
assertTrue(sanitizer.isSanitizableExtension("xlsx"));
|
||||
assertTrue(sanitizer.isSanitizableExtension("pptx"));
|
||||
assertTrue(sanitizer.isSanitizableExtension("odt"));
|
||||
assertTrue(sanitizer.isSanitizableExtension("ods"));
|
||||
assertTrue(sanitizer.isSanitizableExtension("odp"));
|
||||
assertFalse(sanitizer.isSanitizableExtension("pdf"));
|
||||
assertFalse(sanitizer.isSanitizableExtension("html"));
|
||||
assertFalse(sanitizer.isSanitizableExtension(""));
|
||||
assertFalse(sanitizer.isSanitizableExtension(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_stripsOoxmlExternalRelationship() throws IOException {
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("word/_rels/document.xml.rels", DOCX_RELS.getBytes(StandardCharsets.UTF_8));
|
||||
entries.put("word/document.xml", DOCX_DOCUMENT.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] docx = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(docx, "docx");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String rels =
|
||||
new String(result.get("word/_rels/document.xml.rels"), StandardCharsets.UTF_8);
|
||||
assertFalse(rels.contains(EXTERNAL_URL), "External URL should be stripped from .rels");
|
||||
assertFalse(
|
||||
rels.toLowerCase().contains("targetmode=\"external\""),
|
||||
"TargetMode=External relationship should be removed");
|
||||
assertTrue(rels.contains(INTERNAL_TARGET), "Internal image target should be preserved");
|
||||
assertArrayEquals(
|
||||
DOCX_DOCUMENT.getBytes(StandardCharsets.UTF_8),
|
||||
result.get("word/document.xml"),
|
||||
"Non-rels entries must be untouched");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_pptxExternalImageRelStripped() throws IOException {
|
||||
String pptxRels =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
|
||||
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\" TargetMode=\"External\"/>"
|
||||
+ "</Relationships>";
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("ppt/slides/_rels/slide1.xml.rels", pptxRels.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] pptx = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(pptx, "pptx");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String rels =
|
||||
new String(result.get("ppt/slides/_rels/slide1.xml.rels"), StandardCharsets.UTF_8);
|
||||
assertFalse(rels.contains(EXTERNAL_URL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_xlsxExternalImageRelStripped() throws IOException {
|
||||
String xlsxRels =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
|
||||
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\" TargetMode=\"External\"/>"
|
||||
+ "</Relationships>";
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put(
|
||||
"xl/drawings/_rels/drawing1.xml.rels", xlsxRels.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] xlsx = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(xlsx, "xlsx");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String rels =
|
||||
new String(
|
||||
result.get("xl/drawings/_rels/drawing1.xml.rels"), StandardCharsets.UTF_8);
|
||||
assertFalse(rels.contains(EXTERNAL_URL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_odtStripsExternalXlinkHrefButKeepsInternal() throws IOException {
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("content.xml", ODF_CONTENT_EXTERNAL.getBytes(StandardCharsets.UTF_8));
|
||||
String manifestXml =
|
||||
"<?xml version=\"1.0\"?><manifest:manifest"
|
||||
+ " xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\"/>";
|
||||
entries.put("META-INF/manifest.xml", manifestXml.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] odt = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(odt, "odt");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String content = new String(result.get("content.xml"), StandardCharsets.UTF_8);
|
||||
assertFalse(content.contains(EXTERNAL_URL), "External xlink:href should be stripped");
|
||||
assertTrue(content.contains("Pictures/image1.png"), "Internal href should be preserved");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_odsStripsExternalXlinkHref() throws IOException {
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("content.xml", ODF_CONTENT_EXTERNAL.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] ods = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(ods, "ods");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String content = new String(result.get("content.xml"), StandardCharsets.UTF_8);
|
||||
assertFalse(content.contains(EXTERNAL_URL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_odpStripsExternalXlinkHrefInStylesXml() throws IOException {
|
||||
String stylesXml =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<office:document-styles"
|
||||
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
|
||||
+ " xmlns:draw=\"urn:oasis:names:tc:opendocument:xmlns:drawing:1.0\""
|
||||
+ " xmlns:xlink=\"http://www.w3.org/1999/xlink\">"
|
||||
+ "<draw:image xlink:href=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\"/></office:document-styles>";
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("styles.xml", stylesXml.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] odp = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(odp, "odp");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String content = new String(result.get("styles.xml"), StandardCharsets.UTF_8);
|
||||
assertFalse(content.contains(EXTERNAL_URL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_disabledByConfigReturnsOriginal() throws IOException {
|
||||
applicationProperties.getSystem().setDisableSanitize(true);
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("word/_rels/document.xml.rels", DOCX_RELS.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] docx = zip(entries);
|
||||
|
||||
byte[] result = sanitizer.sanitize(docx, "docx");
|
||||
assertArrayEquals(docx, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_unrecognizedExtensionReturnsOriginal() throws IOException {
|
||||
byte[] original = "irrelevant".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] result = sanitizer.sanitize(original, "pdf");
|
||||
assertArrayEquals(original, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_emptyInputThrows() {
|
||||
assertThrows(IOException.class, () -> sanitizer.sanitize(new byte[0], "docx"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_nullInputThrows() {
|
||||
assertThrows(IOException.class, () -> sanitizer.sanitize(null, "docx"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_preservesEntryWithExternalRefWhenAdminAllowsDomain() throws IOException {
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.getHtml()
|
||||
.getUrlSecurity()
|
||||
.getAllowedDomains()
|
||||
.add("webhook.site");
|
||||
lenient().when(ssrfProtectionService.isUrlAllowed(eq(EXTERNAL_URL))).thenReturn(true);
|
||||
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("word/_rels/document.xml.rels", DOCX_RELS.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] docx = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(docx, "docx");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String rels =
|
||||
new String(result.get("word/_rels/document.xml.rels"), StandardCharsets.UTF_8);
|
||||
assertTrue(rels.contains(EXTERNAL_URL), "Allow-listed external URL should be preserved");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_doesNotConsultSsrfServiceWhenAllowedDomainsEmpty() throws IOException {
|
||||
// Even if mock would say allowed, we should not invoke it when there is no allow-list,
|
||||
// because MEDIUM default would let public URLs through and re-introduce the vulnerability.
|
||||
lenient().when(ssrfProtectionService.isUrlAllowed(eq(EXTERNAL_URL))).thenReturn(true);
|
||||
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("word/_rels/document.xml.rels", DOCX_RELS.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] docx = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(docx, "docx");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String rels =
|
||||
new String(result.get("word/_rels/document.xml.rels"), StandardCharsets.UTF_8);
|
||||
assertFalse(rels.contains(EXTERNAL_URL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_handlesNonXmlEntriesSafely() throws IOException {
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
byte[] imageBytes = new byte[] {(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a};
|
||||
entries.put("word/media/image1.png", imageBytes);
|
||||
entries.put("word/_rels/document.xml.rels", DOCX_RELS.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] docx = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(docx, "docx");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
assertArrayEquals(imageBytes, result.get("word/media/image1.png"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_internalLinksKeptWhenNoExternalPresent() throws IOException {
|
||||
String internalOnlyRels =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
|
||||
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\"media/image1.png\"/>"
|
||||
+ "</Relationships>";
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put(
|
||||
"word/_rels/document.xml.rels", internalOnlyRels.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] docx = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(docx, "docx");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String rels =
|
||||
new String(result.get("word/_rels/document.xml.rels"), StandardCharsets.UTF_8);
|
||||
assertTrue(rels.contains("media/image1.png"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_corruptZipProducesSafeOutput() throws IOException {
|
||||
byte[] garbage = "this is not a zip file".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] result = sanitizer.sanitize(garbage, "docx");
|
||||
Map<String, byte[]> entries = unzip(result);
|
||||
assertTrue(entries.isEmpty(), "Garbage input must not yield exploitable entries");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_relativeOdfPathsArePreserved() throws IOException {
|
||||
String content =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<office:document-content"
|
||||
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
|
||||
+ " xmlns:draw=\"urn:oasis:names:tc:opendocument:xmlns:drawing:1.0\""
|
||||
+ " xmlns:xlink=\"http://www.w3.org/1999/xlink\">"
|
||||
+ "<draw:image xlink:href=\"../Pictures/image1.png\"/>"
|
||||
+ "<draw:image xlink:href=\"#anchor\"/>"
|
||||
+ "</office:document-content>";
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put("content.xml", content.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] odt = zip(entries);
|
||||
|
||||
byte[] cleaned = sanitizer.sanitize(odt, "odt");
|
||||
|
||||
Map<String, byte[]> result = unzip(cleaned);
|
||||
String out = new String(result.get("content.xml"), StandardCharsets.UTF_8);
|
||||
assertTrue(out.contains("../Pictures/image1.png"));
|
||||
assertTrue(out.contains("#anchor"));
|
||||
}
|
||||
|
||||
private static byte[] zip(Map<String, byte[]> entries) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (Map.Entry<String, byte[]> e : entries.entrySet()) {
|
||||
ZipEntry entry = new ZipEntry(e.getKey());
|
||||
zos.putNextEntry(entry);
|
||||
zos.write(e.getValue());
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static Map<String, byte[]> unzip(byte[] data) throws IOException {
|
||||
Map<String, byte[]> entries = new HashMap<>();
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(data))) {
|
||||
ZipEntry e;
|
||||
while ((e = zis.getNextEntry()) != null) {
|
||||
entries.put(e.getName(), zis.readAllBytes());
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -35,6 +35,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CustomHtmlSanitizer;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.OfficeDocumentSanitizer;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
@@ -50,6 +51,7 @@ public class ConvertOfficeController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final CustomHtmlSanitizer customHtmlSanitizer;
|
||||
private final OfficeDocumentSanitizer officeDocumentSanitizer;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@@ -83,14 +85,16 @@ public class ConvertOfficeController {
|
||||
Path inputPath = workDir.resolve(baseName + "." + extensionLower);
|
||||
Path outputPath = workDir.resolve(baseName + ".pdf");
|
||||
|
||||
// Check if the file is HTML and apply sanitization if needed
|
||||
// Sanitize input before LibreOffice sees it so embedded URLs can't trigger SSRF.
|
||||
if ("html".equals(extensionLower) || "htm".equals(extensionLower)) {
|
||||
// Read and sanitize HTML content
|
||||
String htmlContent = new String(inputFile.getBytes(), StandardCharsets.UTF_8);
|
||||
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlContent);
|
||||
Files.writeString(inputPath, sanitizedHtml, StandardCharsets.UTF_8);
|
||||
} else if (officeDocumentSanitizer.isSanitizableExtension(extensionLower)) {
|
||||
byte[] sanitized =
|
||||
officeDocumentSanitizer.sanitize(inputFile.getBytes(), extensionLower);
|
||||
Files.write(inputPath, sanitized);
|
||||
} else {
|
||||
// copy file content
|
||||
Files.copy(inputFile.getInputStream(), inputPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user