diff --git a/app/common/src/main/java/stirling/software/common/util/OfficeDocumentSanitizer.java b/app/common/src/main/java/stirling/software/common/util/OfficeDocumentSanitizer.java new file mode 100644 index 000000000..9cdf8cf53 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/OfficeDocumentSanitizer.java @@ -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 OOXML_EXTENSIONS = + Set.of( + "docx", "docm", "dotx", "dotm", "xlsx", "xlsm", "xltx", "xltm", "pptx", "pptm", + "potx", "potm", "ppsx", "ppsm"); + + private static final Set ODF_EXTENSIONS = + Set.of( + "odt", "ott", "ods", "ots", "odp", "otp", "odg", "otg", "odf", "odc", "odi", + "odm"); + + private static final Set 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 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 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; + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/OfficeDocumentSanitizerTest.java b/app/common/src/test/java/stirling/software/common/util/OfficeDocumentSanitizerTest.java new file mode 100644 index 000000000..eb2291906 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/OfficeDocumentSanitizerTest.java @@ -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 = + "" + + "" + + "" + + "" + + ""; + + private static final String DOCX_DOCUMENT = + "" + + "" + + ""; + + private static final String ODF_CONTENT_EXTERNAL = + "" + + "" + + "" + + "" + + "" + + ""; + + 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 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 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 = + "" + + "" + + "" + + ""; + Map 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 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 = + "" + + "" + + "" + + ""; + Map 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 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 entries = new LinkedHashMap<>(); + entries.put("content.xml", ODF_CONTENT_EXTERNAL.getBytes(StandardCharsets.UTF_8)); + String manifestXml = + ""; + entries.put("META-INF/manifest.xml", manifestXml.getBytes(StandardCharsets.UTF_8)); + byte[] odt = zip(entries); + + byte[] cleaned = sanitizer.sanitize(odt, "odt"); + + Map 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 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 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 = + "" + + "" + + ""; + Map entries = new LinkedHashMap<>(); + entries.put("styles.xml", stylesXml.getBytes(StandardCharsets.UTF_8)); + byte[] odp = zip(entries); + + byte[] cleaned = sanitizer.sanitize(odp, "odp"); + + Map 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 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 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 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 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 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 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 result = unzip(cleaned); + assertArrayEquals(imageBytes, result.get("word/media/image1.png")); + } + + @Test + void sanitize_internalLinksKeptWhenNoExternalPresent() throws IOException { + String internalOnlyRels = + "" + + "" + + "" + + ""; + Map 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 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 entries = unzip(result); + assertTrue(entries.isEmpty(), "Garbage input must not yield exploitable entries"); + } + + @Test + void sanitize_relativeOdfPathsArePreserved() throws IOException { + String content = + "" + + "" + + "" + + "" + + ""; + Map entries = new LinkedHashMap<>(); + entries.put("content.xml", content.getBytes(StandardCharsets.UTF_8)); + byte[] odt = zip(entries); + + byte[] cleaned = sanitizer.sanitize(odt, "odt"); + + Map 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 entries) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (Map.Entry e : entries.entrySet()) { + ZipEntry entry = new ZipEntry(e.getKey()); + zos.putNextEntry(entry); + zos.write(e.getValue()); + zos.closeEntry(); + } + } + return baos.toByteArray(); + } + + private static Map unzip(byte[] data) throws IOException { + Map 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; + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java index 8bf53c79e..4fc6669bc 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java @@ -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); }