Repository: gotenberg/gotenberg (go)

Issue:
Images Fail to Load on /convert/url Endpoint Due to Authentication Headers Rejected by CDNs
I noticed that when I send a request to the **_/convert/url_** endpoint with the following data:
```
{
  { “extraHeaders”: { “Authentication”: “Basic base64data” }
}
```
Some images, selected randomly each time, do not load. I believe this is because Gotenberg sends authentication headers for each frontend request, and some CDN servers reject these headers, especially the **_Authentication_** header. This seems to be the reason why some images are not loading.
I ran into a similar problem using the Puppeteer npm package, but **_Puppeteer_** offers an async method **_page.authenticate_** that handles authentication by acting like a native browser passing basic authentication. The authentication header is not sent along with the FE request to the third-party APIs (loading images for display).
Do you have any suggestions or solutions to help with this issue?

Pull request description:
feat(chromium): add scope to extraHttpHeaders
Closes #1011.

Patch:
diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go
index c7b105a..4e89520 100644
--- a/pkg/modules/chromium/browser.go
+++ b/pkg/modules/chromium/browser.go
@@ -228,7 +228,6 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
 		disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
 		setCookiesActionFunc(logger, options.Cookies),
 		userAgentOverride(logger, options.UserAgent),
-		extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
 		navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
 		hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
 		forceExactColorsActionFunc(),
@@ -252,7 +251,6 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
 		disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
 		setCookiesActionFunc(logger, options.Cookies),
 		userAgentOverride(logger, options.UserAgent),
-		extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
 		navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
 		hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
 		forceExactColorsActionFunc(),
@@ -291,8 +289,14 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
 	defer taskCancel()
 
 	// We validate all others requests against our allow / deny lists.
