Harden office conversion against SSRF and local-file readback (#7628)

This commit is contained in:
Anthony Stirling
2026-08-23 13:23:30 +01:00
parent 63ff3afb41
commit 12703611b4
19 changed files with 603 additions and 36 deletions
+1
View File
@@ -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}"
@@ -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 <!DOCTYPE ...> 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("<!DOCTYPE");
if (start < 0) {
return null;
}
int depth = 0;
for (int i = start + "<!DOCTYPE".length(); i < s.length(); i++) {
char c = s.charAt(i);
if (c == '[') {
depth++;
} else if (c == ']') {
if (depth > 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<String> hrefAttrsToRemove = new ArrayList<>();
List<String> 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.
@@ -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<Processes, ProcessExecutor> 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<String> 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<String, String> 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);
@@ -343,6 +343,112 @@ class OfficeDocumentSanitizerTest {
assertTrue(out.contains("#anchor"));
}
private static String flatOdf(String href) {
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<office:document"
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
+ " xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\""
+ " xmlns:draw=\"urn:oasis:names:tc:opendocument:xmlns:drawing:1.0\""
+ " xmlns:xlink=\"http://www.w3.org/1999/xlink\""
+ " office:mimetype=\"application/vnd.oasis.opendocument.text\">"
+ "<office:body><office:text>"
+ "<draw:frame><draw:image xlink:href=\""
+ href
+ "\"/></draw:frame>"
+ "</office:text></office:body></office:document>";
}
@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 =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<!DOCTYPE office:document>"
+ "<office:document"
+ " 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=\"file:///etc/passwd\"/>"
+ "</draw:frame></office:text></office:body></office:document>";
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 =
"<?xml version=\"1.0\"?><doc xmlns:xlink=\"http://www.w3.org/1999/xlink\">"
+ "<img src=\"file:///etc/passwd\"/></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 = "<not-well-formed <<< &amp".getBytes(StandardCharsets.UTF_8);
assertThrows(IOException.class, () -> sanitizer.sanitize(garbage, "fodt"));
}
private static byte[] zip(Map<String, byte[]> entries) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
@@ -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("<!doctype html") || head.startsWith("<html");
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/file/pdf",
+13
View File
@@ -51,6 +51,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}
@@ -68,6 +77,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/
+12
View File
@@ -367,6 +367,16 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
find /opt/venv -name '*.so' -exec strip --strip-unneeded {} + 2>/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 \
+55
View File
@@ -0,0 +1,55 @@
/* LD_PRELOAD connect() guard: blocks LibreOffice/unoserver egress (SSRF); loopback stays open for the UNO bridge. */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <errno.h>
#include <string.h>
#include <stdint.h>
#include <sys/socket.h>
#include <netinet/in.h>
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);
}
+13
View File
@@ -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/
+13
View File
@@ -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/
@@ -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
(`<draw:image xlink:href="http://...">`, `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:
+11
View File
@@ -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}" && \
+18 -1
View File
@@ -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" \
+20 -1
View File
@@ -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.
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
office:version="1.2" office:mimetype="application/vnd.oasis.opendocument.text">
<office:body><office:text>
<text:p>flat ODF security fixture</text:p>
<text:p><draw:frame svg:width="2cm" svg:height="2cm"><draw:image xlink:href="http://192.0.2.1/should-be-stripped.png" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame></text:p>
</office:text></office:body></office:document>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
office:version="1.2" office:mimetype="application/vnd.oasis.opendocument.text">
<office:body><office:text>
<text:p>flat ODF security fixture</text:p>
<text:p><draw:frame svg:width="2cm" svg:height="2cm"><draw:image xlink:href="http://192.0.2.1/should-be-stripped.png" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame></text:p>
</office:text></office:body></office:document>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
office:version="1.2" office:mimetype="application/vnd.oasis.opendocument.text">
<office:body><office:text>
<text:p>flat ODF security fixture</text:p>
<text:p><draw:frame svg:width="2cm" svg:height="2cm"><draw:image><office:binary-data>iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR42mM4IWIDRAwQCgAg3gRhp+AjVAAAAABJRU5ErkJggg==</office:binary-data></draw:image></draw:frame></text:p>
</office:text></office:body></office:document>
@@ -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<ext>" 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
@@ -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}"