mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Harden office conversion against SSRF and local-file readback (#7628)
This commit is contained in:
@@ -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}"
|
||||
|
||||
+145
-26
@@ -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);
|
||||
|
||||
+106
@@ -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 <<< &".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)) {
|
||||
|
||||
+23
-8
@@ -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",
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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" \
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user