diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go index 003f8b6a..d0be247a 100644 --- a/pkg/modules/chromium/browser.go +++ b/pkg/modules/chromium/browser.go @@ -49,6 +49,7 @@ type browserArguments struct { denyPublicIPs bool clearCache bool clearCookies bool + clearStorage bool disableJavaScript bool } @@ -367,6 +368,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out runtime.Enable(), clearCacheActionFunc(logger, b.arguments.clearCache), clearCookiesActionFunc(logger, b.arguments.clearCookies), + clearStorageActionFunc(logger, b.arguments.clearStorage, url), disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), setCookiesActionFunc(logger, options.Cookies), userAgentOverride(logger, options.UserAgent), @@ -393,6 +395,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u runtime.Enable(), clearCacheActionFunc(logger, b.arguments.clearCache), clearCookiesActionFunc(logger, b.arguments.clearCookies), + clearStorageActionFunc(logger, b.arguments.clearStorage, url), disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), setCookiesActionFunc(logger, options.Cookies), userAgentOverride(logger, options.UserAgent), diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index 67dd3e65..aa80b6cc 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -477,6 +477,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor { fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network") fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion") fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion") + fs.Bool("chromium-clear-storage", false, "Clear Chromium local storage between each conversion (session storage is already isolated per conversion)") fs.Bool("chromium-disable-javascript", false, "Disable JavaScript") fs.Bool("chromium-disable-routes", false, "Disable the routes") @@ -528,6 +529,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error { denyPublicIPs: flags.MustBool("chromium-deny-public-ips"), clearCache: flags.MustBool("chromium-clear-cache"), clearCookies: flags.MustBool("chromium-clear-cookies"), + clearStorage: flags.MustBool("chromium-clear-storage"), disableJavaScript: flags.MustBool("chromium-disable-javascript"), } diff --git a/pkg/modules/chromium/tasks.go b/pkg/modules/chromium/tasks.go index 6fa6cd5f..f2eb2eec 100644 --- a/pkg/modules/chromium/tasks.go +++ b/pkg/modules/chromium/tasks.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "net/url" "os" "strconv" "time" @@ -14,6 +15,7 @@ import ( "github.com/chromedp/cdproto/emulation" "github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/page" + "github.com/chromedp/cdproto/storage" "github.com/chromedp/chromedp" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" @@ -345,6 +347,54 @@ func clearCookiesActionFunc(logger *slog.Logger, clear bool) chromedp.ActionFunc } } +// clearStorageActionFunc clears the converted origin's local storage before the +// page loads, so state written by a previous conversion of the same origin does +// not leak into this one. See https://github.com/gotenberg/gotenberg/issues/919. +// +// Session storage is not touched: each conversion runs in its own browsing +// context (a fresh tab), so it is already isolated and cannot leak. Local +// storage is per-origin and shared across tabs of the long-lived browser, so it +// is the only web storage that carries over. +func clearStorageActionFunc(logger *slog.Logger, clear bool, rawURL string) chromedp.ActionFunc { + return func(ctx context.Context) error { + if !clear { + logger.DebugContext(ctx, "local storage not cleared") + return nil + } + + origin, ok := httpOrigin(rawURL) + if !ok { + // A file:// upload gets an opaque, per-request origin that is not + // shared between conversions, so there is nothing to clear. + logger.DebugContext(ctx, "local storage not cleared: non-http(s) origin is already isolated") + return nil + } + + logger.DebugContext(ctx, fmt.Sprintf("clear local storage for %s", origin)) + + err := storage.ClearDataForOrigin(origin, string(storage.TypeLocalStorage)).Do(ctx) + if err == nil { + return nil + } + + return fmt.Errorf("clear local storage: %w", err) + } +} + +// httpOrigin returns the http(s) security origin (scheme://host[:port]) of +// rawURL, and false when rawURL is not http(s). A non-http(s) URL such as a +// file:// upload has an opaque origin that no other conversion shares. +func httpOrigin(rawURL string) (string, bool) { + parsed, err := url.Parse(rawURL) + if err != nil { + return "", false + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", false + } + return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host), true +} + func disableJavaScriptActionFunc(logger *slog.Logger, disable bool) chromedp.ActionFunc { return func(ctx context.Context) error { // See https://github.com/gotenberg/gotenberg/issues/175. diff --git a/test/integration/features/chromium_convert_url.feature b/test/integration/features/chromium_convert_url.feature index 6fad3654..a8020d49 100644 --- a/test/integration/features/chromium_convert_url.feature +++ b/test/integration/features/chromium_convert_url.feature @@ -22,6 +22,41 @@ Feature: /forms/chromium/convert/url Page 1 """ + # localStorage is per-origin and shared by the long-lived browser, so without + # clearing it accumulates across same-origin conversions. + # See https://github.com/gotenberg/gotenberg/issues/919. + Scenario: POST /forms/chromium/convert/url (localStorage leaks without clearing) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1 + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the "foo.pdf" PDF should have content matching "localStorageCount=2" at page 1 + + # With --chromium-clear-storage every conversion clears the origin's + # localStorage first, so the counter never carries over. + # See https://github.com/gotenberg/gotenberg/issues/919. + Scenario: POST /forms/chromium/convert/url (Clear Storage) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_CLEAR_STORAGE | true | + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1 + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1 + Scenario: POST /forms/chromium/convert/url (Single Page) Given I have a default Gotenberg container Given I have a static server diff --git a/test/integration/features/debug.feature b/test/integration/features/debug.feature index 1cc15a1e..cbd67351 100644 --- a/test/integration/features/debug.feature +++ b/test/integration/features/debug.feature @@ -83,6 +83,7 @@ Feature: /debug "chromium-auto-start": "false", "chromium-clear-cache": "false", "chromium-clear-cookies": "false", + "chromium-clear-storage": "false", "chromium-deny-list": "[^file:(?!//\\/tmp/).*]", "chromium-deny-private-ips": "false", "chromium-deny-public-ips": "false", @@ -221,6 +222,7 @@ Feature: /debug "chromium-auto-start": "false", "chromium-clear-cache": "false", "chromium-clear-cookies": "false", + "chromium-clear-storage": "false", "chromium-deny-list": "[^file:(?!//\\/tmp/).*]", "chromium-deny-private-ips": "false", "chromium-deny-public-ips": "false", diff --git a/test/integration/testdata/local-storage-html/index.html b/test/integration/testdata/local-storage-html/index.html new file mode 100644 index 00000000..d8fc18fa --- /dev/null +++ b/test/integration/testdata/local-storage-html/index.html @@ -0,0 +1,18 @@ + + + + + Local storage counter + + + +

+    
+  
+