diff --git a/Makefile b/Makefile index 94469753..1a8f2d22 100644 --- a/Makefile +++ b/Makefile @@ -147,6 +147,7 @@ NO_CONCURRENCY=false # chromium-screenshot-html # chromium-screenshot-markdown # chromium-screenshot-url +# chromium-ssrf # debug # health # libreoffice diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go index e2499bc4..2fdd90ab 100644 --- a/pkg/modules/chromium/browser.go +++ b/pkg/modules/chromium/browser.go @@ -179,6 +179,28 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error { return fmt.Errorf("start pinning proxy: %w", err) } opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL())) + + if b.arguments.denyPrivateIPs || b.arguments.denyPublicIPs { + // Chromium implicitly bypasses the proxy for loopback and + // link-local destinations. A WebSocket handshake is never surfaced + // as a fetch.EventRequestPaused, so listenForEventRequestPaused + // cannot filter it; the pinning proxy is the only layer that sees + // it. Left alone, a page could open a WebSocket to 127.0.0.1, ::1, + // localhost, or the link-local cloud metadata endpoint + // (169.254.169.254) and reach it unfiltered. "<-loopback>" removes + // the implicit bypass so those handshakes also traverse the pinning + // proxy and go through [gotenberg.DecideOutbound] like every other + // request. + // + // Gated on the IP-class policy: it is the control this closes, and + // under it loopback and link-local HTTP sub-resources are already + // blocked by listenForEventRequestPaused before they would reach + // the proxy, so this adds only the missing WebSocket coverage. When + // the policy is off, loopback is not restricted, and routing it + // through the proxy would merely change how an unreachable loopback + // sub-resource reports its failure. + opts = append(opts, chromedp.Flag("proxy-bypass-list", "<-loopback>")) + } } // See https://github.com/gotenberg/gotenberg/issues/524. @@ -434,6 +456,17 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin extraHttpHeaders: options.ExtraHttpHeaders, }) + // WebSocket handshakes never surface as fetch.EventRequestPaused, so + // listenForEventRequestPaused above cannot filter them. Validate them + // against the same allow / deny lists and IP-class policy. + // See https://github.com/gotenberg/gotenberg/issues/1011. + listenForEventWebSocketCreated(taskCtx, logger, eventWebSocketCreatedOptions{ + allowList: b.arguments.allowList, + denyList: b.arguments.denyList, + denyPrivateIPs: b.arguments.denyPrivateIPs, + denyPublicIPs: b.arguments.denyPublicIPs, + }) + var ( invalidHttpStatusCode error invalidHttpStatusCodeMu sync.RWMutex diff --git a/pkg/modules/chromium/events.go b/pkg/modules/chromium/events.go index b01879f5..b85beba2 100644 --- a/pkg/modules/chromium/events.go +++ b/pkg/modules/chromium/events.go @@ -44,6 +44,54 @@ func listenForNetworkActivity(ctx context.Context, aggregate *networkAggregate) }) } +type eventWebSocketCreatedOptions struct { + allowList, denyList []*regexp2.Regexp + denyPrivateIPs bool + denyPublicIPs bool +} + +// listenForEventWebSocketCreated validates the target of every WebSocket +// handshake against the same allow / deny lists and IP-class policy as +// [listenForEventRequestPaused]. Chromium never surfaces a WebSocket +// handshake as a fetch.EventRequestPaused, so without this listener a page +// could open a WebSocket to an address the outbound filter would otherwise +// block. See https://github.com/gotenberg/gotenberg/issues/1011. +// +// This listener records an operator-visible warning with the full ws:// URL. +// The connection itself is severed by the pinning proxy, which every +// WebSocket handshake traverses once the implicit loopback / link-local proxy +// bypass is removed (see the "<-loopback>" flag in browser.go). When the +// operator configures a custom proxy or host-resolver mappings, the pinning +// proxy is not started; the WebSocket then follows the operator's egress path +// and this warning is the remaining safeguard, since a WebSocket handshake +// cannot be aborted through the CDP Network domain. +func listenForEventWebSocketCreated(ctx context.Context, logger *slog.Logger, options eventWebSocketCreatedOptions) { + chromedp.ListenTarget(ctx, func(ev any) { + e, ok := ev.(*network.EventWebSocketCreated) + if !ok { + return + } + + go func() { + logger.DebugContext(ctx, fmt.Sprintf("event EventWebSocketCreated fired for '%s'", e.URL)) + + deadline, ok := ctx.Deadline() + if !ok { + logger.ErrorContext(ctx, "context has no deadline, cannot filter WebSocket URL") + return + } + + err := gotenberg.FilterOutboundURL(ctx, e.URL, options.allowList, options.denyList, deadline, + gotenberg.WithDenyPrivateIPs(options.denyPrivateIPs), + gotenberg.WithDenyPublicIPs(options.denyPublicIPs), + ) + if err != nil { + logger.WarnContext(ctx, err.Error()) + } + }() + }) +} + type eventRequestPausedOptions struct { allowList, denyList []*regexp2.Regexp denyPrivateIPs bool diff --git a/test/integration/README.md b/test/integration/README.md index 29b11611..97007ab6 100644 --- a/test/integration/README.md +++ b/test/integration/README.md @@ -25,8 +25,8 @@ Available tags: | Group | Tags | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url` | -| LibreOffice | `libreoffice`, `libreoffice-convert` | +| Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url`, `chromium-ssrf` | +| LibreOffice | `libreoffice`, `libreoffice-convert` | | PDF Engines | `pdfengines`, `pdfengines-convert`, `pdfengines-merge`, `merge`, `pdfengines-split`, `split`, `pdfengines-flatten`, `flatten`, `pdfengines-rotate`, `rotate`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-metadata`, `metadata`, `pdfengines-bookmarks`, `bookmarks` | | Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` | diff --git a/test/integration/features/chromium_convert_html.feature b/test/integration/features/chromium_convert_html.feature index 77f60051..c111aa91 100644 --- a/test/integration/features/chromium_convert_html.feature +++ b/test/integration/features/chromium_convert_html.feature @@ -416,6 +416,87 @@ Feature: /forms/chromium/convert/html Then the Gotenberg container should log the following entries: | 'file:///etc/passwd' matches the expression from the denied list | + # Control for the WebSocket scenario below. An ordinary fetch to a loopback + # address is surfaced as a Fetch.requestPaused event, so it is blocked by + # CHROMIUM_DENY_PRIVATE_IPS and the block is logged. The allow-list is + # cleared because a matching allow-list entry bypasses the IP-based check. + @chromium-ssrf + Scenario: POST /forms/chromium/convert/html (Fetch to a non-public address is filtered) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | | + | CHROMIUM_DENY_PRIVATE_IPS | true | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/ssrf-fetch-html/index.html | file | + | waitDelay | 1s | field | + Then the response status code should be 200 + Then the Gotenberg container should log the following entries: + | 'http://127.0.0.1:9999/ssrf-fetch' targets a non-public address | + + # A WebSocket handshake is never surfaced as a Fetch.requestPaused event, so + # it escapes the filter in listenForEventRequestPaused. The page opens + # WebSockets to two non-public addresses (loopback and the link-local cloud + # metadata IP). listenForEventWebSocketCreated logs each disallowed handshake + # with its full ws:// URL (detection), and the pinning proxy severs the + # connection now that the implicit loopback bypass is removed (enforcement). + @chromium-ssrf + Scenario: POST /forms/chromium/convert/html (WebSocket to a non-public address is filtered) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | | + | CHROMIUM_DENY_PRIVATE_IPS | true | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/ssrf-websocket-html/index.html | file | + | waitDelay | 1s | field | + Then the response status code should be 200 + Then the Gotenberg container should log the following entries: + | 'ws://127.0.0.1:9999/ssrf-websocket' targets a non-public address | + | CONNECT blocked for '127.0.0.1:9999' | + + # A Web Worker is a separate CDP target, so its WebSocket handshake is not + # observed by listenForEventWebSocketCreated. Enforcement must not depend on + # that listener: the pinning proxy sees the handshake and severs it whatever + # the originating context. Only the proxy's block is asserted, since no + # detection log is produced for the worker target. + @chromium-ssrf + Scenario: POST /forms/chromium/convert/html (WebSocket from a Web Worker is filtered) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | | + | CHROMIUM_DENY_PRIVATE_IPS | true | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/ssrf-websocket-worker-html/index.html | file | + | waitDelay | 1s | field | + Then the response status code should be 200 + Then the Gotenberg container should log the following entries: + | CONNECT blocked for '127.0.0.1:9999' | + + # wss:// (TLS) handshakes tunnel through the proxy via CONNECT, the same path + # as ws://, and must be filtered identically. + @chromium-ssrf + Scenario: POST /forms/chromium/convert/html (Secure WebSocket to a non-public address is filtered) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | | + | CHROMIUM_DENY_PRIVATE_IPS | true | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/ssrf-websocket-tls-html/index.html | file | + | waitDelay | 1s | field | + Then the response status code should be 200 + Then the Gotenberg container should log the following entries: + | 'wss://127.0.0.1:9999/wss-test' targets a non-public address | + | CONNECT blocked for '127.0.0.1:9999' | + + # EventSource issues an ordinary HTTP GET, so unlike a WebSocket it IS surfaced + # as a fetch.EventRequestPaused and blocked by listenForEventRequestPaused. + @chromium-ssrf + Scenario: POST /forms/chromium/convert/html (EventSource to a non-public address is filtered) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | | + | CHROMIUM_DENY_PRIVATE_IPS | true | + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/ssrf-eventsource-html/index.html | file | + | waitDelay | 1s | field | + Then the response status code should be 200 + Then the Gotenberg container should log the following entries: + | 'http://127.0.0.1:9999/sse' targets a non-public address | + Scenario: POST /forms/chromium/convert/html (Main URL does NOT match allowed list) Given I have a Gotenberg container with the following environment variable(s): | CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* | diff --git a/test/integration/features/chromium_convert_url.feature b/test/integration/features/chromium_convert_url.feature index b0b21feb..6fad3654 100644 --- a/test/integration/features/chromium_convert_url.feature +++ b/test/integration/features/chromium_convert_url.feature @@ -499,6 +499,7 @@ Feature: /forms/chromium/convert/url Then the response header "Content-Type" should be "application/pdf" Then there should be 1 PDF(s) in the response + @chromium-ssrf Scenario: POST /forms/chromium/convert/url (Main URL is a non-public IP literal, deny-private-ips on) Given I have a Gotenberg container with the following environment variable(s): | CHROMIUM_ALLOW_LIST | | @@ -512,6 +513,55 @@ Feature: /forms/chromium/convert/url Forbidden """ + # IPv6 loopback literal is parsed as an IP and rejected by the IP-class check, + # like the IPv4 loopback literal above. + @chromium-ssrf + Scenario: POST /forms/chromium/convert/url (Main URL is an IPv6 loopback literal, deny-private-ips on) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | | + | CHROMIUM_DENY_PRIVATE_IPS | true | + 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://[::1]/ | field | + Then the response status code should be 403 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Forbidden + """ + + # An alternate IP encoding (decimal for 127.0.0.1) that Chromium would read + # as loopback but the resolver rejects as a hostname. It must fail closed as + # filtered (a generic 403), not surface as a 500. + @chromium-ssrf + Scenario: POST /forms/chromium/convert/url (Main URL is a decimal-encoded loopback IP, deny-private-ips on) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | | + | CHROMIUM_DENY_PRIVATE_IPS | true | + 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://2130706433/ | field | + Then the response status code should be 403 + Then the response header "Content-Type" should be "text/plain; charset=UTF-8" + Then the response body should match string: + """ + Forbidden + """ + + # A classic SSRF vector: an allow-listed URL that redirects to an internal + # address. The redirected request must not inherit the initial URL's + # allow-list pass. listenForEventRequestPaused re-validates it; it does not + # match the allow-list, so it is blocked (Chromium reports ERR_ACCESS_DENIED + # and the conversion renders the resulting error page). + @chromium-ssrf + Scenario: POST /forms/chromium/convert/url (Redirect to a non-allow-listed address is re-filtered) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | ^https?://host.docker.internal.* | + 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/redirect-to-private | field | + Then the response status code should be 200 + Then the Gotenberg container should log the following entries: + | 'http://127.0.0.1:9999/redirected' does not match any expression from the allowed list | + Scenario: POST /forms/chromium/convert/url (Main URL resolves to a non-public IP, deny-private-ips on with allow-list bypass) Given I have a Gotenberg container with the following environment variable(s): | CHROMIUM_ALLOW_LIST | .+ | diff --git a/test/integration/features/chromium_screenshot_html.feature b/test/integration/features/chromium_screenshot_html.feature index 69ff690d..be3ddabe 100644 --- a/test/integration/features/chromium_screenshot_html.feature +++ b/test/integration/features/chromium_screenshot_html.feature @@ -2,6 +2,22 @@ @chromium-screenshot-html Feature: /forms/chromium/screenshot/html + # Route parity: the WebSocket outbound filter lives in the shared browser + # code path, so the screenshot route enforces it exactly like conversion. + @chromium-ssrf + Scenario: POST /forms/chromium/screenshot/html (WebSocket to a non-public address is filtered) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | | + | CHROMIUM_DENY_PRIVATE_IPS | true | + When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s): + | files | testdata/ssrf-websocket-html/index.html | file | + | waitDelay | 1s | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "image/png" + Then the Gotenberg container should log the following entries: + | 'ws://127.0.0.1:9999/ssrf-websocket' targets a non-public address | + | CONNECT blocked for '127.0.0.1:9999' | + Scenario: POST /forms/chromium/screenshot/html (Default) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s): diff --git a/test/integration/scenario/server.go b/test/integration/scenario/server.go index ad9144f4..a9c4a65f 100644 --- a/test/integration/scenario/server.go +++ b/test/integration/scenario/server.go @@ -181,6 +181,12 @@ func newServer(ctx context.Context, workdir string) (*server, error) { } return c.HTML(http.StatusOK, string(b)) }) + srv.GET("/redirect-to-private", func(c echo.Context) error { + s.req = c.Request() + // Redirect the browser to a non-public address so the outbound filter + // is exercised on the redirected request rather than on this URL. + return c.Redirect(http.StatusFound, "http://127.0.0.1:9999/redirected") + }) return s, nil } diff --git a/test/integration/testdata/ssrf-eventsource-html/index.html b/test/integration/testdata/ssrf-eventsource-html/index.html new file mode 100644 index 00000000..c203bc68 --- /dev/null +++ b/test/integration/testdata/ssrf-eventsource-html/index.html @@ -0,0 +1,15 @@ + + +
+ +