fix(chromium): filter WebSocket handshakes against the outbound policy

This commit is contained in:
Julien Neuhart
2026-08-12 20:36:32 +02:00
parent 357c3b4a59
commit dc7c68152c
13 changed files with 324 additions and 2 deletions

View File

@@ -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` |

View File

@@ -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/).* |

View File

@@ -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 | .+ |

View File

@@ -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):

View File

@@ -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
}

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>EventSource SSRF</title>
</head>
<body>
<h1>EventSource SSRF</h1>
<script type="application/javascript">
// EventSource issues an ordinary HTTP GET, so it is surfaced as a
// Fetch.requestPaused event and blocked by the outbound filter.
new EventSource("http://127.0.0.1:9999/sse");
</script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Fetch SSRF Control</title>
</head>
<body>
<h1>Fetch SSRF Control</h1>
<script type="application/javascript">
// Control: an ordinary fetch to a loopback address IS surfaced as a
// Fetch.requestPaused event and blocked by CHROMIUM_DENY_PRIVATE_IPS.
fetch("http://127.0.0.1:9999/ssrf-fetch").catch(() => {});
</script>
</body>
</html>

View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>WebSocket SSRF</title>
</head>
<body>
<h1>WebSocket SSRF</h1>
<script type="application/javascript">
// Both targets resolve to non-public addresses, so CHROMIUM_DENY_PRIVATE_IPS
// must block them. Unlike fetch/XHR/sub-resources, a WebSocket handshake is
// never surfaced as a Fetch.requestPaused event, so it currently escapes the
// outbound filter entirely.
// 127.0.0.1 -> loopback
// 169.254.169.254 -> link-local (cloud metadata)
new WebSocket("ws://127.0.0.1:9999/ssrf-websocket");
new WebSocket("ws://169.254.169.254:80/ssrf-websocket");
</script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>WSS SSRF</title>
</head>
<body>
<h1>WSS SSRF</h1>
<script type="application/javascript">
// wss:// (TLS) handshakes tunnel through the proxy via CONNECT, the same
// path as ws://, and must be filtered identically.
new WebSocket("wss://127.0.0.1:9999/wss-test");
</script>
</body>
</html>

View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Worker WebSocket SSRF</title>
</head>
<body>
<h1>Worker WebSocket SSRF</h1>
<script type="application/javascript">
// A Web Worker is a separate CDP target, so its WebSocket handshake is
// not observed by listenForEventWebSocketCreated (which listens on the
// page target). Enforcement must therefore not rely on that listener:
// the pinning proxy sees every handshake regardless of the originating
// context and severs the connection.
const source =
'new WebSocket("ws://127.0.0.1:9999/ws-from-worker");' +
'new WebSocket("ws://169.254.169.254:80/ws-from-worker-metadata");';
const blob = new Blob([source], { type: "application/javascript" });
new Worker(URL.createObjectURL(blob));
</script>
</body>
</html>