feat(chromium): add IgnoreResourceHttpStatusDomains option to filter out resources based on their hostnames (#1434)

* Introduced `IgnoreResourceHttpStatusDomains` option to filter out resources based on their hostnames

- Introduced `IgnoreResourceHttpStatusDomains` option to filter out resources based on their hostnames during HTTP status code checks.
- Updated relevant functions to handle domain normalization and matching.
- Enhanced the form data handling to include the new option.
- Added integration test scenario to verify the functionality of ignoring specified domains.

* Updated the `normalizeDomains` function to initialize the `normalized` slice with a predefined capacity based on the input `domains` slice length, improving memory allocation efficiency.
This commit is contained in:
markfrost
2025-12-25 15:36:45 +01:00
committed by GitHub
parent 77b9776d9c
commit 0b211c39eb
6 changed files with 239 additions and 41 deletions

View File

@@ -376,6 +376,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
invalidHttpStatusCode: &invalidHttpStatusCode,
invalidHttpStatusCodeMu: &invalidHttpStatusCodeMu,
failOnResourceOnHttpStatusCode: options.FailOnResourceHttpStatusCodes,
ignoreResourceHttpStatusDomains: options.IgnoreResourceHttpStatusDomains,
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
})

View File

@@ -108,6 +108,20 @@ type Options struct {
// status code from at least one resource matches with one if its entries.
FailOnResourceHttpStatusCodes []int64
// IgnoreResourceHttpStatusDomains excludes resources whose hostname matches
// one of these domains from the application of
// [Options.FailOnResourceHttpStatusCodes].
//
// A match happens if the hostname equals the domain or is a subdomain of it
// (e.g., "browser.sentry-cdn.com" matches "sentry-cdn.com").
//
// Values are normalized (trimmed, lowercased) and may be provided as:
// - "example.com"
// - "*.example.com" or ".example.com"
// - "example.com:443" (port is ignored)
// - "https://example.com/path" (scheme/path are ignored)
IgnoreResourceHttpStatusDomains []string
// FailOnResourceLoadingFailed sets if the conversion should fail like the
// main page if Chromium fails to load at least one resource.
FailOnResourceLoadingFailed bool
@@ -150,19 +164,20 @@ type Options struct {
// DefaultOptions returns the default values for Options.
func DefaultOptions() Options {
return Options{
SkipNetworkIdleEvent: true,
FailOnHttpStatusCodes: []int64{499, 599},
FailOnResourceHttpStatusCodes: nil,
FailOnResourceLoadingFailed: false,
FailOnConsoleExceptions: false,
WaitDelay: 0,
WaitWindowStatus: "",
WaitForExpression: "",
Cookies: nil,
UserAgent: "",
ExtraHttpHeaders: nil,
EmulatedMediaType: "",
OmitBackground: false,
SkipNetworkIdleEvent: true,
FailOnHttpStatusCodes: []int64{499, 599},
FailOnResourceHttpStatusCodes: nil,
IgnoreResourceHttpStatusDomains: nil,
FailOnResourceLoadingFailed: false,
FailOnConsoleExceptions: false,
WaitDelay: 0,
WaitWindowStatus: "",
WaitForExpression: "",
Cookies: nil,
UserAgent: "",
ExtraHttpHeaders: nil,
EmulatedMediaType: "",
OmitBackground: false,
}
}

View File

@@ -4,7 +4,9 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"github.com/chromedp/cdproto/cdp"
@@ -143,6 +145,7 @@ type eventResponseReceivedOptions struct {
invalidHttpStatusCode *error
invalidHttpStatusCodeMu *sync.RWMutex
failOnResourceOnHttpStatusCode []int64
ignoreResourceHttpStatusDomains []string
invalidResourceHttpStatusCode *error
invalidResourceHttpStatusCodeMu *sync.RWMutex
}
@@ -157,6 +160,8 @@ func listenForEventResponseReceived(
logger *zap.Logger,
options eventResponseReceivedOptions,
) {
normalizedIgnoreDomains := normalizeDomains(options.ignoreResourceHttpStatusDomains)
for _, code := range []int64{199, 299, 399, 499, 599} {
if slices.Contains(options.failOnHttpStatusCodes, code) {
for i := code - 99; i <= code; i++ {
@@ -190,6 +195,11 @@ func listenForEventResponseReceived(
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
if !shouldCheckResourceHttpStatusCode(ev.Response.URL, normalizedIgnoreDomains) {
logger.Debug(fmt.Sprintf("skip resource HTTP status code check for '%s' due to domain filtering", ev.Response.URL))
return
}
options.invalidResourceHttpStatusCodeMu.Lock()
defer options.invalidResourceHttpStatusCodeMu.Unlock()
@@ -202,6 +212,79 @@ func listenForEventResponseReceived(
})
}
func shouldCheckResourceHttpStatusCode(rawURL string, ignoreDomains []string) bool {
host := hostnameFromURL(rawURL)
if len(ignoreDomains) > 0 && matchesAnyDomain(host, ignoreDomains) {
return false
}
return true
}
func hostnameFromURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
return strings.ToLower(u.Hostname())
}
func normalizeDomains(domains []string) []string {
normalized := make([]string, 0, len(domains))
for _, domain := range domains {
d := normalizeDomain(domain)
if d == "" {
continue
}
normalized = append(normalized, d)
}
return normalized
}
func normalizeDomain(domain string) string {
d := strings.ToLower(strings.TrimSpace(domain))
if d == "" {
return ""
}
// Accept "example.com", "*.example.com", ".example.com", "https://example.com/path",
// or "example.com:443".
if strings.Contains(d, "://") || strings.HasPrefix(d, "//") {
u, err := url.Parse(d)
if err == nil && u.Hostname() != "" {
d = strings.ToLower(u.Hostname())
}
} else {
// Make it parseable as a URL to extract the hostname and drop any port/path.
u, err := url.Parse("https://" + d)
if err == nil && u.Hostname() != "" {
d = strings.ToLower(u.Hostname())
}
}
d = strings.TrimPrefix(d, "*.")
d = strings.TrimPrefix(d, ".")
return d
}
func matchesAnyDomain(host string, domains []string) bool {
if host == "" || len(domains) == 0 {
return false
}
for _, domain := range domains {
if host == domain || strings.HasSuffix(host, "."+domain) {
return true
}
}
return false
}
type eventLoadingFailedOptions struct {
loadingFailed *error
loadingFailedMu *sync.RWMutex

View File

@@ -0,0 +1,63 @@
package chromium
import "testing"
func TestNormalizeDomain(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "empty", in: "", want: ""},
{name: "spaces", in: " ", want: ""},
{name: "simple", in: "example.com", want: "example.com"},
{name: "mixed case", in: "ExAmPlE.Com", want: "example.com"},
{name: "leading wildcard", in: "*.example.com", want: "example.com"},
{name: "leading dot", in: ".example.com", want: "example.com"},
{name: "with scheme and path", in: "https://example.com/foo/bar", want: "example.com"},
{name: "with port", in: "example.com:443", want: "example.com"},
{name: "with scheme and port", in: "https://example.com:443/foo", want: "example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeDomain(tt.in); got != tt.want {
t.Fatalf("normalizeDomain(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestMatchesAnyDomain(t *testing.T) {
host := "browser.sentry-cdn.com"
if !matchesAnyDomain(host, []string{"sentry-cdn.com"}) {
t.Fatalf("expected %q to match %q", host, "sentry-cdn.com")
}
if matchesAnyDomain("not-sentry-cdn.com", []string{"sentry-cdn.com"}) {
t.Fatalf("expected %q to not match %q", "not-sentry-cdn.com", "sentry-cdn.com")
}
}
func TestShouldCheckResourceHttpStatusCode_IgnoreDomains(t *testing.T) {
ignore := normalizeDomains([]string{"sentry.io"})
if shouldCheckResourceHttpStatusCode("https://sentry.io/api/123", ignore) {
t.Fatalf("expected ignored domain to be skipped")
}
if shouldCheckResourceHttpStatusCode("https://sub.sentry.io/api/123", ignore) {
t.Fatalf("expected ignored subdomain to be skipped")
}
if !shouldCheckResourceHttpStatusCode("https://other.com/api/123", ignore) {
t.Fatalf("expected non-ignored domain to be checked")
}
}
func TestShouldCheckResourceHttpStatusCode_NonHTTPURL(t *testing.T) {
if !shouldCheckResourceHttpStatusCode("data:text/plain,hello", nil) {
t.Fatalf("expected data: URL to be checked (no host filtering possible)")
}
}

View File

@@ -29,25 +29,37 @@ var sameSiteRegexp = regexp2.MustCompile(
regexp2.None,
)
// FormDataChromiumOptions creates [Options] from the form data. Fallback to
// the default value if the considered key is not present.
// FormDataChromiumOptions creates [Options] from the form data.
//
// It falls back to the default value if the considered key is not present.
//
// JSON-encoded fields:
// - failOnHttpStatusCodes: []int
// - failOnResourceHttpStatusCodes: []int
// - ignoreResourceHttpStatusDomains: []string
// - cookies: []Cookie
// - extraHttpHeaders: map[string]string
//
// Domain filtering only applies to resource checks triggered by
// "failOnResourceHttpStatusCodes".
func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
defaultOptions := DefaultOptions()
var (
skipNetworkIdleEvent bool
failOnHttpStatusCodes []int64
failOnResourceHttpStatusCodes []int64
failOnResourceLoadingFailed bool
failOnConsoleExceptions bool
waitDelay time.Duration
waitWindowStatus string
waitForExpression string
cookies []Cookie
userAgent string
extraHttpHeaders []ExtraHttpHeader
emulatedMediaType string
omitBackground bool
skipNetworkIdleEvent bool
failOnHttpStatusCodes []int64
failOnResourceHttpStatusCodes []int64
ignoreResourceHttpStatusDomains []string
failOnResourceLoadingFailed bool
failOnConsoleExceptions bool
waitDelay time.Duration
waitWindowStatus string
waitForExpression string
cookies []Cookie
userAgent string
extraHttpHeaders []ExtraHttpHeader
emulatedMediaType string
omitBackground bool
)
form := ctx.FormData().
@@ -78,6 +90,19 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
return nil
}).
Custom("ignoreResourceHttpStatusDomains", func(value string) error {
if value == "" {
ignoreResourceHttpStatusDomains = defaultOptions.IgnoreResourceHttpStatusDomains
return nil
}
err := json.Unmarshal([]byte(value), &ignoreResourceHttpStatusDomains)
if err != nil {
return fmt.Errorf("unmarshal ignoreResourceHttpStatusDomains: %w", err)
}
return nil
}).
Bool("failOnResourceLoadingFailed", &failOnResourceLoadingFailed, defaultOptions.FailOnResourceLoadingFailed).
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
@@ -203,19 +228,20 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
options := Options{
SkipNetworkIdleEvent: skipNetworkIdleEvent,
FailOnHttpStatusCodes: failOnHttpStatusCodes,
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
FailOnResourceLoadingFailed: failOnResourceLoadingFailed,
FailOnConsoleExceptions: failOnConsoleExceptions,
WaitDelay: waitDelay,
WaitWindowStatus: waitWindowStatus,
WaitForExpression: waitForExpression,
Cookies: cookies,
UserAgent: userAgent,
ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType,
OmitBackground: omitBackground,
SkipNetworkIdleEvent: skipNetworkIdleEvent,
FailOnHttpStatusCodes: failOnHttpStatusCodes,
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
IgnoreResourceHttpStatusDomains: ignoreResourceHttpStatusDomains,
FailOnResourceLoadingFailed: failOnResourceLoadingFailed,
FailOnConsoleExceptions: failOnConsoleExceptions,
WaitDelay: waitDelay,
WaitWindowStatus: waitWindowStatus,
WaitForExpression: waitForExpression,
Cookies: cookies,
UserAgent: userAgent,
ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType,
OmitBackground: omitBackground,
}
return form, options

View File

@@ -343,6 +343,16 @@ Feature: /forms/chromium/convert/html
https://gethttpstatus.com/400 - 400: Bad Request
"""
Scenario: POST /forms/chromium/convert/html (Fail On Resource HTTP Status Codes - Ignore Domains)
Given I have a default Gotenberg container
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/feature-rich-html/index.html | file |
| failOnResourceHttpStatusCodes | [499,599] | field |
| ignoreResourceHttpStatusDomains | ["gethttpstatus.com"] | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Scenario: POST /forms/chromium/convert/html (Fail On Resource Loading Failed)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):