-	// If a request does not pass the validation, we make it fail.
-	listenForEventRequestPaused(taskCtx, logger, b.arguments.allowList, b.arguments.denyList)
+	// If a request does not pass the validation, we make it fail. It also set
+	// the extra HTTP headers, if any.
+	// See https://github.com/gotenberg/gotenberg/issues/1011.
+	listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
+		allowList:        b.arguments.allowList,
+		denyList:         b.arguments.denyList,
+		extraHttpHeaders: options.ExtraHttpHeaders,
+	})
 
 	var (
 		invalidHttpStatusCode   error
diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go
index 735b270..0a8a8a2 100644
--- a/pkg/modules/chromium/chromium.go
+++ b/pkg/modules/chromium/chromium.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/alexliesenfeld/health"
 	"github.com/chromedp/cdproto/network"
+	"github.com/dlclark/regexp2"
 	flag "github.com/spf13/pflag"
 	"go.uber.org/zap"
 
@@ -109,7 +110,7 @@ type Options struct {
 
 	// ExtraHttpHeaders are extra HTTP headers to send by Chromium while
 	// loading he HTML document.
-	ExtraHttpHeaders map[string]string
+	ExtraHttpHeaders []ExtraHttpHeader
 
 	// EmulatedMediaType is the media type to emulate, either "screen" or
 	// "print".
@@ -289,6 +290,22 @@ type Cookie struct {
 	SameSite network.CookieSameSite `json:"sameSite,omitempty"`
 }
 
+// ExtraHttpHeader are extra HTTP headers to send by Chromium.
+type ExtraHttpHeader struct {
+	// Name is the header name.
+	// Required.
+	Name string
+
+	// Value is the header value.
+	// Required.
+	Value string
+
+	// Scope is the header scope. If nil, the header will be applied to ALL
+	// requests from the page.
+	// Optional.
+	Scope *regexp2.Regexp
+}
+
 // Api helps to interact with Chromium for converting HTML documents to PDF.
 type Api interface {
 	Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
diff --git a/pkg/modules/chromium/events.go b/pkg/modules/chromium/events.go
index 8a8ca1d..85d7145 100644
--- a/pkg/modules/chromium/events.go
+++ b/pkg/modules/chromium/events.go
@@ -20,10 +20,22 @@ import (
 	"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
 )
 
+type eventRequestPausedOptions struct {
+	allowList, denyList *regexp2.Regexp
+	extraHttpHeaders    []ExtraHttpHeader
+}
+
 // listenForEventRequestPaused listens for requests to check if they are
-// allowed or not.network.SetBlockedURLS()
+// allowed or not.  It also set the extra HTTP headers, if any.
+// See https://github.com/gotenberg/gotenberg/issues/1011.
 // TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now).
-func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowList *regexp2.Regexp, denyList *regexp2.Regexp) {
+func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, options eventRequestPausedOptions) {
+	if len(options.extraHttpHeaders) == 0 {
+		logger.Debug("no extra HTTP headers")
+	} else {
+		logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
+	}
+
 	chromedp.ListenTarget(ctx, func(ev interface{}) {
 		switch e := ev.(type) {
 		case *fetch.EventRequestPaused:
@@ -37,7 +49,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
 					return
 				}
 
-				err := gotenberg.FilterDeadline(allowList, denyList, e.Request.URL, deadline)
+				err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
 				if err != nil {
 					logger.Warn(err.Error())
 					allow = false
@@ -46,19 +58,78 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
 				cctx := chromedp.FromContext(ctx)
 				executorCtx := cdp.WithExecutor(ctx, cctx.Target)
 
-				if allow {
-					req := fetch.ContinueRequest(e.RequestID)
+				if !allow {
+					req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
 					err = req.Do(executorCtx)
 					if err != nil {
-						logger.Error(fmt.Sprintf("continue request: %s", err))
+						logger.Error(fmt.Sprintf("fail request: %s", err))
 					}
 					return
 				}
 
-				req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
+				req := fetch.ContinueRequest(e.RequestID)
+
+				var extraHttpHeadersToSet []ExtraHttpHeader
+				if len(options.extraHttpHeaders) > 0 {
+					// The user want to set extra HTTP headers.
+
+					// First, we have to check if at least one header has to be
+					// set for current request.
+					for _, header := range options.extraHttpHeaders {
+						if header.Scope == nil {
+							// Non-scoped header.
+							logger.Debug(fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
+							extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
+							continue
+						}
+
+						ok, err := header.Scope.MatchString(e.Request.URL)
+						if err != nil {
+							logger.Error(fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
+						} else if ok {
+							logger.Debug(fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL))
+							extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
+						} else {
+							logger.Debug(fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL))
+						}
+					}
+				}
+
+				if len(extraHttpHeadersToSet) > 0 {
+					logger.Debug(fmt.Sprintf("setting extra HTTP headers for request URL '%s': %+v", e.Request.URL, extraHttpHeadersToSet))
+
+					originalHeaders := e.Request.Headers
+					headers := make(map[string]string)
+
+					for key, value := range originalHeaders {
+						strValue, ok := value.(string)
+						if ok {
+							headers[key] = strValue
+						} else {
+							logger.Error(fmt.Sprintf("ignoring header '%s' for URL '%s' since it cannot be cast to a string", key, e.Request.URL))
+						}
+					}
+
+					var headersEntries []*fetch.HeaderEntry
+					for key, value := range headers {
+						headersEntries = append(headersEntries, &fetch.HeaderEntry{
+							Name:  key,
+							Value: value,
+						})
+					}
+					for _, header := range extraHttpHeadersToSet {
+						headersEntries = append(headersEntries, &fetch.HeaderEntry{
+							Name:  header.Name,
+							Value: header.Value,
+						})
+					}
+
+					req.Headers = headersEntries
+				}
+
 				err = req.Do(executorCtx)
 				if err != nil {
-					logger.Error(fmt.Sprintf("fail request: %s", err))
+					logger.Error(fmt.Sprintf("continue request: %s", err))
 				}
 			}()
 		}
diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go
index ba1e7be..a725c7c 100644
--- a/pkg/modules/chromium/routes.go
+++ b/pkg/modules/chromium/routes.go
@@ -13,6 +13,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/dlclark/regexp2"
 	"github.com/labstack/echo/v4"
 	"github.com/microcosm-cc/bluemonday"
 	"github.com/russross/blackfriday/v2"
@@ -36,7 +37,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
 		waitForExpression       string
 		cookies                 []Cookie
 		userAgent               string
-		extraHttpHeaders        map[string]string
+		extraHttpHeaders        []ExtraHttpHeader
 		emulatedMediaType       string
 		omitBackground          bool
 	)
@@ -86,12 +87,59 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
 				return nil
 			}
 
-			err := json.Unmarshal([]byte(value), &extraHttpHeaders)
+			var headers map[string]string
+			err := json.Unmarshal([]byte(value), &headers)
 			if err != nil {
 				return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
 			}
 
-			return nil
+			for k, v := range headers {
+				var scope string
+				var valueTokens []string
+				var invalidScopeToken bool
+
+				tokens := strings.Split(v, ";")
+				for _, token := range tokens {
+					if strings.HasPrefix(strings.ToLower(strings.TrimSpace(token)), "scope") {
+						tokenNoSpaces := strings.Join(strings.Fields(token), "")
+						parts := strings.SplitN(tokenNoSpaces, "=", 2)
+
+						if len(parts) == 2 && strings.ToLower(parts[0]) == "scope" && parts[1] != "" {
+							scope = parts[1]
+						} else {
+							err = multierr.Append(err, fmt.Errorf("invalid scope '%s' for header '%s'", scope, k))
+							invalidScopeToken = true
+							break
+						}
+					} else {
+						if token != "" {
+							valueTokens = append(valueTokens, token)
+						}
+					}
+				}
+
+				if invalidScopeToken {
+					continue
+				}
+
+				var scopeRegexp *regexp2.Regexp
+				if len(scope) > 0 {
+					p, errCompile := regexp2.Compile(scope, 0)
+					if errCompile != nil {
+						err = multierr.Append(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
+						continue
+					}
+					scopeRegexp = p
+				}
+
+				extraHttpHeaders = append(extraHttpHeaders, ExtraHttpHeader{
+					Name:  k,
+					Value: strings.Join(valueTokens, "; "),
+					Scope: scopeRegexp,
+				})
+			}
+
+			return err
 		}).
 		Custom("emulatedMediaType", func(value string) error {
 			if value == "" {
diff --git a/pkg/modules/chromium/tasks.go b/pkg/modules/chromium/tasks.go
index ac5d6e1..4e841d7 100644
--- a/pkg/modules/chromium/tasks.go
+++ b/pkg/modules/chromium/tasks.go
@@ -298,28 +298,33 @@ func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc
 	}
 }
 
-func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
-	return func(ctx context.Context) error {
-		if len(extraHttpHeaders) == 0 {
-			logger.Debug("no extra HTTP headers")
-			return nil
-		}
-
-		logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
-
-		headers := make(network.Headers, len(extraHttpHeaders))
-		for key, value := range extraHttpHeaders {
-			headers[key] = value
-		}
-
-		err := network.SetExtraHTTPHeaders(headers).Do(ctx)
-		if err == nil {
-			return nil
-		}
-
-		return fmt.Errorf("set extra HTTP headers: %w", err)
-	}
-}
+// This code has been replaced with the listenForEventRequestPaused function.
+// Indeed, the user may want to scope the headers per domain, but using
+// network.SetExtraHTTPHeaders set the headers for ALL requests from the page.
+// See https://github.com/gotenberg/gotenberg/issues/1011.
+//
+//func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
+//	return func(ctx context.Context) error {
+//		if len(extraHttpHeaders) == 0 {
+//			logger.Debug("no extra HTTP headers")
+//			return nil
+//		}
+//
+//		logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
+//
+//		headers := make(network.Headers, len(extraHttpHeaders))
+//		for key, value := range extraHttpHeaders {
+//			headers[key] = value
+//		}
+//
+//		err := network.SetExtraHTTPHeaders(headers).Do(ctx)
+//		if err == nil {
+//			return nil
+//		}
+//
+//		return fmt.Errorf("set extra HTTP headers: %w", err)
+//	}
+//}
 
 func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent bool) chromedp.ActionFunc {
 	return func(ctx context.Context) error {


Test patch:
diff --git a/pkg/modules/chromium/browser_test.go b/pkg/modules/chromium/browser_test.go
index dfee69a..9cc4bcb 100644
--- a/pkg/modules/chromium/browser_test.go
+++ b/pkg/modules/chromium/browser_test.go
@@ -702,8 +702,21 @@ func TestChromiumBrowser_pdf(t *testing.T) {
 				return fs
 			}(),
 			options: PdfOptions{
-				Options: Options{ExtraHttpHeaders: map[string]string{
-					"X-Foo": "Bar",
+				Options: Options{ExtraHttpHeaders: []ExtraHttpHeader{
+					{
+						Name:  "X-Foo",
+						Value: "foo",
+					},
+					{
+						Name:  "X-Bar",
+						Value: "bar",
+						Scope: regexp2.MustCompile(`.*index\.html.*`, 0),
+					},
+					{
+						Name:  "X-Baz",
+						Value: "baz",
+						Scope: regexp2.MustCompile(`.*another\.html.*`, 0),
+					},
 				}},
 			},
 			noDeadline:  false,
@@ -711,6 +724,10 @@ func TestChromiumBrowser_pdf(t *testing.T) {
 			expectError: false,
 			expectedLogEntries: []string{
 				"extra HTTP headers:",
+				"extra HTTP header 'X-Foo' will be set for request URL",
+				"extra HTTP header 'X-Bar' (scoped) will be set for request URL",
+				"extra HTTP header 'X-Baz' (scoped) will not be set for request URL",
+				"setting extra HTTP headers for request URL",
 			},
 		},
 		{
@@ -1716,6 +1733,41 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
 				"set cookie",
 			},
 		},
+		{
+			scenario: "user agent override",
+			browser: newChromiumBrowser(
+				browserArguments{
+					binPath:          os.Getenv("CHROMIUM_BIN_PATH"),
+					wsUrlReadTimeout: 5 * time.Second,
+					allowList:        regexp2.MustCompile("", 0),
+					denyList:         regexp2.MustCompile("", 0),
+				},
+			),
+			fs: func() *gotenberg.FileSystem {
+				fs := gotenberg.NewFileSystem()
+
+				err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
+				if err != nil {
+					t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
+				}
+
+				err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>User-Agent override</h1>"), 0o755)
+				if err != nil {
+					t.Fatalf("expected no error but got: %v", err)
+				}
+
+				return fs
+			}(),
+			options: ScreenshotOptions{
+				Options: Options{UserAgent: "foo"},
+			},
+			noDeadline:  false,
+			start:       true,
+			expectError: false,
+			expectedLogEntries: []string{
+				fmt.Sprintf("user agent override: foo"),
+			},
+		},
 		{
 			scenario: "extra HTTP headers",
 			browser: newChromiumBrowser(
@@ -1742,8 +1794,21 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
 				return fs
 			}(),
 			options: ScreenshotOptions{
-				Options: Options{ExtraHttpHeaders: map[string]string{
-					"X-Foo": "Bar",
+				Options: Options{ExtraHttpHeaders: []ExtraHttpHeader{
+					{
+						Name:  "X-Foo",
+						Value: "foo",
+					},
+					{
+						Name:  "X-Bar",
+						Value: "bar",
+						Scope: regexp2.MustCompile(`.*index\.html.*`, 0),
+					},
+					{
+						Name:  "X-Baz",
+						Value: "baz",
+						Scope: regexp2.MustCompile(`.*another\.html.*`, 0),
+					},
 				}},
 			},
 			noDeadline:  false,
@@ -1751,6 +1816,10 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
 			expectError: false,
 			expectedLogEntries: []string{
 				"extra HTTP headers:",
+				"extra HTTP header 'X-Foo' will be set for request URL",
+				"extra HTTP header 'X-Bar' (scoped) will be set for request URL",
+				"extra HTTP header 'X-Baz' (scoped) will not be set for request URL",
+				"setting extra HTTP headers for request URL",
 			},
 		},
 		{
diff --git a/pkg/modules/chromium/routes_test.go b/pkg/modules/chromium/routes_test.go
index ba957e8..29fb9d1 100644
--- a/pkg/modules/chromium/routes_test.go
+++ b/pkg/modules/chromium/routes_test.go
@@ -7,8 +7,10 @@ import (
 	"net/http"
 	"os"
 	"reflect"
+	"sort"
 	"testing"
 
+	"github.com/dlclark/regexp2"
 	"github.com/google/uuid"
 	"github.com/labstack/echo/v4"
 	"go.uber.org/zap"
@@ -19,14 +21,18 @@ import (
 
 func TestFormDataChromiumOptions(t *testing.T) {
 	for _, tc := range []struct {
-		scenario        string
-		ctx             *api.ContextMock
-		expectedOptions Options
+		scenario                string
+		ctx                     *api.ContextMock
+		expectedOptions         Options
+		compareWithoutDeepEqual bool
+		expectValidationError   bool
 	}{
 		{
-			scenario:        "no custom form fields",
-			ctx:             &api.ContextMock{Context: new(api.Context)},
-			expectedOptions: DefaultOptions(),
+			scenario:                "no custom form fields",
+			ctx:                     &api.ContextMock{Context: new(api.Context)},
+			expectedOptions:         DefaultOptions(),
+			compareWithoutDeepEqual: false,
+			expectValidationError:   false,
 		},
 		{
 			scenario: "invalid failOnHttpStatusCodes form field",
@@ -44,6 +50,8 @@ func TestFormDataChromiumOptions(t *testing.T) {
 				options.FailOnHttpStatusCodes = nil
 				return options
 			}(),
+			compareWithoutDeepEqual: false,
+			expectValidationError:   true,
 		},
 		{
 			scenario: "valid failOnHttpStatusCodes form field",
@@ -61,6 +69,8 @@ func TestFormDataChromiumOptions(t *testing.T) {
 				options.FailOnHttpStatusCodes = []int64{399, 499, 599}
 				return options
 			}(),
+			compareWithoutDeepEqual: false,
+			expectValidationError:   false,
 		},
 		{
 			scenario: "invalid cookies form field",
@@ -73,7 +83,9 @@ func TestFormDataChromiumOptions(t *testing.T) {
 				})
 				return ctx
 			}(),
-			expectedOptions: DefaultOptions(),
+			expectedOptions:         DefaultOptions(),
+			compareWithoutDeepEqual: false,
+			expectValidationError:   true,
 		},
 		{
 			scenario: "invalid cookies form field (missing required values)",
@@ -93,6 +105,8 @@ func TestFormDataChromiumOptions(t *testing.T) {
 				options.Cookies = []Cookie{{}}
 				return options
 			}(),
+			compareWithoutDeepEqual: false,
+			expectValidationError:   true,
 		},
 		{
 			scenario: "valid cookies form field",
@@ -114,9 +128,11 @@ func TestFormDataChromiumOptions(t *testing.T) {
 				}}
 				return options
 			}(),
+			compareWithoutDeepEqual: false,
+			expectValidationError:   false,
 		},
 		{
-			scenario: "invalid extraHttpHeaders form field",
+			scenario: "invalid extraHttpHeaders form field: cannot unmarshall",
 			ctx: func() *api.ContextMock {
 				ctx := &api.ContextMock{Context: new(api.Context)}
 				ctx.SetValues(map[string][]string{
@@ -126,7 +142,39 @@ func TestFormDataChromiumOptions(t *testing.T) {
 				})
 				return ctx
 			}(),
-			expectedOptions: DefaultOptions(),
+			expectedOptions:         DefaultOptions(),
+			compareWithoutDeepEqual: false,
+			expectValidationError:   true,
+		},
+		{
+			scenario: "invalid extraHttpHeaders form field: invalid scope",
+			ctx: func() *api.ContextMock {
+				ctx := &api.ContextMock{Context: new(api.Context)}
+				ctx.SetValues(map[string][]string{
+					"extraHttpHeaders": {
+						`{"foo":"bar;scope;;"}`,
+					},
+				})
+				return ctx
+			}(),
+			expectedOptions:         DefaultOptions(),
+			compareWithoutDeepEqual: false,
+			expectValidationError:   true,
+		},
+		{
+			scenario: "invalid extraHttpHeaders form field: invalid scope regex pattern",
+			ctx: func() *api.ContextMock {
+				ctx := &api.ContextMock{Context: new(api.Context)}
+				ctx.SetValues(map[string][]string{
+					"extraHttpHeaders": {
+						`{"foo":"bar;scope=*."}`,
+					},
+				})
+				return ctx
+			}(),
+			expectedOptions:         DefaultOptions(),
+			compareWithoutDeepEqual: false,
+			expectValidationError:   true,
 		},
 		{
 			scenario: "valid extraHttpHeaders form field",
@@ -134,18 +182,28 @@ func TestFormDataChromiumOptions(t *testing.T) {
 				ctx := &api.ContextMock{Context: new(api.Context)}
 				ctx.SetValues(map[string][]string{
 					"extraHttpHeaders": {
-						`{"foo":"bar"}`,
+						`{"foo":"bar","baz":"qux;scope=https?:\\/\\/([a-zA-Z0-9-]+\\.)*qux\\.com\\/.*"}`,
 					},
 				})
 				return ctx
 			}(),
 			expectedOptions: func() Options {
 				options := DefaultOptions()
-				options.ExtraHttpHeaders = map[string]string{
-					"foo": "bar",
+				options.ExtraHttpHeaders = []ExtraHttpHeader{
+					{
+						Name:  "foo",
+						Value: "bar",
+					},
+					{
+						Name:  "baz",
+						Value: "qux",
+						Scope: regexp2.MustCompile(`https?:\/\/([a-zA-Z0-9-]+\.)*qux\.com\/.*`, 0),
+					},
 				}
 				return options
 			}(),
+			compareWithoutDeepEqual: true,
+			expectValidationError:   false,
 		},
 		{
 			scenario: "invalid emulatedMediaType form field",
@@ -158,7 +216,8 @@ func TestFormDataChromiumOptions(t *testing.T) {
 				})
 				return ctx
 			}(),
-			expectedOptions: DefaultOptions(),
+			expectedOptions:       DefaultOptions(),
+			expectValidationError: true,
 		},
 		{
 			scenario: "valid emulatedMediaType form field",
@@ -176,14 +235,61 @@ func TestFormDataChromiumOptions(t *testing.T) {
 				options.EmulatedMediaType = "screen"
 				return options
 			}(),
+			expectValidationError: false,
 		},
 	} {
 		t.Run(tc.scenario, func(t *testing.T) {
 			tc.ctx.SetLogger(zap.NewNop())
-			_, actual := FormDataChromiumOptions(tc.ctx.Context)
+			form, actual := FormDataChromiumOptions(tc.ctx.Context)
 
-			if !reflect.DeepEqual(actual, tc.expectedOptions) {
-				t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual)
+			if tc.compareWithoutDeepEqual {
+				if len(tc.expectedOptions.ExtraHttpHeaders) != len(actual.ExtraHttpHeaders) {
+					t.Fatalf("expected %d extra HTTP headers, but got %d", len(tc.expectedOptions.ExtraHttpHeaders), len(actual.ExtraHttpHeaders))
+				}
+
+				sort.Slice(tc.expectedOptions.ExtraHttpHeaders, func(i, j int) bool {
+					return tc.expectedOptions.ExtraHttpHeaders[i].Name < tc.expectedOptions.ExtraHttpHeaders[j].Name
+				})
+				sort.Slice(actual.ExtraHttpHeaders, func(i, j int) bool {
+					return actual.ExtraHttpHeaders[i].Name < actual.ExtraHttpHeaders[j].Name
+				})
+
+				for i := range tc.expectedOptions.ExtraHttpHeaders {
+					if tc.expectedOptions.ExtraHttpHeaders[i].Name != actual.ExtraHttpHeaders[i].Name {
+						t.Fatalf("expected '%s' extra HTTP header, but got '%s'", tc.expectedOptions.ExtraHttpHeaders[i].Name, tc.expectedOptions.ExtraHttpHeaders[i].Name)
+					}
+
+					if tc.expectedOptions.ExtraHttpHeaders[i].Value != actual.ExtraHttpHeaders[i].Value {
+						t.Fatalf("expected '%s' as value for extra HTTP header '%s', but got '%s'", tc.expectedOptions.ExtraHttpHeaders[i].Value, tc.expectedOptions.ExtraHttpHeaders[i].Name, actual.ExtraHttpHeaders[i].Value)
+					}
+
+					var expectedScope string
+					if tc.expectedOptions.ExtraHttpHeaders[i].Scope != nil {
+						expectedScope = tc.expectedOptions.ExtraHttpHeaders[i].Scope.String()
+					}
+					var actualScope string
+					if actual.ExtraHttpHeaders[i].Scope != nil {
+						actualScope = actual.ExtraHttpHeaders[i].Scope.String()
+					}
+
+					if expectedScope != actualScope {
+						t.Fatalf("expected '%s' as scope for extra HTTP header '%s', but got '%s'", expectedScope, tc.expectedOptions.ExtraHttpHeaders[i].Name, actualScope)
+					}
+				}
+			} else {
+				if !reflect.DeepEqual(actual, tc.expectedOptions) {
+					t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual)
+				}
+			}
+
+			err := form.Validate()
+
+			if tc.expectValidationError && err == nil {
+				t.Fatal("expected validation error but got none", err)
+			}
+
+			if !tc.expectValidationError && err != nil {
+				t.Fatalf("expected no validation error but got: %v", err)
 			}
 		})
 	}
@@ -191,14 +297,16 @@ func TestFormDataChromiumOptions(t *testing.T) {
 
 func TestFormDataChromiumPdfOptions(t *testing.T) {
 	for _, tc := range []struct {
-		scenario        string
-		ctx             *api.ContextMock
-		expectedOptions PdfOptions
+		scenario              string
+		ctx                   *api.ContextMock
+		expectedOptions       PdfOptions
+		expectValidationError bool
 	}{
 		{
-			scenario:        "no custom form fields",
-			ctx:             &api.ContextMock{Context: new(api.Context)},
-			expectedOptions: DefaultPdfOptions(),
+			scenario:              "no custom form fields",
+			ctx:                   &api.ContextMock{Context: new(api.Context)},
+			expectedOptions:       DefaultPdfOptions(),
+			expectValidationError: false,
 		},
 		{
 			scenario: "custom form fields (Options & PdfOptions)",
@@ -220,29 +328,42 @@ func TestFormDataChromiumPdfOptions(t *testing.T) {
 				options.EmulatedMediaType = "screen"
 				return options
 			}(),
+			expectValidationError: false,
 		},
 	} {
 		t.Run(tc.scenario, func(t *testing.T) {
 			tc.ctx.SetLogger(zap.NewNop())
-			_, actual := FormDataChromiumPdfOptions(tc.ctx.Context)
+			form, actual := FormDataChromiumPdfOptions(tc.ctx.Context)
 
 			if !reflect.DeepEqual(actual, tc.expectedOptions) {
 				t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual)
 			}
+
+			err := form.Validate()
+
+			if tc.expectValidationError && err == nil {
+				t.Fatal("expected validation error but got none", err)
+			}
+
+			if !tc.expectValidationError && err != nil {
+				t.Fatalf("expected no validation error but got: %v", err)
+			}
 		})
 	}
 }
 
 func TestFormDataChromiumScreenshotOptions(t *testing.T) {
 	for _, tc := range []struct {
-		scenario        string
-		ctx             *api.ContextMock
-		expectedOptions ScreenshotOptions
+		scenario              string
+		ctx                   *api.ContextMock
+		expectedOptions       ScreenshotOptions
+		expectValidationError bool
 	}{
 		{
-			scenario:        "no custom form fields",
-			ctx:             &api.ContextMock{Context: new(api.Context)},
-			expectedOptions: DefaultScreenshotOptions(),
+			scenario:              "no custom form fields",
+			ctx:                   &api.ContextMock{Context: new(api.Context)},
+			expectedOptions:       DefaultScreenshotOptions(),
+			expectValidationError: false,
 		},
 		{
 			scenario: "invalid format form field",
@@ -260,6 +381,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
 				options.Format = ""
 				return options
 			}(),
+			expectValidationError: true,
 		},
 		{
 			scenario: "valid png format form field",
@@ -277,6 +399,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
 				options.Format = "png"
 				return options
 			}(),
+			expectValidationError: false,
 		},
 		{
 			scenario: "valid jpeg format form field",
@@ -294,6 +417,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
 				options.Format = "jpeg"
 				return options
 			}(),
+			expectValidationError: false,
 		},
 		{
 			scenario: "valid webp format form field",
@@ -311,6 +435,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
 				options.Format = "webp"
 				return options
 			}(),
+			expectValidationError: false,
 		},
 		{
 			scenario: "invalid quality form field (not an integer)",
@@ -328,6 +453,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
 				options.Quality = 0
 				return options
 			}(),
+			expectValidationError: true,
 		},
 		{
 			scenario: "invalid quality form field (< 0)",
@@ -345,6 +471,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
 				options.Quality = 0
 				return options
 			}(),
+			expectValidationError: true,
 		},
 		{
 			scenario: "invalid quality form field (> 100)",
@@ -362,6 +489,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
 				options.Quality = 0
 				return options
 			}(),
+			expectValidationError: true,
 		},
 		{
 			scenario: "valid quality form field",
@@ -379,6 +507,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
 				options.Quality = 50
 				return options
 			}(),
