mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-18 21:22:15 +01:00
fix(chromium): filter WebSocket handshakes against the outbound policy
This commit is contained in:
1
Makefile
1
Makefile
@@ -147,6 +147,7 @@ NO_CONCURRENCY=false
|
|||||||
# chromium-screenshot-html
|
# chromium-screenshot-html
|
||||||
# chromium-screenshot-markdown
|
# chromium-screenshot-markdown
|
||||||
# chromium-screenshot-url
|
# chromium-screenshot-url
|
||||||
|
# chromium-ssrf
|
||||||
# debug
|
# debug
|
||||||
# health
|
# health
|
||||||
# libreoffice
|
# libreoffice
|
||||||
|
|||||||
@@ -179,6 +179,28 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
|||||||
return fmt.Errorf("start pinning proxy: %w", err)
|
return fmt.Errorf("start pinning proxy: %w", err)
|
||||||
}
|
}
|
||||||
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
|
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.
|
// 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,
|
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 (
|
var (
|
||||||
invalidHttpStatusCode error
|
invalidHttpStatusCode error
|
||||||
invalidHttpStatusCodeMu sync.RWMutex
|
invalidHttpStatusCodeMu sync.RWMutex
|
||||||
|
|||||||
@@ -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 {
|
type eventRequestPausedOptions struct {
|
||||||
allowList, denyList []*regexp2.Regexp
|
allowList, denyList []*regexp2.Regexp
|
||||||
denyPrivateIPs bool
|
denyPrivateIPs bool
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Available tags:
|
|||||||
|
|
||||||
| Group | 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` |
|
||||||
|
|||||||
@@ -416,6 +416,87 @@ Feature: /forms/chromium/convert/html
|
|||||||
Then the Gotenberg container should log the following entries:
|
Then the Gotenberg container should log the following entries:
|
||||||
| 'file:///etc/passwd' matches the expression from the denied list |
|
| '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)
|
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):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* |
|
| CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* |
|
||||||
|
|||||||
@@ -499,6 +499,7 @@ Feature: /forms/chromium/convert/url
|
|||||||
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
|
||||||
|
|
||||||
|
@chromium-ssrf
|
||||||
Scenario: POST /forms/chromium/convert/url (Main URL is a non-public IP literal, deny-private-ips on)
|
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):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| CHROMIUM_ALLOW_LIST | |
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
@@ -512,6 +513,55 @@ Feature: /forms/chromium/convert/url
|
|||||||
Forbidden
|
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)
|
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):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| CHROMIUM_ALLOW_LIST | .+ |
|
| CHROMIUM_ALLOW_LIST | .+ |
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
@chromium-screenshot-html
|
@chromium-screenshot-html
|
||||||
Feature: /forms/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)
|
Scenario: POST /forms/chromium/screenshot/html (Default)
|
||||||
Given I have a default Gotenberg container
|
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):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||||
|
|||||||
@@ -181,6 +181,12 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
|
|||||||
}
|
}
|
||||||
return c.HTML(http.StatusOK, string(b))
|
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
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|||||||
15
test/integration/testdata/ssrf-eventsource-html/index.html
vendored
Normal file
15
test/integration/testdata/ssrf-eventsource-html/index.html
vendored
Normal 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>
|
||||||
15
test/integration/testdata/ssrf-fetch-html/index.html
vendored
Normal file
15
test/integration/testdata/ssrf-fetch-html/index.html
vendored
Normal 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>
|
||||||
20
test/integration/testdata/ssrf-websocket-html/index.html
vendored
Normal file
20
test/integration/testdata/ssrf-websocket-html/index.html
vendored
Normal 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>
|
||||||
15
test/integration/testdata/ssrf-websocket-tls-html/index.html
vendored
Normal file
15
test/integration/testdata/ssrf-websocket-tls-html/index.html
vendored
Normal 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>
|
||||||
22
test/integration/testdata/ssrf-websocket-worker-html/index.html
vendored
Normal file
22
test/integration/testdata/ssrf-websocket-worker-html/index.html
vendored
Normal 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>
|
||||||
Reference in New Issue
Block a user