feat(libreoffice): block linked content from untrusted locations

This commit is contained in:
Julien Neuhart
2026-06-11 14:51:23 +02:00
parent 808a96f3d0
commit 98fc403478
6 changed files with 77 additions and 19 deletions
+6 -4
View File
@@ -70,11 +70,13 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
// LibreOffice fetches external content (OOXML images via
// TargetMode=External, RTF INCLUDEPICTURE, ODT linked images) inside
// its own libcurl. Route those fetches through the in-process proxy
// so the chromium/webhook SSRF filters apply.
if err := writeSofficeProxyConfig(userProfileDirPath, proxy.Addr()); err != nil {
// its own libcurl. The profile config routes those fetches through the
// in-process proxy so the chromium/webhook SSRF filters apply, and
// blocks content linked from untrusted locations so absolute-path
// (file://) and direct fetches are dropped at the source.
if err := writeSofficeProfileConfig(userProfileDirPath, proxy.Addr()); err != nil {
_ = proxy.Stop(context.Background())
return fmt.Errorf("write soffice proxy config: %w", err)
return fmt.Errorf("write soffice profile config: %w", err)
}
sofficeEnv := sofficeProxyEnv(os.Environ(), proxy.Addr())
+24 -10
View File
@@ -245,13 +245,26 @@ var hopByHopHeaders = []string{
"Upgrade",
}
// sofficeProxyConfigTmpl is the registrymodifications.xcu fragment that
// tells soffice's UCB layer to route every HTTP and HTTPS fetch through
// proxyHost:proxyPort. The %s placeholders accept the proxy host and
// port respectively (host first, port second, repeated for HTTP and
// HTTPS).
const sofficeProxyConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
// sofficeProfileConfigTmpl is the registrymodifications.xcu the soffice
// daemon loads at startup. It does two things:
//
// 1. Routes every HTTP and HTTPS fetch through proxyHost:proxyPort so
// soffice's own libcurl fetches hit the in-process SSRF proxy.
// 2. Sets BlockUntrustedRefererLinks so soffice refuses to load content
// linked from a document that sits in an untrusted location.
//
// The second setting closes the local-read and direct-fetch vectors the
// proxy cannot see. A document that links an absolute path
// (file:///etc/...) or any URL is loaded from the per-request temp dir,
// which is never a trusted location, so soffice drops the linked content
// instead of resolving it. Embedded content (stored inside the document)
// is unaffected.
//
// The %s placeholders accept the proxy host and port respectively (host
// first, port second, repeated for HTTP and HTTPS).
const sofficeProfileConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="BlockUntrustedRefererLinks" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetProxyType" oor:op="fuse"><value>1</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyName" oor:op="fuse"><value>%s</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyPort" oor:op="fuse"><value>%s</value></prop></item>
@@ -261,10 +274,11 @@ const sofficeProxyConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
</oor:items>
`
// writeSofficeProxyConfig drops a registrymodifications.xcu file into
// writeSofficeProfileConfig drops a registrymodifications.xcu file into
// userProfileDirPath/user/ that points soffice's UCB layer at proxyAddr
// for both HTTP and HTTPS. proxyAddr must be a host:port pair.
func writeSofficeProxyConfig(userProfileDirPath, proxyAddr string) error {
// for both HTTP and HTTPS and blocks linked content from untrusted
// locations. proxyAddr must be a host:port pair.
func writeSofficeProfileConfig(userProfileDirPath, proxyAddr string) error {
host, port, err := net.SplitHostPort(proxyAddr)
if err != nil {
return fmt.Errorf("split proxy address %q: %w", proxyAddr, err)
@@ -276,7 +290,7 @@ func writeSofficeProxyConfig(userProfileDirPath, proxyAddr string) error {
return fmt.Errorf("create soffice user profile directory: %w", err)
}
body := fmt.Sprintf(sofficeProxyConfigTmpl, host, port, host, port)
body := fmt.Sprintf(sofficeProfileConfigTmpl, host, port, host, port)
err = os.WriteFile(userDir+"/registrymodifications.xcu", []byte(body), 0o600)
if err != nil {
return fmt.Errorf("write registrymodifications.xcu: %w", err)
+8 -5
View File
@@ -280,11 +280,11 @@ func TestLibreOfficeProxy_StopIsIdempotent(t *testing.T) {
}
}
func TestWriteSofficeProxyConfig(t *testing.T) {
func TestWriteSofficeProfileConfig(t *testing.T) {
dir := t.TempDir()
if err := writeSofficeProxyConfig(dir, "127.0.0.1:9876"); err != nil {
t.Fatalf("writeSofficeProxyConfig: %v", err)
if err := writeSofficeProfileConfig(dir, "127.0.0.1:9876"); err != nil {
t.Fatalf("writeSofficeProfileConfig: %v", err)
}
body, err := os.ReadFile(filepath.Join(dir, "user", "registrymodifications.xcu"))
@@ -297,6 +297,9 @@ func TestWriteSofficeProxyConfig(t *testing.T) {
`ooInetHTTPProxyName`, `<value>127.0.0.1</value>`,
`ooInetHTTPProxyPort`, `<value>9876</value>`,
`ooInetHTTPSProxyName`, `ooInetHTTPSProxyPort`,
// Blocks linked content from untrusted locations, closing the
// file:// local-read and direct-fetch vectors the proxy cannot see.
`BlockUntrustedRefererLinks`, `<value>true</value>`,
} {
if !strings.Contains(string(body), want) {
t.Errorf("xcu missing %q\nfull body:\n%s", want, body)
@@ -304,8 +307,8 @@ func TestWriteSofficeProxyConfig(t *testing.T) {
}
}
func TestWriteSofficeProxyConfig_InvalidAddr(t *testing.T) {
err := writeSofficeProxyConfig(t.TempDir(), "not-a-host-port")
func TestWriteSofficeProfileConfig_InvalidAddr(t *testing.T) {
err := writeSofficeProfileConfig(t.TempDir(), "not-a-host-port")
if err == nil {
t.Fatal("expected error for malformed proxy address")
}
@@ -939,3 +939,28 @@ Feature: /forms/libreoffice/convert
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "foo.pdf" PDF should have 1 page(s)
# An embedded image is stored inside the document, not linked, so blocking
# untrusted linked content leaves it untouched. Guards against over-blocking.
@libreoffice-linked-content
Scenario: POST /forms/libreoffice/convert (Embedded Image Survives)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/libreoffice-embedded-image.fodt | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then there should be 1 PDF(s) in the response
Then the "foo.pdf" PDF should have 1 image(s)
# An uploaded document always loads from an untrusted location, so soffice
# refuses to resolve any content it links (absolute file:// path or external
# URL). Closes the SSRF and local-file-read vector.
@libreoffice-linked-content
Scenario: POST /forms/libreoffice/convert (Linked External Resource Blocked)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/libreoffice-linked-external.fodt | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then there should be 1 PDF(s) in the response
Then the "foo.pdf" PDF should have 0 image(s)
@@ -0,0 +1,7 @@
<?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: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>An embedded image is stored in the document and must survive.</text:p>
<text:p><draw:frame text:anchor-type="as-char" svg:width="3cm" svg:height="3cm"><draw:image><office:binary-data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC</office:binary-data></draw:image></draw:frame></text:p>
</office:text></office:body>
</office:document>
@@ -0,0 +1,7 @@
<?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>An image linked by absolute path outside the document folder must not load.</text:p>
<text:p><draw:frame text:anchor-type="as-char" svg:width="3cm" svg:height="3cm"><draw:image xlink:href="file:///usr/lib/libreoffice/share/gallery/backgrounds/giraffe.png" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame></text:p>
</office:text></office:body>
</office:document>