diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index ea379cf7c6..6eeacf7eb2 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -280,6 +280,7 @@ jobs: docker/unoserver/entrypoint.sh \ docker/unoserver/healthcheck.sh \ docker/unoserver/VERSION \ + docker/base/security/soffice_no_network.c \ | sha256sum | cut -d' ' -f1) echo "hash=${hash}" >> "$GITHUB_OUTPUT" echo "Unoserver source hash: ${hash}" 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 index 9cdf8cf53b..5b017460f9 100644 --- a/app/common/src/main/java/stirling/software/common/util/OfficeDocumentSanitizer.java +++ b/app/common/src/main/java/stirling/software/common/util/OfficeDocumentSanitizer.java @@ -3,6 +3,7 @@ package stirling.software.common.util; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -37,7 +38,7 @@ 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. +// Strips external/file references from office uploads so LibreOffice can't be made to fetch them. @Component @Slf4j public class OfficeDocumentSanitizer { @@ -81,10 +82,18 @@ public class OfficeDocumentSanitizer { log.debug("Office document sanitization disabled by configuration"); return documentBytes; } - if (!isSanitizableExtension(extension)) { - return documentBytes; + // Route by content, not extension (a flat-ODF renamed .xml still needs sanitizing). + if (looksLikeZip(documentBytes)) { + return sanitizeZipContainer(documentBytes); } + if (looksLikeXml(documentBytes)) { + return sanitizeFlatXml(documentBytes); + } + // Binary formats we can't introspect pass through; the network guard contains their SSRF. + return documentBytes; + } + private byte[] sanitizeZipContainer(byte[] documentBytes) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(documentBytes.length); try (ZipInputStream zipIn = ZipSecurity.createHardenedInputStream( @@ -117,6 +126,92 @@ public class OfficeDocumentSanitizer { return out.toByteArray(); } + // Flat single-file XML: strip all out-of-document refs; fail CLOSED on unparseable. + byte[] sanitizeFlatXml(byte[] xmlBytes) throws IOException { + byte[] cleaned = tryStripFlatXml(xmlBytes); + if (cleaned != null) { + return cleaned; + } + // DOCTYPE trips the hardened parser; strip and retry so benign DOCTYPE files convert. + byte[] withoutDoctype = stripDoctype(xmlBytes); + if (withoutDoctype != null) { + cleaned = tryStripFlatXml(withoutDoctype); + if (cleaned != null) { + return cleaned; + } + } + throw new IOException("XML document could not be parsed for sanitization and was rejected"); + } + + // Returns null (not the original bytes) to signal a parse failure to the caller. + private byte[] tryStripFlatXml(byte[] xmlBytes) { + try { + Document doc = parseSecurely(xmlBytes); + Element root = doc.getDocumentElement(); + if (root == null) { + return xmlBytes; + } + if (!stripExternalHrefs(root, true)) { + return xmlBytes; + } + return serializeDocument(doc); + } catch (ParserConfigurationException + | SAXException + | IOException + | TransformerException e) { + log.warn("Single-file XML did not parse for sanitization: {}", e.getMessage()); + return null; + } + } + + // Strip leading so parsing works; null if absent/unterminated. + private static byte[] stripDoctype(byte[] xmlBytes) { + String s = new String(xmlBytes, StandardCharsets.UTF_8); + int start = s.indexOf(" 0) { + depth--; + } + } else if (c == '>' && depth == 0) { + return (s.substring(0, start) + s.substring(i + 1)) + .getBytes(StandardCharsets.UTF_8); + } + } + return null; + } + + private static boolean looksLikeZip(byte[] b) { + return b.length >= 4 && b[0] == 'P' && b[1] == 'K' && b[2] == 3 && b[3] == 4; + } + + private static boolean looksLikeXml(byte[] b) { + int i = 0; + int n = b.length; + if (n >= 3 && (b[0] & 0xFF) == 0xEF && (b[1] & 0xFF) == 0xBB && (b[2] & 0xFF) == 0xBF) { + i = 3; // UTF-8 BOM + } else if (n >= 2 && (b[0] & 0xFF) == 0xFF && (b[1] & 0xFF) == 0xFE) { + i = 2; // UTF-16 LE BOM + } else if (n >= 2 && (b[0] & 0xFF) == 0xFE && (b[1] & 0xFF) == 0xFF) { + i = 2; // UTF-16 BE BOM + } + for (; i < n; i++) { + byte c = b[i]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == 0) { + continue; + } + return c == '<'; + } + return false; + } + private byte[] sanitizeEntry(String entryName, byte[] entryBytes) { String lower = entryName.toLowerCase(Locale.ROOT); try { @@ -189,58 +284,69 @@ public class OfficeDocumentSanitizer { if (root == null) { return xmlBytes; } - boolean modified = stripExternalHrefs(root); + boolean modified = stripExternalHrefs(root, false); if (!modified) { return xmlBytes; } return serializeDocument(doc); } - private boolean stripExternalHrefs(Node node) { + // flatMode: flat XML has no package, so strip all refs but #frag/data: (zip keeps relatives). + private boolean stripExternalHrefs(Node node, boolean flatMode) { boolean modified = false; if (node.getNodeType() == Node.ELEMENT_NODE) { NamedNodeMap attrs = node.getAttributes(); - List hrefAttrsToRemove = new ArrayList<>(); + List attrsToRemove = 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"))) { + if (name == null || !isReferenceAttribute(name)) { continue; } String value = attr.getNodeValue(); - if (!isExternalUrl(value)) { + boolean dangerous = flatMode ? isOutsideDocumentRef(value) : isExternalUrl(value); + if (!dangerous || isAdminAllowed(value)) { continue; } - if (isAdminAllowed(value)) { - continue; - } - log.warn( - "Stripping ODF external href attribute ({}): {}", - name, - truncateForLog(value)); - hrefAttrsToRemove.add(name); + log.warn("Stripping reference attribute ({}): {}", name, truncateForLog(value)); + attrsToRemove.add(name); } Element element = (Element) node; - for (String attrName : hrefAttrsToRemove) { + for (String attrName : attrsToRemove) { element.removeAttribute(attrName); modified = true; } } NodeList children = node.getChildNodes(); for (int i = 0; i < children.getLength(); i++) { - if (stripExternalHrefs(children.item(i))) { + if (stripExternalHrefs(children.item(i), flatMode)) { modified = true; } } return modified; } + private static boolean isReferenceAttribute(String name) { + String lower = name.toLowerCase(Locale.ROOT); + return lower.equals("href") + || lower.endsWith(":href") + || lower.equals("src") + || lower.endsWith(":src"); + } + + // Flat XML: anything but a #fragment or data: URI points outside the document and is stripped. + private static boolean isOutsideDocumentRef(String url) { + if (url == null) { + return false; + } + String trimmed = url.trim(); + if (trimmed.isEmpty()) { + return false; + } + String lower = trimmed.toLowerCase(Locale.ROOT); + return !(lower.startsWith("#") || lower.startsWith("data:")); + } + private boolean isExternalUrl(String url) { if (url == null) { return false; @@ -249,14 +355,27 @@ public class OfficeDocumentSanitizer { if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("../")) { return false; } + // Absolute/UNC/drive-letter paths are never valid in-package references. + if (trimmed.startsWith("/") || trimmed.startsWith("\\")) { + return true; + } + if (trimmed.length() >= 3 + && Character.isLetter(trimmed.charAt(0)) + && trimmed.charAt(1) == ':' + && (trimmed.charAt(2) == '\\' || trimmed.charAt(2) == '/')) { + return true; + } return trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("ftp://") || trimmed.startsWith("ftps://") || trimmed.startsWith("file:") || trimmed.startsWith("smb:") - || trimmed.startsWith("\\\\") - || trimmed.startsWith("//"); + || trimmed.startsWith("webdav:") + || trimmed.startsWith("davs:") + || trimmed.startsWith("dav:") + || trimmed.startsWith("vnd.sun.star.webdav:") + || trimmed.startsWith("vnd.sun.star.pkg:"); } // Preserved only with an explicit allowedDomains entry; MEDIUM default would admit public URLs. diff --git a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java index 1a8786bf1d..620fda8c34 100644 --- a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java +++ b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java @@ -10,6 +10,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -31,6 +32,36 @@ public class ProcessExecutor { private static final Map instances = new ConcurrentHashMap<>(); private static ApplicationProperties applicationProperties = new ApplicationProperties(); private static volatile UnoServerPool unoServerPool; + + // LD_PRELOAD SSRF guard for soffice; null when LIBREOFFICE_ALLOW_NETWORK set or lib missing. + private static final String OFFICE_NETWORK_GUARD_LIB = resolveOfficeNetworkGuard(); + + private static String resolveOfficeNetworkGuard() { + String allow = System.getenv("LIBREOFFICE_ALLOW_NETWORK"); + if (allow != null) { + String v = allow.trim().toLowerCase(Locale.ROOT); + if (v.equals("1") || v.equals("true") || v.equals("yes") || v.equals("on")) { + return null; + } + } + String override = System.getenv("LIBREOFFICE_NETWORK_GUARD_LIB"); + String path = + (override != null && !override.isBlank()) + ? override.trim() + : "/usr/local/lib/stirling/soffice_no_network.so"; + return Files.exists(Path.of(path)) ? path : null; + } + + private static boolean isSofficeEngineCommand(List command) { + if (command == null || command.isEmpty() || command.get(0) == null) { + return false; + } + String exe = command.get(0); + int slash = Math.max(exe.lastIndexOf('/'), exe.lastIndexOf('\\')); + String base = (slash >= 0 ? exe.substring(slash + 1) : exe).toLowerCase(Locale.ROOT); + return base.equals("soffice") || base.equals("soffice.bin"); + } + private final Semaphore semaphore; private final boolean liveUpdates; private long timeoutDuration; @@ -224,6 +255,19 @@ public class ProcessExecutor { log.info("Running command: {}", String.join(" ", commandToRun)); ProcessBuilder processBuilder = new ProcessBuilder(commandToRun); + // Guard the soffice engine only, not the unoconvert client (remote-UNO connects out). + if (processType == Processes.LIBRE_OFFICE + && OFFICE_NETWORK_GUARD_LIB != null + && isSofficeEngineCommand(commandToRun)) { + Map env = processBuilder.environment(); + String existing = env.get("LD_PRELOAD"); + env.put( + "LD_PRELOAD", + (existing == null || existing.isBlank()) + ? OFFICE_NETWORK_GUARD_LIB + : OFFICE_NETWORK_GUARD_LIB + " " + existing); + } + // Use the working directory if it's set if (workingDirectory != null) { processBuilder.directory(workingDirectory); 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 index eb22919068..684d23f4a6 100644 --- a/app/common/src/test/java/stirling/software/common/util/OfficeDocumentSanitizerTest.java +++ b/app/common/src/test/java/stirling/software/common/util/OfficeDocumentSanitizerTest.java @@ -343,6 +343,112 @@ class OfficeDocumentSanitizerTest { assertTrue(out.contains("#anchor")); } + private static String flatOdf(String href) { + return "" + + "" + + "" + + "" + + ""; + } + + @Test + void sanitize_flatOdfStripsExternalHref() throws IOException { + byte[] fodt = flatOdf(EXTERNAL_URL).getBytes(StandardCharsets.UTF_8); + String out = new String(sanitizer.sanitize(fodt, "fodt"), StandardCharsets.UTF_8); + assertFalse(out.contains(EXTERNAL_URL), "flat-ODF external href must be stripped"); + } + + @Test + void sanitize_flatOdfStripsFileHrefLocalReadback() throws IOException { + // Local-file readback (#7628): file:// reference must be removed before LibreOffice. + byte[] fodt = flatOdf("file:///etc/passwd").getBytes(StandardCharsets.UTF_8); + String out = new String(sanitizer.sanitize(fodt, "fodt"), StandardCharsets.UTF_8); + assertFalse(out.contains("file:///etc/passwd"), "flat-ODF file: href must be stripped"); + } + + @Test + void sanitize_flatOdfSanitizedRegardlessOfExtension() throws IOException { + // #7629 bypass: identical bytes renamed .xml/.txt must still be sanitized by content. + byte[] fodt = flatOdf(EXTERNAL_URL).getBytes(StandardCharsets.UTF_8); + for (String ext : new String[] {"xml", "txt", "dat", "fods", "unknown"}) { + String out = new String(sanitizer.sanitize(fodt, ext), StandardCharsets.UTF_8); + assertFalse( + out.contains(EXTERNAL_URL), + "external href must be stripped even when extension is ." + ext); + } + } + + @Test + void sanitize_flatOdfKeepsInDocumentRefsAndInlineData() throws IOException { + // A single-file doc legitimately references only in-document anchors and data: URIs. + byte[] anchor = flatOdf("#anchor").getBytes(StandardCharsets.UTF_8); + byte[] inline = + flatOdf("data:image/png;base64,iVBORw0KGgo=").getBytes(StandardCharsets.UTF_8); + assertTrue( + new String(sanitizer.sanitize(anchor, "fodt"), StandardCharsets.UTF_8) + .contains("#anchor")); + assertTrue( + new String(sanitizer.sanitize(inline, "fodt"), StandardCharsets.UTF_8) + .contains("data:image/png;base64")); + } + + @Test + void sanitize_flatOdfStripsTraversalAndAbsolutePaths() throws IOException { + // #7628 H1: flat XML has no package, so ../ traversal and absolute paths are external. + for (String href : + new String[] { + "../../../../etc/passwd", "/etc/passwd", "\\\\attacker\\share\\x", "C:/secret" + }) { + byte[] fodt = flatOdf(href).getBytes(StandardCharsets.UTF_8); + String out = new String(sanitizer.sanitize(fodt, "fodt"), StandardCharsets.UTF_8); + assertFalse(out.contains(href), "flat-ODF must strip filesystem ref: " + href); + } + } + + @Test + void sanitize_flatOdfDoctypeDoesNotFailOpen() throws IOException { + // #7628 C1: a DOCTYPE makes the strict parser reject the doc; must NOT pass through raw. + String doc = + "" + + "" + + "" + + "" + + "" + + ""; + byte[] fodt = doc.getBytes(StandardCharsets.UTF_8); + String out = new String(sanitizer.sanitize(fodt, "fodt"), StandardCharsets.UTF_8); + assertFalse(out.contains("file:///etc/passwd"), "DOCTYPE payload must be sanitized"); + } + + @Test + void sanitize_flatOdfStripsSrcAttribute() throws IOException { + String doc = + "" + + ""; + String out = + new String( + sanitizer.sanitize(doc.getBytes(StandardCharsets.UTF_8), "xml"), + StandardCharsets.UTF_8); + assertFalse(out.contains("file:///etc/passwd"), "src attribute must be stripped"); + } + + @Test + void sanitize_unparseableXmlIsRejectedNotPassedThrough() { + // Fail closed: XML-detected content that cannot be parsed is rejected, not passed raw. + byte[] garbage = " sanitizer.sanitize(garbage, "fodt")); + } + private static byte[] zip(Map entries) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (ZipOutputStream zos = new ZipOutputStream(baos)) { 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 97552000f2..1e4bf80e73 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 @@ -87,17 +87,15 @@ public class ConvertOfficeController { Path inputPath = workDir.resolve(baseName + "." + extensionLower); Path outputPath = workDir.resolve(baseName + ".pdf"); - // Sanitize input before LibreOffice sees it so embedded URLs can't trigger SSRF. - if ("html".equals(extensionLower) || "htm".equals(extensionLower)) { - String htmlContent = new String(inputFile.getBytes(), StandardCharsets.UTF_8); + // Sanitize by CONTENT not extension (a flat-ODF renamed .xml must still be sanitized). + byte[] inputBytes = inputFile.getBytes(); + if (isHtmlContent(inputBytes, extensionLower)) { + String htmlContent = new String(inputBytes, 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 { - Files.copy(inputFile.getInputStream(), inputPath, StandardCopyOption.REPLACE_EXISTING); + byte[] sanitized = officeDocumentSanitizer.sanitize(inputBytes, extensionLower); + Files.write(inputPath, sanitized); } Path libreOfficeProfile = null; @@ -207,6 +205,23 @@ public class ConvertOfficeController { .matches(); } + // HTML needs CustomHtmlSanitizer; detect by content so HTML renamed .fodt/.xml is caught. + private static boolean isHtmlContent(byte[] content, String extensionLower) { + if ("html".equals(extensionLower) || "htm".equals(extensionLower)) { + return true; + } + if (content == null || content.length == 0) { + return false; + } + int limit = Math.min(content.length, 1024); + String head = new String(content, 0, limit, StandardCharsets.UTF_8); + if (!head.isEmpty() && head.charAt(0) == '\uFEFF') { + head = head.substring(1); + } + head = head.stripLeading().toLowerCase(Locale.ROOT); + return head.startsWith("/dev/null || true +# Stage 5: Compile the LibreOffice network guard (LD_PRELOAD connect() SSRF blocker). +FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS office-guard-build +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev && \ + rm -rf /var/lib/apt/lists/* +COPY security/soffice_no_network.c /tmp/soffice_no_network.c +RUN gcc -shared -fPIC -O2 -Wall -Werror -o /soffice_no_network.so /tmp/soffice_no_network.c -ldl && \ + test -s /soffice_no_network.so + + # Final runtime image - the actual base image FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS runtime @@ -604,6 +614,8 @@ COPY --link --from=gs-build /usr/local/bin/gs /usr/local/b COPY --link --from=gs-build /usr/local/share/ghostscript /usr/local/share/ghostscript # Python venv pre-built (no pip install at runtime, no build tools needed) COPY --link --from=python-venv-build /opt/venv /opt/venv +# LibreOffice network guard (loaded via LD_PRELOAD for soffice/unoserver only). +COPY --link --from=office-guard-build /soffice_no_network.so /usr/local/lib/stirling/soffice_no_network.so RUN ldconfig /usr/local/lib && \ PYTHONDONTWRITEBYTECODE=1 \ diff --git a/docker/base/security/soffice_no_network.c b/docker/base/security/soffice_no_network.c new file mode 100644 index 0000000000..f1cae52e0e --- /dev/null +++ b/docker/base/security/soffice_no_network.c @@ -0,0 +1,55 @@ +/* LD_PRELOAD connect() guard: blocks LibreOffice/unoserver egress (SSRF); loopback stays open for the UNO bridge. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +typedef int (*connect_fn)(int, const struct sockaddr *, socklen_t); + +static int is_local(const struct sockaddr *addr) { + if (addr == NULL) { + return 1; + } + switch (addr->sa_family) { + case AF_UNIX: + return 1; + case AF_INET: { + uint32_t ip = ntohl(((const struct sockaddr_in *)addr)->sin_addr.s_addr); + return (ip >> 24) == 127; + } + case AF_INET6: { + const unsigned char *b = ((const struct sockaddr_in6 *)addr)->sin6_addr.s6_addr; + static const unsigned char loopback[16] = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1}; + if (memcmp(b, loopback, 16) == 0) { + return 1; + } + /* ::ffff:127.x v4-mapped loopback */ + if (memcmp(b, "\0\0\0\0\0\0\0\0\0\0\xff\xff", 12) == 0 && b[12] == 127) { + return 1; + } + return 0; + } + default: + return 1; /* AF_NETLINK etc. are not egress */ + } +} + +static connect_fn real_connect = NULL; + +__attribute__((constructor)) static void guard_init(void) { + real_connect = (connect_fn)dlsym(RTLD_NEXT, "connect"); +} + +int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen) { + if (real_connect == NULL) { + real_connect = (connect_fn)dlsym(RTLD_NEXT, "connect"); + } + if (!is_local(addr)) { + errno = EACCES; + return -1; + } + return real_connect(sockfd, addr, addrlen); +} diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index 7289d67bb5..74776da7ee 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -67,6 +67,15 @@ COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers +# Compile the network guard here (not only in base) so it ships regardless of the pinned base version. +FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS office-guard-build +RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev && \ + rm -rf /var/lib/apt/lists/* +COPY docker/base/security/soffice_no_network.c /tmp/soffice_no_network.c +RUN gcc -shared -fPIC -O2 -Wall -Werror -o /soffice_no_network.so /tmp/soffice_no_network.c -ldl && \ + test -s /soffice_no_network.so + + # Stage 3: Final runtime image on top of pre-built base FROM ${BASE_IMAGE} @@ -84,6 +93,10 @@ COPY --link --from=app-build --chown=1000:1000 \ /app/build/libs/restart-helper.jar /restart-helper.jar COPY --link --chown=1000:1000 scripts/ /scripts/ +# LibreOffice network guard (loaded via LD_PRELOAD for soffice/unoserver only). +COPY --link --from=office-guard-build \ + /soffice_no_network.so /usr/local/lib/stirling/soffice_no_network.so + # Fonts go to system dir, root ownership is correct (world-readable) COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/ diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 542bdec981..400cb9c84c 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -67,6 +67,15 @@ COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers +# Compile the network guard here (not only in base) so it ships regardless of the pinned base version. +FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS office-guard-build +RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev && \ + rm -rf /var/lib/apt/lists/* +COPY docker/base/security/soffice_no_network.c /tmp/soffice_no_network.c +RUN gcc -shared -fPIC -O2 -Wall -Werror -o /soffice_no_network.so /tmp/soffice_no_network.c -ldl && \ + test -s /soffice_no_network.so + + # Stage 3: Final runtime image on top of pre-built base FROM ${BASE_IMAGE} @@ -84,6 +93,10 @@ COPY --link --from=app-build --chown=1000:1000 \ /app/build/libs/restart-helper.jar /restart-helper.jar COPY --link --chown=1000:1000 scripts/ /scripts/ +# LibreOffice network guard (loaded via LD_PRELOAD for soffice/unoserver only). +COPY --link --from=office-guard-build \ + /soffice_no_network.so /usr/local/lib/stirling/soffice_no_network.so + # Fonts go to system dir, root ownership is correct (world-readable) COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/ diff --git a/docker/embedded/compose/README-remote-uno.md b/docker/embedded/compose/README-remote-uno.md index 45a75b5262..8c5c0f2f29 100644 --- a/docker/embedded/compose/README-remote-uno.md +++ b/docker/embedded/compose/README-remote-uno.md @@ -191,6 +191,65 @@ environment: LOGGING_LEVEL_STIRLING_SOFTWARE_COMMON_UTIL_PROCESSEXECUTOR: DEBUG ``` +## Security hardening (LibreOffice network isolation) + +LibreOffice resolves external references embedded in office documents +(``, `file://...`, etc.) during conversion. +Left unchecked this turns document conversion into a Server-Side Request Forgery +(SSRF) and local-file-readback primitive (see #7628 / #7629). + +Two layers protect against this, both **on by default**: + +1. **Input sanitization** - uploads are routed to the sanitizer by *content* (not + file extension), so flat-ODF (`.fodt`/`.fods`) and look-alike renames (e.g. a + flat-ODF sent as `.xml`) have their external and `file:` references stripped + before LibreOffice sees them. +2. **Process network guard** - the LibreOffice/unoserver processes run with an + `LD_PRELOAD` guard that blocks all non-loopback outbound connections. Loopback + stays open so the unoserver↔soffice UNO bridge keeps working. This applies to + the main image and the standalone `stirling-unoserver` image. + +To disable the network guard (e.g. you have a legitimate need for LibreOffice to +fetch remote images and accept the risk), set on the app **and** each unoserver: + +```yaml +environment: + LIBREOFFICE_ALLOW_NETWORK: "true" +``` + +### Defense in depth: isolate the UNO containers at the network layer + +The in-image guard operates inside the container. For a kernel-level guarantee, +put the `unoserver` containers on an **internal** Docker network with no route to +your internal services or the internet. They only need to be reachable by the +Stirling-PDF app: + +```yaml +networks: + frontend: {} + uno-internal: + internal: true # no egress to the host network / internet + +services: + stirling-pdf: + networks: [frontend, uno-internal] + unoserver1: + networks: [uno-internal] # reachable by the app, cannot reach anything else + unoserver2: + networks: [uno-internal] +``` + +This complements (does not replace) the in-image guard: even if a future +LibreOffice change bypassed the `LD_PRELOAD` guard, an `internal` network still +prevents egress. + +**Residual coverage.** Content sanitization covers OOXML/ODF, flat-ODF and HTML. +Legacy binary formats (`.doc`/`.xls`/`.ppt`, `.rtf`) are not content-inspected: +their outbound `http(s)` references are stopped by the network guard, but local +`file://` reads via such formats rely on the converter running non-root with no +access to sensitive paths. Isolating the UNO containers as above, and not mounting +secrets into them, closes that gap in depth. + ## What This Demonstrates This configuration showcases all the improvements from the PR reviews: diff --git a/docker/unoserver/Dockerfile b/docker/unoserver/Dockerfile index da5ba27371..ca0b4d86f1 100644 --- a/docker/unoserver/Dockerfile +++ b/docker/unoserver/Dockerfile @@ -1,6 +1,14 @@ # Standalone unoserver image for Stirling-PDF remote UNO mode. # Pinned to unoserver 3.7 to match Stirling-PDF's client (avoids wire mismatch). +# Compile the LibreOffice network guard (LD_PRELOAD connect() blocker). +FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS office-guard-build +RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev && \ + rm -rf /var/lib/apt/lists/* +COPY docker/base/security/soffice_no_network.c /tmp/soffice_no_network.c +RUN gcc -shared -fPIC -O2 -Wall -Werror -o /soffice_no_network.so /tmp/soffice_no_network.c -ldl && \ + test -s /soffice_no_network.so + FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea ARG UNOSERVER_VERSION=3.7 @@ -63,6 +71,9 @@ RUN set -eu; \ COPY --chmod=0644 docker/unoserver/fonts.conf /etc/fonts/conf.d/100-stirling.conf RUN fc-cache -f +# LibreOffice network guard (loaded via LD_PRELOAD by the entrypoint). +COPY --from=office-guard-build /soffice_no_network.so /usr/local/lib/stirling/soffice_no_network.so + # venv with --system-site-packages so it picks up distro python3-uno. RUN python3 -m venv /opt/unoserver-venv --system-site-packages && \ /opt/unoserver-venv/bin/pip install --no-cache-dir "unoserver==${UNOSERVER_VERSION}" && \ diff --git a/docker/unoserver/entrypoint.sh b/docker/unoserver/entrypoint.sh index bf1b6dbc3e..a43f6eebd2 100644 --- a/docker/unoserver/entrypoint.sh +++ b/docker/unoserver/entrypoint.sh @@ -18,6 +18,23 @@ case "$RECYCLE_INTERVAL_SECONDS" in ''|*[!0-9]*) log "Invalid UNOSERVER_RECYCLE_ mkdir -p "$PROFILE_DIR" +# LibreOffice network isolation (SSRF guard); default on, LIBREOFFICE_ALLOW_NETWORK=true opts out. +OFFICE_GUARD_LIB="/usr/local/lib/stirling/soffice_no_network.so" +OFFICE_LD_PRELOAD="" +case "$(printf '%s' "${LIBREOFFICE_ALLOW_NETWORK:-false}" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes|on) + log "LibreOffice network isolation DISABLED (LIBREOFFICE_ALLOW_NETWORK=${LIBREOFFICE_ALLOW_NETWORK})" + ;; + *) + if [ -f "$OFFICE_GUARD_LIB" ]; then + OFFICE_LD_PRELOAD="$OFFICE_GUARD_LIB" + log "LibreOffice network isolation enabled (guard: $OFFICE_GUARD_LIB)" + else + log "WARNING: LibreOffice network guard missing at $OFFICE_GUARD_LIB; conversions are NOT network-isolated" + fi + ;; +esac + start_xvfb() { if command -v Xvfb >/dev/null 2>&1 && [ -z "${DISPLAY:-}" ]; then Xvfb :99 -screen 0 1024x768x24 -ac +extension GLX +render -noreset >/dev/null 2>&1 & @@ -46,7 +63,7 @@ start_unoserver() { log "Starting unoserver on ${INTERFACE}:${PORT} (uno-port ${UNO_PORT}, timeout ${CONVERSION_TIMEOUT}s, profile ${PROFILE_DIR})" # Pass --user-installation as a plain path; unoserver 3.6 wraps it itself # and crashes if pre-wrapped as a file:// URI. - unoserver \ + env ${OFFICE_LD_PRELOAD:+LD_PRELOAD="$OFFICE_LD_PRELOAD"} unoserver \ --interface "$INTERFACE" \ --port "$PORT" \ --uno-port "$UNO_PORT" \ diff --git a/scripts/init-without-ocr.sh b/scripts/init-without-ocr.sh index c55dc1e6d4..415461d82e 100755 --- a/scripts/init-without-ocr.sh +++ b/scripts/init-without-ocr.sh @@ -283,7 +283,7 @@ start_unoserver_instance() { local profile_dir="${LIBREOFFICE_PROFILE}/instance_${port}" run_as_runtime_user mkdir -p "$profile_dir" # --user-installation is a plain path; unoserver 3.6 crashes if pre-wrapped as file://. - run_as_runtime_user "$UNOSERVER_BIN" \ + run_as_runtime_user env ${OFFICE_LD_PRELOAD:+LD_PRELOAD="$OFFICE_LD_PRELOAD"} "$UNOSERVER_BIN" \ --interface 127.0.0.1 \ --port "$port" \ --uno-port "$uno_port" \ @@ -925,6 +925,25 @@ else log "Xvfb not installed; skipping virtual display setup" fi +# ---------- LibreOffice network isolation ---------- +# LD_PRELOAD connect() guard blocks soffice egress (SSRF); default on, LIBREOFFICE_ALLOW_NETWORK=true opts out. +OFFICE_GUARD_LIB="/usr/local/lib/stirling/soffice_no_network.so" +OFFICE_LD_PRELOAD="" +case "$(printf '%s' "${LIBREOFFICE_ALLOW_NETWORK:-false}" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes|on) + log "LibreOffice network isolation DISABLED (LIBREOFFICE_ALLOW_NETWORK=${LIBREOFFICE_ALLOW_NETWORK})" + ;; + *) + if [ -f "$OFFICE_GUARD_LIB" ]; then + OFFICE_LD_PRELOAD="$OFFICE_GUARD_LIB" + log "LibreOffice network isolation enabled (guard: $OFFICE_GUARD_LIB)" + else + log "WARNING: LibreOffice network guard missing at $OFFICE_GUARD_LIB; conversions are NOT network-isolated" + fi + ;; +esac +export OFFICE_LD_PRELOAD + # ---------- unoserver ---------- # Start LibreOffice UNO server for document conversions. # Java and unoserver start in parallel, do NOT block here waiting for readiness. diff --git a/testing/cucumber/exampleFiles/security_flat_external.fodt b/testing/cucumber/exampleFiles/security_flat_external.fodt new file mode 100644 index 0000000000..63520658dc --- /dev/null +++ b/testing/cucumber/exampleFiles/security_flat_external.fodt @@ -0,0 +1,11 @@ + + + + flat ODF security fixture + + diff --git a/testing/cucumber/exampleFiles/security_flat_external.xml b/testing/cucumber/exampleFiles/security_flat_external.xml new file mode 100644 index 0000000000..63520658dc --- /dev/null +++ b/testing/cucumber/exampleFiles/security_flat_external.xml @@ -0,0 +1,11 @@ + + + + flat ODF security fixture + + diff --git a/testing/cucumber/exampleFiles/security_flat_inline.fodt b/testing/cucumber/exampleFiles/security_flat_inline.fodt new file mode 100644 index 0000000000..530c83b214 --- /dev/null +++ b/testing/cucumber/exampleFiles/security_flat_inline.fodt @@ -0,0 +1,11 @@ + + + + flat ODF security fixture + iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR42mM4IWIDRAwQCgAg3gRhp+AjVAAAAABJRU5ErkJggg== + diff --git a/testing/cucumber/features/office_security.feature b/testing/cucumber/features/office_security.feature new file mode 100644 index 0000000000..9c881ada8f --- /dev/null +++ b/testing/cucumber/features/office_security.feature @@ -0,0 +1,25 @@ +@convert @libre @security +Feature: Office conversion sanitizes flat-ODF documents + + # Regression for #7628/#7629: flat-ODF and content-renamed variants (.xml) must be sanitized by content, not extension. + + @positive @sanitize + Scenario Outline: Flat-ODF with an external reference converts with the reference stripped + Given I use an example file at "exampleFiles/security_flat_external" as parameter "fileInput" + When I send the API request to the endpoint "/api/v1/convert/file/pdf" + Then the response status code should be 200 + And the response file should have extension ".pdf" + And the response PDF should contain 0 embedded images + + Examples: + | ext | + | .fodt | + | .xml | + + @positive @sanitize + Scenario: Legitimate inline image in a flat-ODF is preserved + Given I use an example file at "exampleFiles/security_flat_inline.fodt" as parameter "fileInput" + When I send the API request to the endpoint "/api/v1/convert/file/pdf" + Then the response status code should be 200 + And the response file should have extension ".pdf" + And the response PDF should contain 1 embedded images diff --git a/testing/cucumber/features/steps/step_definitions.py b/testing/cucumber/features/steps/step_definitions.py index 5abefa049a..70d52bd85b 100644 --- a/testing/cucumber/features/steps/step_definitions.py +++ b/testing/cucumber/features/steps/step_definitions.py @@ -826,3 +826,15 @@ def step_response_matches_regex(context, pattern): assert re.match( pattern, response_text ), f"Response '{response_text}' does not match the expected pattern '{pattern}'" + + +@then("the response PDF should contain {count:d} embedded images") +def step_response_pdf_embedded_image_count(context, count): + reader = PdfReader(io.BytesIO(context.response.content)) + total = 0 + for page in reader.pages: + try: + total += len(page.images) + except Exception: + pass + assert total == count, f"Expected {count} embedded images in response PDF, found {total}"