mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-14 19:32:15 +01:00
fix(chromium): filter WebSocket handshakes against the outbound policy
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user