diff --git a/Makefile b/Makefile index 3ffd8e31..85dace02 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,10 @@ API_OIDC_ISSUER= API_OIDC_AUDIENCE= API_OIDC_JWKS_URL= API_DOWNLOAD_FROM_ALLOW_LIST= -API_DOWNLOAD_FROM_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd) +# Empty, like the flag default since 8.32.0. A textual deny-list cannot +# enumerate every way to write a private address, so *_DENY_PRIVATE_IPS is the +# control to reach for. Left false here so local testing can reach the host. +API_DOWNLOAD_FROM_DENY_LIST= API_DOWNLOAD_FROM_DENY_PRIVATE_IPS=false API_DOWNLOAD_FROM_DENY_PUBLIC_IPS=false API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY=false @@ -114,7 +117,8 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 OTEL_EXPORTER_OTLP_INSECURE=true WEBHOOK_ENABLE_SYNC_MODE=false WEBHOOK_ALLOW_LIST= -WEBHOOK_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd) +# See the note on API_DOWNLOAD_FROM_DENY_LIST. +WEBHOOK_DENY_LIST= WEBHOOK_DENY_PRIVATE_IPS=false WEBHOOK_DENY_PUBLIC_IPS=false WEBHOOK_ENABLE_ENVIRONMENT_PROXY=false diff --git a/pkg/gotenberg/allowlist.go b/pkg/gotenberg/allowlist.go index 8ffb6c8d..a4489a5c 100644 --- a/pkg/gotenberg/allowlist.go +++ b/pkg/gotenberg/allowlist.go @@ -343,8 +343,9 @@ const ( const hostTerminators = "/:#?" // crosserClassChars are the characters that, if a class can match them, let a -// match escape the authority. -const crosserClassChars = "/@?#" +// match escape the authority. "/" is deliberately absent: it ends the +// authority rather than escaping it, so a class such as "[:/]" is safe. +const crosserClassChars = "@?#" // classifyHostToken classifies one token of the authority walk. func classifyHostToken(token string) hostTokenKind { @@ -383,6 +384,9 @@ func classifyHostToken(token string) hostTokenKind { if classContainsAny(inner, crosserClassChars) { return hostTokenCrosser } + if classOnlyTerminators(inner) { + return hostTokenTerminator + } return hostTokenNeutral case strings.HasPrefix(token, "("): @@ -477,6 +481,35 @@ func tokenAt(s string, i int) string { return s[i : i+1] } +// classOnlyTerminators reports whether every character a class can match ends +// the authority, which makes the class itself a terminator. A range is never +// treated as one. +func classOnlyTerminators(class string) bool { + if class == "" { + return false + } + + for i := 0; i < len(class); i++ { + if class[i] == '\\' && i+1 < len(class) { + if !strings.Contains(hostTerminators, class[i+1:i+2]) { + return false + } + i++ + continue + } + + if i+2 < len(class) && class[i+1] == '-' { + return false + } + + if !strings.Contains(hostTerminators, class[i:i+1]) { + return false + } + } + + return true +} + // classContainsAny reports whether a character class body can match any of the // given characters, expanding simple ranges. func classContainsAny(class, chars string) bool { diff --git a/pkg/gotenberg/allowlist_test.go b/pkg/gotenberg/allowlist_test.go index 96455be9..7b188740 100644 --- a/pkg/gotenberg/allowlist_test.go +++ b/pkg/gotenberg/allowlist_test.go @@ -24,6 +24,8 @@ func TestAuditAllowList(t *testing.T) { {"alternation both anchored and terminated", `^https://a\.example/|^https://b\.example/`, ""}, {"no authority to check", `^file:///tmp/`, ""}, {"digit class in host", `^https://node\d+\.example\.com/`, ""}, + {"class of only terminators", `^https://example\.com[:/]`, ""}, + {"feature file pattern, fixed", `^https?://host\.docker\.internal(:[0-9]+)?/`, ""}, // Unanchored: regexp2 searches, so these match anywhere in the URL. {"no anchor", `trusted\.example\.com`, AllowListRiskUnanchored}, diff --git a/pkg/modules/api/api.go b/pkg/modules/api/api.go index c5a1b590..0b4506f7 100644 --- a/pkg/modules/api/api.go +++ b/pkg/modules/api/api.go @@ -208,7 +208,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor { fs.String("api-oidc-issuer", "", "Set the OIDC issuer URL, e.g. https://tenant.example.com/ - the token 'iss' claim must match") fs.String("api-oidc-audience", "", "Set the expected OIDC audience - the token 'aud' claim must contain it") fs.String("api-oidc-jwks-url", "", "Set the OIDC JWKS URL - discovered from the issuer's well-known configuration when empty") - 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-allow-list", []string{}, `Set the allowed URLs for the download from feature using regular expressions - supports multiple values. A match bypasses --api-download-from-deny-private-ips (API_DOWNLOAD_FROM_DENY_PRIVATE_IPS) and --api-download-from-deny-public-ips (API_DOWNLOAD_FROM_DENY_PUBLIC_IPS), so terminate the host or the pattern also matches suffix hosts, for example ^https?://internal\.svc(:|/|$)`) fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values") fs.Bool("api-download-from-deny-private-ips", false, "Reject downloadFrom URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted downloadFrom sources to mitigate SSRF against internal services") fs.Bool("api-download-from-deny-public-ips", false, "Reject downloadFrom URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent downloads from reaching the public internet") diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index 51173248..d384c05d 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -475,7 +475,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor { 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.Bool("chromium-enable-environment-proxy", false, "Route Chromium's outbound requests through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials. Use this instead of --chromium-proxy-server for authenticated proxies, and leave --chromium-proxy-server and --chromium-host-resolver-rules unset") - fs.StringSlice("chromium-allow-list", []string{}, "Set the allowed URLs for Chromium using regular expressions - supports multiple values") + fs.StringSlice("chromium-allow-list", []string{}, `Set the allowed URLs for Chromium using regular expressions - supports multiple values. A match bypasses --chromium-deny-private-ips (CHROMIUM_DENY_PRIVATE_IPS) and --chromium-deny-public-ips (CHROMIUM_DENY_PUBLIC_IPS), so terminate the host or the pattern also matches suffix hosts, for example ^https?://internal\.svc(:|/|$)`) fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values") fs.Bool("chromium-deny-private-ips", false, "Reject URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted form input to mitigate SSRF against internal services") fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network") diff --git a/pkg/modules/libreoffice/api/api.go b/pkg/modules/libreoffice/api/api.go index 815d71a2..71e41937 100644 --- a/pkg/modules/libreoffice/api/api.go +++ b/pkg/modules/libreoffice/api/api.go @@ -353,7 +353,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor { fs.Duration("libreoffice-idle-shutdown-timeout", 0, "Shutdown LibreOffice after being idle for the given duration. Set to 0 to disable this feature") fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion") fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart") - fs.StringSlice("libreoffice-allow-list", []string{}, "Set the allowed URLs for LibreOffice outbound fetches (embedded images, linked content) using regular expressions - supports multiple values") + fs.StringSlice("libreoffice-allow-list", []string{}, `Set the allowed URLs for LibreOffice outbound fetches (embedded images, linked content) using regular expressions - supports multiple values. A match bypasses --libreoffice-deny-private-ips (LIBREOFFICE_DENY_PRIVATE_IPS) and --libreoffice-deny-public-ips (LIBREOFFICE_DENY_PUBLIC_IPS), so terminate the host or the pattern also matches suffix hosts, for example ^https?://internal\.svc(:|/|$)`) fs.StringSlice("libreoffice-deny-list", []string{}, "Set the denied URLs for LibreOffice outbound fetches using regular expressions - supports multiple values") fs.Bool("libreoffice-deny-private-ips", false, "Reject LibreOffice outbound URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted documents to mitigate SSRF against internal services") fs.Bool("libreoffice-deny-public-ips", false, "Reject LibreOffice outbound URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network") diff --git a/pkg/modules/webhook/webhook.go b/pkg/modules/webhook/webhook.go index ff036a40..0916c28d 100644 --- a/pkg/modules/webhook/webhook.go +++ b/pkg/modules/webhook/webhook.go @@ -42,7 +42,7 @@ 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.StringSlice("webhook-allow-list", []string{}, "Set the allowed URLs for the webhook feature using regular expressions - supports multiple values") + fs.StringSlice("webhook-allow-list", []string{}, `Set the allowed URLs for the webhook feature using regular expressions - supports multiple values. A match bypasses --webhook-deny-private-ips (WEBHOOK_DENY_PRIVATE_IPS) and --webhook-deny-public-ips (WEBHOOK_DENY_PUBLIC_IPS), so terminate the host or the pattern also matches suffix hosts, for example ^https?://internal\.svc(:|/|$)`) fs.StringSlice("webhook-deny-list", []string{}, "Set the denied URLs for the webhook feature using regular expressions - supports multiple values") fs.Bool("webhook-deny-private-ips", false, "Reject webhook URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted webhook destinations to mitigate SSRF against internal services") fs.Bool("webhook-deny-public-ips", false, "Reject webhook URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent callbacks from leaving a private network") @@ -50,7 +50,7 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor { fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature") // Deprecated flags. - 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-allow-list", []string{}, `Set the allowed URLs in case of an error for the webhook feature using regular expressions - supports multiple values. A match bypasses --webhook-deny-private-ips (WEBHOOK_DENY_PRIVATE_IPS) and --webhook-deny-public-ips (WEBHOOK_DENY_PUBLIC_IPS), so terminate the host or the pattern also matches suffix hosts, for example ^https?://internal\.svc(:|/|$)`) 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") err := fs.MarkDeprecated("webhook-error-allow-list", "use --webhook-allow-list instead") if err != nil { diff --git a/test/integration/features/chromium_convert_url.feature b/test/integration/features/chromium_convert_url.feature index 7e6a5bec..00eeed9a 100644 --- a/test/integration/features/chromium_convert_url.feature +++ b/test/integration/features/chromium_convert_url.feature @@ -589,7 +589,7 @@ Feature: /forms/chromium/convert/url @chromium-ssrf Scenario: POST /forms/chromium/convert/url (Redirect to a non-allow-listed address is re-filtered) Given I have a Gotenberg container with the following environment variable(s): - | CHROMIUM_ALLOW_LIST | ^https?://host.docker.internal.* | + | CHROMIUM_ALLOW_LIST | ^https?://host\.docker\.internal(:[0-9]+)?/ | Given I have a static server When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): | url | http://host.docker.internal:%d/redirect-to-private | field |