+			expectValidationError: false,
 		},
 		{
 			scenario: "custom form fields (Options & ScreenshotOptions)",
@@ -412,29 +541,42 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
 				options.EmulatedMediaType = "screen"
 				return options
 			}(),
+			expectValidationError: false,
 		},
 	} {
 		t.Run(tc.scenario, func(t *testing.T) {
 			tc.ctx.SetLogger(zap.NewNop())
-			_, actual := FormDataChromiumScreenshotOptions(tc.ctx.Context)
+			form, actual := FormDataChromiumScreenshotOptions(tc.ctx.Context)
 
 			if !reflect.DeepEqual(actual, tc.expectedOptions) {
 				t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual)
 			}
+
+			err := form.Validate()
+
+			if tc.expectValidationError && err == nil {
+				t.Fatal("expected validation error but got none", err)
+			}
+
+			if !tc.expectValidationError && err != nil {
+				t.Fatalf("expected no validation error but got: %v", err)
+			}
 		})
 	}
 }
 
 func TestFormDataChromiumPdfFormats(t *testing.T) {
 	for _, tc := range []struct {
-		scenario           string
-		ctx                *api.ContextMock
-		expectedPdfFormats gotenberg.PdfFormats
+		scenario              string
+		ctx                   *api.ContextMock
+		expectedPdfFormats    gotenberg.PdfFormats
+		expectValidationError bool
 	}{
 		{
-			scenario:           "no custom form fields",
-			ctx:                &api.ContextMock{Context: new(api.Context)},
-			expectedPdfFormats: gotenberg.PdfFormats{},
+			scenario:              "no custom form fields",
+			ctx:                   &api.ContextMock{Context: new(api.Context)},
+			expectedPdfFormats:    gotenberg.PdfFormats{},
+			expectValidationError: false,
 		},
 		{
 			scenario: "pdfa and pdfua form fields",
@@ -450,30 +592,44 @@ func TestFormDataChromiumPdfFormats(t *testing.T) {
 				})
 				return ctx
 			}(),
-			expectedPdfFormats: gotenberg.PdfFormats{PdfA: "foo", PdfUa: true},
+			expectedPdfFormats:    gotenberg.PdfFormats{PdfA: "foo", PdfUa: true},
+			expectValidationError: false,
 		},
 	} {
 		t.Run(tc.scenario, func(t *testing.T) {
 			tc.ctx.SetLogger(zap.NewNop())
-			actual := FormDataChromiumPdfFormats(tc.ctx.Context.FormData())
+			form := tc.ctx.Context.FormData()
+			actual := FormDataChromiumPdfFormats(form)
 
 			if !reflect.DeepEqual(actual, tc.expectedPdfFormats) {
 				t.Fatalf("expected %+v but got: %+v", tc.expectedPdfFormats, actual)
 			}
+
+			err := form.Validate()
+
+			if tc.expectValidationError && err == nil {
+				t.Fatal("expected validation error but got none", err)
+			}
+
+			if !tc.expectValidationError && err != nil {
+				t.Fatalf("expected no validation error but got: %v", err)
+			}
 		})
 	}
 }
 
 func TestFormDataPdfMetadata(t *testing.T) {
 	for _, tc := range []struct {
-		scenario         string
-		ctx              *api.ContextMock
-		expectedMetadata map[string]interface{}
+		scenario              string
+		ctx                   *api.ContextMock
+		expectedMetadata      map[string]interface{}
+		expectValidationError bool
 	}{
 		{
-			scenario:         "no metadata form field",
-			ctx:              &api.ContextMock{Context: new(api.Context)},
-			expectedMetadata: nil,
+			scenario:              "no metadata form field",
+			ctx:                   &api.ContextMock{Context: new(api.Context)},
+			expectedMetadata:      nil,
+			expectValidationError: false,
 		},
 		{
 			scenario: "invalid metadata form field",
@@ -486,7 +642,8 @@ func TestFormDataPdfMetadata(t *testing.T) {
 				})
 				return ctx
 			}(),
-			expectedMetadata: nil,
+			expectedMetadata:      nil,
+			expectValidationError: true,
 		},
 		{
 			scenario: "valid metadata form field",
@@ -502,15 +659,27 @@ func TestFormDataPdfMetadata(t *testing.T) {
 			expectedMetadata: map[string]interface{}{
 				"foo": "bar",
 			},
+			expectValidationError: false,
 		},
 	} {
 		t.Run(tc.scenario, func(t *testing.T) {
 			tc.ctx.SetLogger(zap.NewNop())
-			actual := FormDataPdfMetadata(tc.ctx.Context.FormData())
+			form := tc.ctx.Context.FormData()
+			actual := FormDataPdfMetadata(form)
 
 			if !reflect.DeepEqual(actual, tc.expectedMetadata) {
 				t.Fatalf("expected %+v but got: %+v", tc.expectedMetadata, actual)
 			}
+
+			err := form.Validate()
+
+			if tc.expectValidationError && err == nil {
+				t.Fatal("expected validation error but got none", err)
+			}
+
+			if !tc.expectValidationError && err != nil {
+				t.Fatalf("expected no validation error but got: %v", err)
+			}
 		})
 	}
 }