diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go index 8f4a5f64..1191c9c4 100644 --- a/pkg/modules/chromium/browser.go +++ b/pkg/modules/chromium/browser.go @@ -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, }) diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index 2b32174d..6412923e 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -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, } } diff --git a/pkg/modules/chromium/events.go b/pkg/modules/chromium/events.go index f249b2bd..1d77f5c6 100644 --- a/pkg/modules/chromium/events.go +++ b/pkg/modules/chromium/events.go @@ -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 diff --git a/pkg/modules/chromium/events_test.go b/pkg/modules/chromium/events_test.go new file mode 100644 index 00000000..d01bcbe7 --- /dev/null +++ b/pkg/modules/chromium/events_test.go @@ -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)") + } +} diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go index 8af92307..b503cadd 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -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 diff --git a/test/integration/features/chromium_convert_html.feature b/test/integration/features/chromium_convert_html.feature index d9306fd7..19d7550c 100644 --- a/test/integration/features/chromium_convert_html.feature +++ b/test/integration/features/chromium_convert_html.feature @@ -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):