mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-08 05:23:18 +01:00
402 lines
12 KiB
Go
402 lines
12 KiB
Go
package gotenberg
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dlclark/regexp2"
|
|
"github.com/labstack/gommon/bytes"
|
|
flag "github.com/spf13/pflag"
|
|
|
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg/internal/log"
|
|
)
|
|
|
|
// ParsedFlags wraps a [flag.FlagSet] so that retrieving the typed values is
|
|
// easier.
|
|
type ParsedFlags struct {
|
|
*flag.FlagSet
|
|
}
|
|
|
|
// MustString returns the string value of a flag given by name.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustString(name string) string {
|
|
val, err := f.GetString(name)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
// MustDeprecatedString returns the string value of a deprecated flag if it was
|
|
// explicitly set or the string value of the new flag.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustDeprecatedString(deprecated string, newName string) string {
|
|
if f.Changed(deprecated) {
|
|
return f.MustString(deprecated)
|
|
}
|
|
|
|
return f.MustString(newName)
|
|
}
|
|
|
|
// MustStringSlice returns the string slice value of a flag given by name.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustStringSlice(name string) []string {
|
|
val, err := f.GetStringSlice(name)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
// MustDeprecatedStringSlice returns the string slice value of a deprecated
|
|
// flag if it was explicitly set or the string slice value of the new flag.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustDeprecatedStringSlice(deprecated string, newName string) []string {
|
|
if f.Changed(deprecated) {
|
|
return f.MustStringSlice(deprecated)
|
|
}
|
|
|
|
return f.MustStringSlice(newName)
|
|
}
|
|
|
|
// MustBool returns the boolean value of a flag given by name.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustBool(name string) bool {
|
|
val, err := f.GetBool(name)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
// MustDeprecatedBool returns the boolean value of a deprecated flag if it was
|
|
// explicitly set or the int value of the new flag.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustDeprecatedBool(deprecated string, newName string) bool {
|
|
if f.Changed(deprecated) {
|
|
return f.MustBool(deprecated)
|
|
}
|
|
|
|
return f.MustBool(newName)
|
|
}
|
|
|
|
// MustInt64 returns the int64 value of a flag given by name.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustInt64(name string) int64 {
|
|
val, err := f.GetInt64(name)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
// MustDeprecatedInt64 returns the int64 value of a deprecated flag if it was
|
|
// explicitly set or the int64 value of the new flag.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustDeprecatedInt64(deprecated string, newName string) int64 {
|
|
if f.Changed(deprecated) {
|
|
return f.MustInt64(deprecated)
|
|
}
|
|
|
|
return f.MustInt64(newName)
|
|
}
|
|
|
|
// MustInt returns the int value of a flag given by name.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustInt(name string) int {
|
|
val, err := f.GetInt(name)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
// MustDeprecatedInt returns the int value of a deprecated flag if it was
|
|
// explicitly set or the int value of the new flag.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustDeprecatedInt(deprecated string, newName string) int {
|
|
if f.Changed(deprecated) {
|
|
return f.MustInt(deprecated)
|
|
}
|
|
|
|
return f.MustInt(newName)
|
|
}
|
|
|
|
// MustFloat64 returns the float value of a flag given by name.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustFloat64(name string) float64 {
|
|
val, err := f.GetFloat64(name)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
// MustDeprecatedFloat64 returns the float value of a deprecated flag if it was
|
|
// explicitly set or the float value of the new flag.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustDeprecatedFloat64(deprecated string, newName string) float64 {
|
|
if f.Changed(deprecated) {
|
|
return f.MustFloat64(deprecated)
|
|
}
|
|
|
|
return f.MustFloat64(newName)
|
|
}
|
|
|
|
// MustDuration returns the time.Duration value of a flag given by name.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustDuration(name string) time.Duration {
|
|
val, err := f.GetDuration(name)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
// MustDeprecatedDuration returns the time.Duration value of a deprecated flag
|
|
// if it was explicitly set or the time.Duration value of the new flag.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustDeprecatedDuration(deprecated string, newName string) time.Duration {
|
|
if f.Changed(deprecated) {
|
|
return f.MustDuration(deprecated)
|
|
}
|
|
|
|
return f.MustDuration(newName)
|
|
}
|
|
|
|
// MustHumanReadableBytes returns the human-readable bytes string of a flag
|
|
// given by name.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustHumanReadableBytes(name string) int64 {
|
|
val, err := f.GetString(name)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
if val == "" {
|
|
return 0
|
|
}
|
|
|
|
b, err := bytes.Parse(val)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return b
|
|
}
|
|
|
|
// MustDeprecatedHumanReadableBytes returns the human-readable bytes of a
|
|
// deprecated flag if it was explicitly set or the human-readable bytes string
|
|
// of the new flag.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustDeprecatedHumanReadableBytes(deprecated string, newName string) int64 {
|
|
if f.Changed(deprecated) {
|
|
return f.MustHumanReadableBytes(deprecated)
|
|
}
|
|
|
|
return f.MustHumanReadableBytes(newName)
|
|
}
|
|
|
|
// PatternMatchTimeout bounds a single match against an operator-supplied
|
|
// allow-list or deny-list pattern.
|
|
//
|
|
// regexp2 backtracks, and the strings matched against these patterns are
|
|
// client-controlled: a request URL, a CONNECT host. A pattern that backtracks
|
|
// catastrophically would otherwise burn a core for as long as the caller's
|
|
// deadline allows, which is --api-timeout (env API_TIMEOUT), 30 seconds by
|
|
// default. The ceiling mirrors the one the Chromium module already applies to
|
|
// the per-request extraHttpHeaders scope pattern.
|
|
//
|
|
// [ParsedFlags.MustRegexp] and [ParsedFlags.MustRegexpSlice] stamp this onto
|
|
// every pattern they compile, which is how all four production lists are
|
|
// built. Patterns compiled any other way keep regexp2's default of
|
|
// math.MaxInt64, which it treats as no timeout at all, so a hand-built slice
|
|
// must set this itself before reaching [DecideOutbound].
|
|
const PatternMatchTimeout = 250 * time.Millisecond
|
|
|
|
// MustRegexp returns the regular expression of a flag given by name.
|
|
// It panics if an error occurs.
|
|
//
|
|
// The returned expression carries [PatternMatchTimeout] and is safe to match
|
|
// on concurrently: callers must not compile a private copy per match.
|
|
func (f *ParsedFlags) MustRegexp(name string) *regexp2.Regexp {
|
|
val, err := f.GetString(name)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
re := regexp2.MustCompile(val, 0)
|
|
re.MatchTimeout = PatternMatchTimeout
|
|
|
|
return re
|
|
}
|
|
|
|
// MustDeprecatedRegexp returns the regular expression of a deprecated flag if
|
|
// it was explicitly set or the regular expression of the new flag.
|
|
// It panics if an error occurs.
|
|
func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *regexp2.Regexp {
|
|
if f.Changed(deprecated) {
|
|
return f.MustRegexp(deprecated)
|
|
}
|
|
|
|
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.
|
|
//
|
|
// Every allow-list and deny-list in Gotenberg is read through this method, so
|
|
// it is also where allow-list patterns are audited. See [AuditAllowList].
|
|
//
|
|
// The returned expressions carry [PatternMatchTimeout] and are safe to match
|
|
// on concurrently: callers must not compile a private copy per match.
|
|
func (f *ParsedFlags) MustRegexpSlice(name string) []*regexp2.Regexp {
|
|
vals := f.MustStringSlice(name)
|
|
|
|
f.warnRiskyAllowList(name, vals)
|
|
|
|
var regexps []*regexp2.Regexp
|
|
for _, val := range vals {
|
|
if val == "" {
|
|
continue
|
|
}
|
|
|
|
re := regexp2.MustCompile(val, 0)
|
|
re.MatchTimeout = PatternMatchTimeout
|
|
|
|
regexps = append(regexps, re)
|
|
}
|
|
|
|
return regexps
|
|
}
|
|
|
|
// allowListFlagSuffix identifies the flags whose patterns grant an IP-check
|
|
// bypass. Deny-lists are never audited: they always apply, cannot be bypassed,
|
|
// and a loose deny-list is safe rather than dangerous.
|
|
const allowListFlagSuffix = "-allow-list"
|
|
|
|
// warnRiskyAllowList logs one warning per allow-list entry that matches more
|
|
// URLs than its author is likely to intend.
|
|
//
|
|
// It warns and never fails: operators depend on loose patterns today, and
|
|
// rejecting them at startup would break running deployments.
|
|
func (f *ParsedFlags) warnRiskyAllowList(name string, vals []string) {
|
|
if !strings.HasSuffix(name, allowListFlagSuffix) {
|
|
return
|
|
}
|
|
|
|
findings := AuditAllowList(vals)
|
|
if len(findings) == 0 {
|
|
return
|
|
}
|
|
|
|
// The logger is nil until the entry point initializes it, which happens
|
|
// before any module is provisioned. Tests and embedders that call this
|
|
// method directly get no logger, and must not panic for it.
|
|
logger := log.Logger()
|
|
if logger == nil {
|
|
return
|
|
}
|
|
|
|
for _, finding := range findings {
|
|
// Provision has no context.Context to propagate, so the trace-aware
|
|
// logging convention is satisfied with a background context.
|
|
logger.WarnContext(
|
|
context.Background(),
|
|
f.allowListWarning(name, finding),
|
|
slog.String("flag", "--"+name),
|
|
slog.String("env", EnvVarName(name)),
|
|
slog.Int("entry", finding.Index+1),
|
|
slog.String("reason", string(finding.Risk)),
|
|
)
|
|
}
|
|
}
|
|
|
|
// allowListWarning builds the operator-facing message for a finding. It names
|
|
// the flag and its environment variable, and, when they exist, the IP-check
|
|
// flags the entry silently disables.
|
|
func (f *ParsedFlags) allowListWarning(name string, finding AllowListFinding) string {
|
|
var b strings.Builder
|
|
|
|
// Print the pattern raw rather than quoted: %q escapes every backslash, so
|
|
// the operator would not recognize the value they set.
|
|
fmt.Fprintf(&b, "--%s (%s) entry %d '%s' ", name, EnvVarName(name), finding.Index+1, finding.Pattern)
|
|
|
|
switch finding.Risk {
|
|
case AllowListRiskUnanchored:
|
|
b.WriteString("is not anchored with ^, so it matches anywhere in the URL and a URL such as http://attacker.example/?u=trusted.example.com passes. ")
|
|
case AllowListRiskUnanchoredBranch:
|
|
b.WriteString("has an alternation branch that is not anchored with ^, and that branch matches anywhere in the URL. ")
|
|
case AllowListRiskCatchAll:
|
|
b.WriteString("matches every URL. ")
|
|
case AllowListRiskOpenHost:
|
|
b.WriteString("does not terminate the host, so it also matches suffix hosts such as http://trusted.example.com.attacker.example/. ")
|
|
}
|
|
|
|
b.WriteString(f.bypassSentence(name))
|
|
|
|
switch finding.Risk {
|
|
case AllowListRiskUnanchored, AllowListRiskUnanchoredBranch:
|
|
b.WriteString("Anchor every branch with ^ and end the host with /, :, or $.")
|
|
case AllowListRiskCatchAll:
|
|
b.WriteString("Restrict the entry to the hosts you trust, or unset the flag.")
|
|
case AllowListRiskOpenHost:
|
|
b.WriteString("End the host with /, :, $, or a group such as (:|/|$).")
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// bypassSentence names the IP-check flags an allow-list match skips, when the
|
|
// module registers them.
|
|
func (f *ParsedFlags) bypassSentence(name string) string {
|
|
prefix := strings.TrimSuffix(name, allowListFlagSuffix)
|
|
|
|
private, public := prefix+"-deny-private-ips", prefix+"-deny-public-ips"
|
|
if f.Lookup(private) == nil || f.Lookup(public) == nil {
|
|
// A deprecated alias such as webhook-error-allow-list carries an extra
|
|
// segment that the IP-check flags do not have.
|
|
if i := strings.LastIndex(prefix, "-"); i != -1 {
|
|
private, public = prefix[:i]+"-deny-private-ips", prefix[:i]+"-deny-public-ips"
|
|
}
|
|
}
|
|
|
|
if f.Lookup(private) == nil || f.Lookup(public) == nil {
|
|
return "A URL that matches the allow-list skips the private and public IP checks. "
|
|
}
|
|
|
|
return fmt.Sprintf(
|
|
"A URL that matches the allow-list skips --%s (%s) and --%s (%s). ",
|
|
private, EnvVarName(private), public, EnvVarName(public),
|
|
)
|
|
}
|
|
|
|
// EnvVarName returns the environment variable that overrides the flag given by
|
|
// name. The entry point derives the same name when it applies environment
|
|
// overrides, so operator-facing messages can name both without drifting.
|
|
func EnvVarName(name string) string {
|
|
return strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
|
|
}
|
|
|
|
// 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)
|
|
}
|