High severity7.5NVD Advisory· Published Mar 30, 2026· Updated Apr 29, 2026
CVE-2026-27018
CVE-2026-27018
Description
Gotenberg is an API for converting document formats. Prior to version 8.29.0, the fix introduced for CVE-2024-21527 can be bypassed using mixed-case or uppercase URL schemes. This issue has been patched in version 8.29.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/gotenberg/gotenberg/v8Go | < 8.29.0 | 8.29.0 |
github.com/gotenberg/gotenberg/v7Go | <= 7.10.2 | — |
Affected products
1Patches
28625a4e899ebfix(webhook/downloadFrom): better default security and DX for allow / deny lists
7 files changed · +37 −105
pkg/gotenberg/filter.go+0 −11 modified@@ -12,17 +12,6 @@ import ( // ErrFiltered happens if a value is filtered by the [FilterDeadline] function. var ErrFiltered = errors.New("value filtered") -// RegexpToSlice wraps a single [regexp2.Regexp] into a slice suitable for -// [FilterDeadline]. If the regexp pattern is empty, it returns nil (meaning no -// filtering). -func RegexpToSlice(r *regexp2.Regexp) []*regexp2.Regexp { - if r == nil || r.String() == "" { - return nil - } - - return []*regexp2.Regexp{r} -} - // FilterDeadline checks if the given value is allowed and not denied according // to regex patterns. The allowed list uses OR semantics (value must match at // least one pattern). The denied list uses OR semantics (value is denied if it
pkg/gotenberg/filter_test.go+0 −42 modified@@ -115,45 +115,3 @@ func TestFilterDeadline(t *testing.T) { }) } } - -func TestRegexpToSlice(t *testing.T) { - for _, tc := range []struct { - scenario string - input *regexp2.Regexp - expectNil bool - expectLen int - }{ - { - scenario: "nil regexp", - input: nil, - expectNil: true, - }, - { - scenario: "empty regexp", - input: regexp2.MustCompile("", 0), - expectNil: true, - }, - { - scenario: "non-empty regexp", - input: regexp2.MustCompile("^file:.*", 0), - expectNil: false, - expectLen: 1, - }, - } { - t.Run(tc.scenario, func(t *testing.T) { - result := RegexpToSlice(tc.input) - - if tc.expectNil && result != nil { - t.Fatalf("expected nil but got: %v", result) - } - - if !tc.expectNil && result == nil { - t.Fatal("expected non-nil but got nil") - } - - if !tc.expectNil && len(result) != tc.expectLen { - t.Fatalf("expected length %d but got %d", tc.expectLen, len(result)) - } - }) - } -}
pkg/modules/api/api.go+6 −6 modified@@ -55,8 +55,8 @@ type Api struct { } type downloadFromConfig struct { - allowList *regexp2.Regexp - denyList *regexp2.Regexp + allowList []*regexp2.Regexp + denyList []*regexp2.Regexp maxRetry int disable bool } @@ -192,8 +192,8 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor { fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths") fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests") fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables") - fs.String("api-download-from-allow-list", "", "Set the allowed URLs for the download from feature using a regular expression") - fs.String("api-download-from-deny-list", "", "Set the denied URLs for the download from feature using a regular expression") + fs.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed URLs for the download from feature using regular expressions - supports multiple values") + fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values") fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature") fs.Bool("api-disable-download-from", false, "Disable the download from feature") fs.Bool("api-disable-health-check-logging", false, "Disable health check logging") @@ -217,8 +217,8 @@ func (a *Api) Provision(ctx *gotenberg.Context) error { a.rootPath = flags.MustString("api-root-path") a.traceHeader = flags.MustString("api-trace-header") a.downloadFromCfg = downloadFromConfig{ - allowList: flags.MustRegexp("api-download-from-allow-list"), - denyList: flags.MustRegexp("api-download-from-deny-list"), + allowList: flags.MustRegexpSlice("api-download-from-allow-list"), + denyList: flags.MustRegexpSlice("api-download-from-deny-list"), maxRetry: flags.MustInt("api-download-from-max-retry"), disable: flags.MustBool("api-disable-download-from"), }
pkg/modules/api/context.go+1 −6 modified@@ -225,12 +225,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst ) } - err := gotenberg.FilterDeadline( - gotenberg.RegexpToSlice(downloadFromCfg.allowList), - gotenberg.RegexpToSlice(downloadFromCfg.denyList), - dl.Url, - deadline, - ) + err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline) if err != nil { return fmt.Errorf("filter URL: %w", err) }
pkg/modules/webhook/middleware.go+2 −12 modified@@ -114,22 +114,12 @@ func webhookMiddleware(w *Webhook) api.Middleware { // Let's check if the webhook URLs are acceptable according to our // allowed/denied lists. - err := gotenberg.FilterDeadline( - gotenberg.RegexpToSlice(w.allowList), - gotenberg.RegexpToSlice(w.denyList), - webhookUrl, - deadline, - ) + err := gotenberg.FilterDeadline(w.allowList, w.denyList, webhookUrl, deadline) if err != nil { return fmt.Errorf("filter webhook URL: %w", err) } - err = gotenberg.FilterDeadline( - gotenberg.RegexpToSlice(w.errorAllowList), - gotenberg.RegexpToSlice(w.errorDenyList), - webhookErrorUrl, - deadline, - ) + err = gotenberg.FilterDeadline(w.errorAllowList, w.errorDenyList, webhookErrorUrl, deadline) if err != nil { return fmt.Errorf("filter webhook error URL: %w", err) }
pkg/modules/webhook/webhook.go+12 −12 modified@@ -19,10 +19,10 @@ func init() { // to any destinations in an asynchronous fashion. type Webhook struct { enableSyncMode bool - allowList *regexp2.Regexp - denyList *regexp2.Regexp - errorAllowList *regexp2.Regexp - errorDenyList *regexp2.Regexp + allowList []*regexp2.Regexp + denyList []*regexp2.Regexp + errorAllowList []*regexp2.Regexp + errorDenyList []*regexp2.Regexp maxRetry int retryMinWait time.Duration retryMaxWait time.Duration @@ -38,10 +38,10 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor { FlagSet: func() *flag.FlagSet { fs := flag.NewFlagSet("webhook", flag.ExitOnError) fs.Bool("webhook-enable-sync-mode", false, "Enable synchronous mode for the webhook feature") - fs.String("webhook-allow-list", "", "Set the allowed URLs for the webhook feature using a regular expression") - fs.String("webhook-deny-list", "", "Set the denied URLs for the webhook feature using a regular expression") - fs.String("webhook-error-allow-list", "", "Set the allowed URLs in case of an error for the webhook feature using a regular expression") - fs.String("webhook-error-deny-list", "", "Set the denied URLs in case of an error for the webhook feature using a regular expression") + fs.StringSlice("webhook-allow-list", []string{}, "Set the allowed URLs for the webhook feature using regular expressions - supports multiple values") + fs.StringSlice("webhook-deny-list", []string{}, "Set the denied URLs for the webhook feature using regular expressions - supports multiple values") + fs.StringSlice("webhook-error-allow-list", []string{}, "Set the allowed URLs in case of an error for the webhook feature using regular expressions - supports multiple values") + fs.StringSlice("webhook-error-deny-list", []string{}, "Set the denied URLs in case of an error for the webhook feature using regular expressions - supports multiple values") fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature") fs.Duration("webhook-retry-min-wait", time.Duration(1)*time.Second, "Set the minimum duration to wait before trying to call the webhook again") fs.Duration("webhook-retry-max-wait", time.Duration(30)*time.Second, "Set the maximum duration to wait before trying to call the webhook again") @@ -58,10 +58,10 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor { func (w *Webhook) Provision(ctx *gotenberg.Context) error { flags := ctx.ParsedFlags() w.enableSyncMode = flags.MustBool("webhook-enable-sync-mode") - w.allowList = flags.MustRegexp("webhook-allow-list") - w.denyList = flags.MustRegexp("webhook-deny-list") - w.errorAllowList = flags.MustRegexp("webhook-error-allow-list") - w.errorDenyList = flags.MustRegexp("webhook-error-deny-list") + w.allowList = flags.MustRegexpSlice("webhook-allow-list") + w.denyList = flags.MustRegexpSlice("webhook-deny-list") + w.errorAllowList = flags.MustRegexpSlice("webhook-error-allow-list") + w.errorDenyList = flags.MustRegexpSlice("webhook-error-deny-list") w.maxRetry = flags.MustInt("webhook-max-retry") w.retryMinWait = flags.MustDuration("webhook-retry-min-wait") w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait")
test/integration/features/debug.feature+16 −16 modified@@ -58,8 +58,8 @@ Feature: /debug "api-body-limit": "", "api-disable-download-from": "false", "api-disable-health-check-logging": "false", - "api-download-from-allow-list": "", - "api-download-from-deny-list": "", + "api-download-from-allow-list": "[]", + "api-download-from-deny-list": "[]", "api-download-from-max-retry": "4", "api-enable-basic-auth": "false", "api-enable-debug-route": "true", @@ -73,11 +73,11 @@ Feature: /debug "api-trace-header": "Gotenberg-Trace", "chromium-allow-file-access-from-files": "false", "chromium-allow-insecure-localhost": "false", - "chromium-allow-list": "", + "chromium-allow-list": "[]", "chromium-auto-start": "false", "chromium-clear-cache": "false", "chromium-clear-cookies": "false", - "chromium-deny-list": "^file:(?!//\\/tmp/).*", + "chromium-deny-list": "[^file:(?!//\\/tmp/).*]", "chromium-disable-javascript": "false", "chromium-disable-routes": "false", "chromium-disable-web-security": "false", @@ -114,12 +114,12 @@ Feature: /debug "prometheus-disable-route-logging": "false", "prometheus-namespace": "gotenberg", "prometheus-metrics-path": "/prometheus/metrics", - "webhook-allow-list": "", + "webhook-allow-list": "[]", "webhook-client-timeout": "30s", - "webhook-deny-list": "", + "webhook-deny-list": "[]", "webhook-disable": "false", - "webhook-error-allow-list": "", - "webhook-error-deny-list": "", + "webhook-error-allow-list": "[]", + "webhook-error-deny-list": "[]", "webhook-max-retry": "4", "webhook-retry-max-wait": "30s", "webhook-retry-min-wait": "1s" @@ -180,8 +180,8 @@ Feature: /debug "api-body-limit": "", "api-disable-download-from": "false", "api-disable-health-check-logging": "false", - "api-download-from-allow-list": "", - "api-download-from-deny-list": "", + "api-download-from-allow-list": "[]", + "api-download-from-deny-list": "[]", "api-download-from-max-retry": "4", "api-enable-basic-auth": "false", "api-enable-debug-route": "true", @@ -195,11 +195,11 @@ Feature: /debug "api-trace-header": "Gotenberg-Trace", "chromium-allow-file-access-from-files": "false", "chromium-allow-insecure-localhost": "false", - "chromium-allow-list": "", + "chromium-allow-list": "[]", "chromium-auto-start": "false", "chromium-clear-cache": "false", "chromium-clear-cookies": "false", - "chromium-deny-list": "^file:(?!//\\/tmp/).*", + "chromium-deny-list": "[^file:(?!//\\/tmp/).*]", "chromium-disable-javascript": "false", "chromium-disable-routes": "false", "chromium-disable-web-security": "false", @@ -236,12 +236,12 @@ Feature: /debug "prometheus-disable-route-logging": "false", "prometheus-namespace": "gotenberg", "prometheus-metrics-path": "/prometheus/metrics", - "webhook-allow-list": "", + "webhook-allow-list": "[]", "webhook-client-timeout": "30s", - "webhook-deny-list": "", + "webhook-deny-list": "[]", "webhook-disable": "false", - "webhook-error-allow-list": "", - "webhook-error-deny-list": "", + "webhook-error-allow-list": "[]", + "webhook-error-deny-list": "[]", "webhook-max-retry": "4", "webhook-retry-max-wait": "30s", "webhook-retry-min-wait": "1s"
06b2b2e10c52fix(chromium): better default security and DX for allow / deny lists
13 files changed · +359 −57
pkg/gotenberg/filter.go+55 −27 modified@@ -12,40 +12,68 @@ import ( // ErrFiltered happens if a value is filtered by the [FilterDeadline] function. var ErrFiltered = errors.New("value filtered") -// FilterDeadline checks if given value is allowed and not denied according to -// regex patterns. It returns a [context.DeadlineExceeded] if it takes too long -// to process. -func FilterDeadline(allowed, denied *regexp2.Regexp, s string, deadline time.Time) error { - // FIXME: not ideal to compile everytime, but is there another way to create a clone? - if allowed.String() != "" { - allow := regexp2.MustCompile(allowed.String(), 0) - allow.MatchTimeout = time.Until(deadline) - - ok, err := allow.MatchString(s) - if err != nil { - if time.Now().After(deadline) { - return context.DeadlineExceeded +// RegexpToSlice wraps a single [regexp2.Regexp] into a slice suitable for +// [FilterDeadline]. If the regexp pattern is empty, it returns nil (meaning no +// filtering). +func RegexpToSlice(r *regexp2.Regexp) []*regexp2.Regexp { + if r == nil || r.String() == "" { + return nil + } + + return []*regexp2.Regexp{r} +} + +// FilterDeadline checks if the given value is allowed and not denied according +// to regex patterns. The allowed list uses OR semantics (value must match at +// least one pattern). The denied list uses OR semantics (value is denied if it +// matches any pattern). It returns a [context.DeadlineExceeded] if it takes +// too long to process. +func FilterDeadline(allowed, denied []*regexp2.Regexp, s string, deadline time.Time) error { + if len(allowed) > 0 { + matched := false + + for _, pattern := range allowed { + // FIXME: not ideal to compile everytime, but is there another way to create a clone? + clone := regexp2.MustCompile(pattern.String(), 0) + clone.MatchTimeout = time.Until(deadline) + + ok, err := clone.MatchString(s) + if err != nil { + if time.Now().After(deadline) { + return context.DeadlineExceeded + } + + return fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), s, err) + } + + if ok { + matched = true + break } - return fmt.Errorf("'%s' cannot handle '%s': %w", allow.String(), s, err) } - if !ok { - return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", s, ErrFiltered) + + if !matched { + return fmt.Errorf("'%s' does not match any expression from the allowed list: %w", s, ErrFiltered) } } - if denied.String() != "" { - deny := regexp2.MustCompile(denied.String(), 0) - deny.MatchTimeout = time.Until(deadline) + if len(denied) > 0 { + for _, pattern := range denied { + clone := regexp2.MustCompile(pattern.String(), 0) + clone.MatchTimeout = time.Until(deadline) + + ok, err := clone.MatchString(s) + if err != nil { + if time.Now().After(deadline) { + return context.DeadlineExceeded + } - ok, err := deny.MatchString(s) - if err != nil { - if time.Now().After(deadline) { - return context.DeadlineExceeded + return fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), s, err) + } + + if ok { + return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered) } - return fmt.Errorf("'%s' cannot handle '%s': %w", deny.String(), s, err) - } - if ok { - return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered) } }
pkg/gotenberg/filter_test.go+90 −14 modified@@ -12,57 +12,91 @@ import ( func TestFilterDeadline(t *testing.T) { for _, tc := range []struct { scenario string - allowed *regexp2.Regexp - denied *regexp2.Regexp + allowed []*regexp2.Regexp + denied []*regexp2.Regexp s string deadline time.Time expectError bool expectedError error }{ { scenario: "DeadlineExceeded (allowed)", - allowed: regexp2.MustCompile("foo", 0), - denied: regexp2.MustCompile("", 0), + allowed: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)}, + denied: nil, s: "foo", deadline: time.Now().Add(time.Duration(-1) * time.Hour), expectError: true, expectedError: context.DeadlineExceeded, }, { - scenario: "ErrFiltered (allowed)", - allowed: regexp2.MustCompile("foo", 0), - denied: regexp2.MustCompile("", 0), + scenario: "ErrFiltered (allowed, no match)", + allowed: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)}, + denied: nil, s: "bar", deadline: time.Now().Add(time.Duration(5) * time.Second), expectError: true, expectedError: ErrFiltered, }, { scenario: "DeadlineExceeded (denied)", - allowed: regexp2.MustCompile("", 0), - denied: regexp2.MustCompile("foo", 0), + allowed: nil, + denied: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)}, s: "foo", deadline: time.Now().Add(time.Duration(-1) * time.Hour), expectError: true, expectedError: context.DeadlineExceeded, }, { scenario: "ErrFiltered (denied)", - allowed: regexp2.MustCompile("", 0), - denied: regexp2.MustCompile("foo", 0), + allowed: nil, + denied: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)}, s: "foo", deadline: time.Now().Add(time.Duration(5) * time.Second), expectError: true, expectedError: ErrFiltered, }, { - scenario: "success", - allowed: regexp2.MustCompile("", 0), - denied: regexp2.MustCompile("", 0), + scenario: "success (empty lists)", + allowed: nil, + denied: nil, s: "foo", deadline: time.Now().Add(time.Duration(5) * time.Second), expectError: false, }, + { + scenario: "multi-pattern allow list, second matches", + allowed: []*regexp2.Regexp{regexp2.MustCompile("^https://", 0), regexp2.MustCompile("^file:///tmp/", 0)}, + denied: nil, + s: "file:///tmp/abc/index.html", + deadline: time.Now().Add(time.Duration(5) * time.Second), + expectError: false, + }, + { + scenario: "multi-pattern allow list, none matches", + allowed: []*regexp2.Regexp{regexp2.MustCompile("^https://", 0), regexp2.MustCompile("^ftp://", 0)}, + denied: nil, + s: "file:///tmp/abc/index.html", + deadline: time.Now().Add(time.Duration(5) * time.Second), + expectError: true, + expectedError: ErrFiltered, + }, + { + scenario: "multi-pattern deny list, second matches", + allowed: nil, + denied: []*regexp2.Regexp{regexp2.MustCompile("^ftp://", 0), regexp2.MustCompile("^file:.*", 0)}, + s: "file:///etc/passwd", + deadline: time.Now().Add(time.Duration(5) * time.Second), + expectError: true, + expectedError: ErrFiltered, + }, + { + scenario: "https URL passes deny list targeting file://", + allowed: nil, + denied: []*regexp2.Regexp{regexp2.MustCompile("^file:.*", 0)}, + s: "https://example.com", + deadline: time.Now().Add(time.Duration(5) * time.Second), + expectError: false, + }, } { t.Run(tc.scenario, func(t *testing.T) { err := FilterDeadline(tc.allowed, tc.denied, tc.s, tc.deadline) @@ -81,3 +115,45 @@ func TestFilterDeadline(t *testing.T) { }) } } + +func TestRegexpToSlice(t *testing.T) { + for _, tc := range []struct { + scenario string + input *regexp2.Regexp + expectNil bool + expectLen int + }{ + { + scenario: "nil regexp", + input: nil, + expectNil: true, + }, + { + scenario: "empty regexp", + input: regexp2.MustCompile("", 0), + expectNil: true, + }, + { + scenario: "non-empty regexp", + input: regexp2.MustCompile("^file:.*", 0), + expectNil: false, + expectLen: 1, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + result := RegexpToSlice(tc.input) + + if tc.expectNil && result != nil { + t.Fatalf("expected nil but got: %v", result) + } + + if !tc.expectNil && result == nil { + t.Fatal("expected non-nil but got nil") + } + + if !tc.expectNil && len(result) != tc.expectLen { + t.Fatalf("expected length %d but got %d", tc.expectLen, len(result)) + } + }) + } +}
pkg/gotenberg/flags.go+29 −0 modified@@ -222,3 +222,32 @@ func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *r return f.MustRegexp(newName) } + +// MustRegexpSlice returns a slice of compiled regular expressions from a +// string-slice flag given by name. Empty strings are skipped. +// It panics if an error occurs. +func (f *ParsedFlags) MustRegexpSlice(name string) []*regexp2.Regexp { + vals := f.MustStringSlice(name) + + var regexps []*regexp2.Regexp + for _, val := range vals { + if val == "" { + continue + } + + regexps = append(regexps, regexp2.MustCompile(val, 0)) + } + + return regexps +} + +// MustDeprecatedRegexpSlice returns the slice of compiled regular expressions +// of a deprecated flag if it was explicitly set or the slice of the new flag. +// It panics if an error occurs. +func (f *ParsedFlags) MustDeprecatedRegexpSlice(deprecated string, newName string) []*regexp2.Regexp { + if f.Changed(deprecated) { + return f.MustRegexpSlice(deprecated) + } + + return f.MustRegexpSlice(newName) +}
pkg/gotenberg/flags_test.go+118 −0 modified@@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/dlclark/regexp2" flag "github.com/spf13/pflag" ) @@ -833,3 +834,120 @@ func TestParsedFlags_MustDeprecatedRegexp(t *testing.T) { }) } } + +func TestParsedFlags_MustRegexpSlice(t *testing.T) { + fs := flag.NewFlagSet("tests", flag.ContinueOnError) + fs.StringSlice("foo", []string{}, "") + + err := fs.Parse([]string{"--foo=^file:.*", "--foo=^ftp://.*"}) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + parsedFlags := ParsedFlags{FlagSet: fs} + + for _, tc := range []struct { + scenario string + name string + expectPanic bool + expectLen int + }{ + { + scenario: "success with multiple patterns", + name: "foo", + expectPanic: false, + expectLen: 2, + }, + { + scenario: "non-existing flag", + name: "bar", + expectPanic: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + if tc.expectPanic { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic but got none") + } + }() + } + + if !tc.expectPanic { + defer func() { + if r := recover(); r != nil { + t.Fatalf("expected no panic but got: %v", r) + } + }() + } + + result := parsedFlags.MustRegexpSlice(tc.name) + + if !tc.expectPanic && len(result) != tc.expectLen { + t.Errorf("expected %d regexps but got %d", tc.expectLen, len(result)) + } + }) + } + + // Test empty strings are skipped. + fs2 := flag.NewFlagSet("tests2", flag.ContinueOnError) + fs2.StringSlice("baz", []string{""}, "") + + err = fs2.Parse([]string{}) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + parsedFlags2 := ParsedFlags{FlagSet: fs2} + result := parsedFlags2.MustRegexpSlice("baz") + if len(result) != 0 { + t.Errorf("expected 0 regexps for empty strings but got %d", len(result)) + } +} + +func TestParsedFlags_MustDeprecatedRegexpSlice(t *testing.T) { + for _, tc := range []struct { + scenario string + rawFlags []string + expectPattern string + }{ + { + scenario: "deprecated flag value", + rawFlags: []string{"--foo=^file:.*"}, + expectPattern: "^file:.*", + }, + { + scenario: "non-deprecated flag value", + rawFlags: []string{"--bar=^ftp://.*"}, + expectPattern: "^ftp://.*", + }, + { + scenario: "deprecated flag value > non-deprecated flag value", + rawFlags: []string{"--foo=^file:.*", "--bar=^ftp://.*"}, + expectPattern: "^file:.*", + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + fs := flag.NewFlagSet("tests", flag.ContinueOnError) + fs.StringSlice("foo", []string{}, "") + fs.StringSlice("bar", []string{}, "") + + parsedFlags := ParsedFlags{FlagSet: fs} + + err := parsedFlags.Parse(tc.rawFlags) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + actual := parsedFlags.MustDeprecatedRegexpSlice("foo", "bar") + if len(actual) != 1 { + t.Fatalf("expected 1 regexp but got %d", len(actual)) + } + if actual[0].String() != tc.expectPattern { + t.Errorf("expected pattern '%s' but got '%s'", tc.expectPattern, actual[0].String()) + } + }) + } + + _ = regexp2.None // Keep import alive. +}
pkg/modules/api/context.go+11 −1 modified@@ -225,7 +225,12 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst ) } - err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline) + err := gotenberg.FilterDeadline( + gotenberg.RegexpToSlice(downloadFromCfg.allowList), + gotenberg.RegexpToSlice(downloadFromCfg.denyList), + dl.Url, + deadline, + ) if err != nil { return fmt.Errorf("filter URL: %w", err) } @@ -430,6 +435,11 @@ func (ctx *Context) FormData() *FormData { } } +// DirPath returns the path to the request's working directory. +func (ctx *Context) DirPath() string { + return ctx.dirPath +} + // GeneratePath generates a path within the context's working directory. // It generates a new UUID-based filename. It does not create a file. func (ctx *Context) GeneratePath(extension string) string {
pkg/modules/chromium/browser.go+6 −5 modified@@ -42,8 +42,8 @@ type browserArguments struct { hyphenDataDirPath string // Tasks specific. - allowList *regexp2.Regexp - denyList *regexp2.Regexp + allowList []*regexp2.Regexp + denyList []*regexp2.Regexp clearCache bool clearCookies bool disableJavaScript bool @@ -356,9 +356,10 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string // 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, + allowList: b.arguments.allowList, + denyList: b.arguments.denyList, + allowedFilePrefixes: options.AllowedFilePrefixes, + extraHttpHeaders: options.ExtraHttpHeaders, }) var (
pkg/modules/chromium/chromium.go+10 −4 modified@@ -172,6 +172,12 @@ type Options struct { // OmitBackground hides the default white background and allows generating // PDFs with transparency. OmitBackground bool + + // AllowedFilePrefixes restricts file:// sub-resource access to only these + // directory prefixes. Applied in listenForEventRequestPaused in addition + // to the global allow/deny lists. Set internally by route handlers, not + // via form data. + AllowedFilePrefixes []string } // EmulatedMediaFeature gathers the available entries for emulating a media @@ -421,8 +427,8 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor { fs.Bool("chromium-allow-file-access-from-files", false, "Allow file:// URIs to read other file:// URIs") fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver") fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests") - fs.String("chromium-allow-list", "", "Set the allowed URLs for Chromium using a regular expression") - fs.String("chromium-deny-list", `^file:(?!//\/tmp/).*`, "Set the denied URLs for Chromium using a regular expression") + fs.StringSlice("chromium-allow-list", []string{}, "Set the allowed URLs for Chromium using regular expressions - supports multiple values") + fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values") fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion") fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion") fs.Bool("chromium-disable-javascript", false, "Disable JavaScript") @@ -469,8 +475,8 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error { wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"), hyphenDataDirPath: hyphenDataDirPath, - allowList: flags.MustRegexp("chromium-allow-list"), - denyList: flags.MustRegexp("chromium-deny-list"), + allowList: flags.MustRegexpSlice("chromium-allow-list"), + denyList: flags.MustRegexpSlice("chromium-deny-list"), clearCache: flags.MustBool("chromium-clear-cache"), clearCookies: flags.MustBool("chromium-clear-cookies"), disableJavaScript: flags.MustBool("chromium-disable-javascript"),
pkg/modules/chromium/events.go+21 −1 modified@@ -24,7 +24,8 @@ import ( ) type eventRequestPausedOptions struct { - allowList, denyList *regexp2.Regexp + allowList, denyList []*regexp2.Regexp + allowedFilePrefixes []string extraHttpHeaders []ExtraHttpHeader } @@ -58,6 +59,25 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option allow = false } + // Additional restriction: if the sub-resource is a file:// URL + // and we have allowed file prefixes, restrict access to only + // those directories. This prevents cross-request file access + // in /tmp. + if allow && strings.HasPrefix(e.Request.URL, "file://") && len(options.allowedFilePrefixes) > 0 { + prefixMatch := false + for _, prefix := range options.allowedFilePrefixes { + if strings.HasPrefix(e.Request.URL, "file://"+prefix) { + prefixMatch = true + break + } + } + + if !prefixMatch { + logger.Warn(fmt.Sprintf("'%s' is not within any allowed file prefix", e.Request.URL)) + allow = false + } + } + cctx := chromedp.FromContext(ctx) executorCtx := cdp.WithExecutor(ctx, cctx.Target)
pkg/modules/chromium/routes.go+4 −0 modified@@ -512,6 +512,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { } url := fmt.Sprintf("file://%s", inputPath) + options.AllowedFilePrefixes = []string{ctx.DirPath()} err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages) if err != nil { return fmt.Errorf("convert HTML to PDF: %w", err) @@ -542,6 +543,7 @@ func screenshotHtmlRoute(chromium Api) api.Route { } url := fmt.Sprintf("file://%s", inputPath) + options.AllowedFilePrefixes = []string{ctx.DirPath()} err = screenshotUrl(ctx, chromium, url, options) if err != nil { return fmt.Errorf("HTML screenshot: %w", err) @@ -598,6 +600,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("transform markdown file(s) to HTML: %w", err) } + options.AllowedFilePrefixes = []string{ctx.DirPath()} err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages) if err != nil { return fmt.Errorf("convert markdown to PDF: %w", err) @@ -637,6 +640,7 @@ func screenshotMarkdownRoute(chromium Api) api.Route { return fmt.Errorf("transform markdown file(s) to HTML: %w", err) } + options.AllowedFilePrefixes = []string{ctx.DirPath()} err = screenshotUrl(ctx, chromium, url, options) if err != nil { return fmt.Errorf("markdown screenshot: %w", err)
pkg/modules/webhook/middleware.go+12 −2 modified@@ -114,12 +114,22 @@ func webhookMiddleware(w *Webhook) api.Middleware { // Let's check if the webhook URLs are acceptable according to our // allowed/denied lists. - err := gotenberg.FilterDeadline(w.allowList, w.denyList, webhookUrl, deadline) + err := gotenberg.FilterDeadline( + gotenberg.RegexpToSlice(w.allowList), + gotenberg.RegexpToSlice(w.denyList), + webhookUrl, + deadline, + ) if err != nil { return fmt.Errorf("filter webhook URL: %w", err) } - err = gotenberg.FilterDeadline(w.errorAllowList, w.errorDenyList, webhookErrorUrl, deadline) + err = gotenberg.FilterDeadline( + gotenberg.RegexpToSlice(w.errorAllowList), + gotenberg.RegexpToSlice(w.errorDenyList), + webhookErrorUrl, + deadline, + ) if err != nil { return fmt.Errorf("filter webhook error URL: %w", err) }
test/integration/features/chromium_convert_html.feature+1 −1 modified@@ -401,7 +401,7 @@ Feature: /forms/chromium/convert/html Then the response header "Content-Type" should be "application/pdf" Then there should be 1 PDF(s) in the response Then the Gotenberg container should log the following entries: - | 'file:///etc/passwd' does not match the expression from the allowed list | + | 'file:///etc/passwd' does not match any expression from the allowed list | Scenario: POST /forms/chromium/convert/html (JavaScript Enabled) Given I have a default Gotenberg container
test/integration/features/chromium_convert_markdown.feature+1 −1 modified@@ -357,7 +357,7 @@ Feature: /forms/chromium/convert/markdown Then the response header "Content-Type" should be "application/pdf" Then there should be 1 PDF(s) in the response Then the Gotenberg container should log the following entries: - | 'file:///etc/passwd' does not match the expression from the allowed list | + | 'file:///etc/passwd' does not match any expression from the allowed list | Scenario: POST /forms/chromium/convert/markdown (JavaScript Enabled) Given I have a default Gotenberg container
test/integration/features/chromium_convert_url.feature+1 −1 modified@@ -476,7 +476,7 @@ Feature: /forms/chromium/convert/url Then there should be 1 PDF(s) in the response Then the Gotenberg container should NOT log the following entries: # Modern browsers block file URIs from being loaded into iframes when the parent page is served over HTTP/HTTPS. - | 'file:///etc/passwd' does not match the expression from the allowed list | + | 'file:///etc/passwd' does not match any expression from the allowed list | Scenario: POST /forms/chromium/convert/url (JavaScript Enabled) Given I have a default Gotenberg container
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/gotenberg/gotenberg/commit/06b2b2e10c52b58135edbfe82e94d599eb0c5a11nvdPatchWEB
- github.com/gotenberg/gotenberg/commit/8625a4e899eb75e6fcf46d28394334c7fd79fff5nvdPatchWEB
- github.com/gotenberg/gotenberg/security/advisories/GHSA-jjwv-57xh-xr6rnvdExploitMitigationVendor AdvisoryWEB
- github.com/advisories/GHSA-jjwv-57xh-xr6rghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27018ghsaADVISORY
- github.com/gotenberg/gotenberg/releases/tag/v8.29.0nvdProductRelease NotesWEB
- github.com/gotenberg/gotenberg/security/advisories/GHSA-rh2x-ccvw-q7r3ghsaWEB
News mentions
0No linked articles in our index yet.