mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 00:22:14 +01:00
feat(libreoffice): block linked content from untrusted locations
This commit is contained in:
@@ -70,11 +70,13 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
|
|||||||
|
|
||||||
// LibreOffice fetches external content (OOXML images via
|
// LibreOffice fetches external content (OOXML images via
|
||||||
// TargetMode=External, RTF INCLUDEPICTURE, ODT linked images) inside
|
// TargetMode=External, RTF INCLUDEPICTURE, ODT linked images) inside
|
||||||
// its own libcurl. Route those fetches through the in-process proxy
|
// its own libcurl. The profile config routes those fetches through the
|
||||||
// so the chromium/webhook SSRF filters apply.
|
// in-process proxy so the chromium/webhook SSRF filters apply, and
|
||||||
if err := writeSofficeProxyConfig(userProfileDirPath, proxy.Addr()); err != nil {
|
// 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())
|
_ = 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())
|
sofficeEnv := sofficeProxyEnv(os.Environ(), proxy.Addr())
|
||||||
|
|
||||||
|
|||||||
@@ -245,13 +245,26 @@ var hopByHopHeaders = []string{
|
|||||||
"Upgrade",
|
"Upgrade",
|
||||||
}
|
}
|
||||||
|
|
||||||
// sofficeProxyConfigTmpl is the registrymodifications.xcu fragment that
|
// sofficeProfileConfigTmpl is the registrymodifications.xcu the soffice
|
||||||
// tells soffice's UCB layer to route every HTTP and HTTPS fetch through
|
// daemon loads at startup. It does two things:
|
||||||
// proxyHost:proxyPort. The %s placeholders accept the proxy host and
|
//
|
||||||
// port respectively (host first, port second, repeated for HTTP and
|
// 1. Routes every HTTP and HTTPS fetch through proxyHost:proxyPort so
|
||||||
// HTTPS).
|
// soffice's own libcurl fetches hit the in-process SSRF proxy.
|
||||||
const sofficeProxyConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
|
// 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">
|
<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="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="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>
|
<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>
|
</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
|
// userProfileDirPath/user/ that points soffice's UCB layer at proxyAddr
|
||||||
// for both HTTP and HTTPS. proxyAddr must be a host:port pair.
|
// for both HTTP and HTTPS and blocks linked content from untrusted
|
||||||
func writeSofficeProxyConfig(userProfileDirPath, proxyAddr string) error {
|
// locations. proxyAddr must be a host:port pair.
|
||||||
|
func writeSofficeProfileConfig(userProfileDirPath, proxyAddr string) error {
|
||||||
host, port, err := net.SplitHostPort(proxyAddr)
|
host, port, err := net.SplitHostPort(proxyAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("split proxy address %q: %w", proxyAddr, err)
|
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)
|
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)
|
err = os.WriteFile(userDir+"/registrymodifications.xcu", []byte(body), 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("write registrymodifications.xcu: %w", err)
|
return fmt.Errorf("write registrymodifications.xcu: %w", err)
|
||||||
|
|||||||
@@ -280,11 +280,11 @@ func TestLibreOfficeProxy_StopIsIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriteSofficeProxyConfig(t *testing.T) {
|
func TestWriteSofficeProfileConfig(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
if err := writeSofficeProxyConfig(dir, "127.0.0.1:9876"); err != nil {
|
if err := writeSofficeProfileConfig(dir, "127.0.0.1:9876"); err != nil {
|
||||||
t.Fatalf("writeSofficeProxyConfig: %v", err)
|
t.Fatalf("writeSofficeProfileConfig: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := os.ReadFile(filepath.Join(dir, "user", "registrymodifications.xcu"))
|
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>`,
|
`ooInetHTTPProxyName`, `<value>127.0.0.1</value>`,
|
||||||
`ooInetHTTPProxyPort`, `<value>9876</value>`,
|
`ooInetHTTPProxyPort`, `<value>9876</value>`,
|
||||||
`ooInetHTTPSProxyName`, `ooInetHTTPSProxyPort`,
|
`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) {
|
if !strings.Contains(string(body), want) {
|
||||||
t.Errorf("xcu missing %q\nfull body:\n%s", want, body)
|
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) {
|
func TestWriteSofficeProfileConfig_InvalidAddr(t *testing.T) {
|
||||||
err := writeSofficeProxyConfig(t.TempDir(), "not-a-host-port")
|
err := writeSofficeProfileConfig(t.TempDir(), "not-a-host-port")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for malformed proxy address")
|
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 the response header "Content-Type" should be "application/pdf"
|
||||||
Then there should be 1 PDF(s) in the response
|
Then there should be 1 PDF(s) in the response
|
||||||
Then the "foo.pdf" PDF should have 1 page(s)
|
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)
|
||||||
|
|||||||
7
test/integration/testdata/libreoffice-embedded-image.fodt
vendored
Normal file
7
test/integration/testdata/libreoffice-embedded-image.fodt
vendored
Normal file
@@ -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>
|
||||||
7
test/integration/testdata/libreoffice-linked-external.fodt
vendored
Normal file
7
test/integration/testdata/libreoffice-linked-external.fodt
vendored
Normal file
@@ -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>
|
||||||
Reference in New Issue
Block a user