All checks were successful
check / check (push) Successful in 3m7s
With TRUSTED_PROXIES empty, every rate limiter keys on the connecting peer. Production runs behind a TLS-terminating reverse proxy, so the peer is that proxy for every request and all clients share one bucket per limit. For the login limiter that means any remote client sending five POSTs a minute holds the only administrative login at HTTP 429. The empty default is correct — trusting forwarded headers from arbitrary peers lets any client choose its own bucket — so this makes the consequence visible rather than changing the keying, the limits or the default: - config logs a WARN at startup when the environment is prod and TRUSTED_PROXIES is empty, naming the variable, the shared bucket and the deniable admin login. - The security-feature bullet's "per IP" login claim is now conditional on TRUSTED_PROXIES, which is the only case where it holds. - The rate-limiting section separates the receiver case (sharing costs throughput, the safe direction) from the login case (sharing costs availability of the only admin path, not safe). - The trusted-proxies configuration section states the consequence and names TRUSTED_PROXIES as the remedy.
505 lines
14 KiB
Go
505 lines
14 KiB
Go
// Package config loads application configuration from environment variables.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/netip"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/fx"
|
|
"sneak.berlin/go/webhooker/internal/globals"
|
|
"sneak.berlin/go/webhooker/internal/logger"
|
|
|
|
// Populates the environment from a ./.env file automatically for
|
|
// development configuration. Kept in one place only (here).
|
|
_ "github.com/joho/godotenv/autoload"
|
|
)
|
|
|
|
const (
|
|
// EnvironmentDev represents development environment.
|
|
EnvironmentDev = "dev"
|
|
// EnvironmentProd represents production environment.
|
|
EnvironmentProd = "prod"
|
|
|
|
// defaultPort is the default HTTP listen port.
|
|
defaultPort = 8080
|
|
|
|
// defaultRetentionSweepInterval is how often the retention
|
|
// reaper deletes events older than each webhook's RetentionDays.
|
|
defaultRetentionSweepInterval = time.Hour
|
|
|
|
// defaultSessionIdleTimeout is how long a session may go without
|
|
// authenticated activity before it expires.
|
|
defaultSessionIdleTimeout = 24 * time.Hour
|
|
|
|
// defaultReceiverRateLimit is the default number of requests
|
|
// per minute each client IP may send to a single webhook
|
|
// receiver entrypoint. Generous for legitimate webhook
|
|
// senders while bounding abuse of the one unauthenticated,
|
|
// internet-exposed endpoint.
|
|
defaultReceiverRateLimit = 120
|
|
|
|
// maxPort is the highest valid TCP port number. The lower
|
|
// bound (at least 1) is enforced by envPositiveInt.
|
|
maxPort = 65535
|
|
|
|
// mappedV4Offset is the number of leading bits an IPv4-mapped
|
|
// IPv6 prefix spends on the ::ffff:0:0/96 wrapper, so a /104
|
|
// covers the same addresses as an IPv4 /8.
|
|
mappedV4Offset = 96
|
|
)
|
|
|
|
// ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT
|
|
// contains an unrecognised value.
|
|
var ErrInvalidEnvironment = errors.New("invalid environment")
|
|
|
|
// ErrNonPositiveValue is returned when an environment variable that
|
|
// requires a positive integer is set to zero or a negative number.
|
|
var ErrNonPositiveValue = errors.New("value must be positive")
|
|
|
|
// ErrInvalidPort is returned when an environment variable holding a
|
|
// TCP port number is set above the valid port range.
|
|
var ErrInvalidPort = errors.New("invalid port")
|
|
|
|
// ErrInvalidCIDR is returned when an environment variable holding a
|
|
// list of CIDR blocks contains an entry that is neither a CIDR block
|
|
// nor a bare IP address.
|
|
var ErrInvalidCIDR = errors.New("invalid CIDR")
|
|
|
|
//nolint:revive // ConfigParams is a standard fx naming convention.
|
|
type ConfigParams struct {
|
|
fx.In
|
|
|
|
Globals *globals.Globals
|
|
Logger *logger.Logger
|
|
}
|
|
|
|
// Config holds all application configuration loaded from
|
|
// environment variables.
|
|
type Config struct {
|
|
DataDir string
|
|
Debug bool
|
|
MaintenanceMode bool
|
|
Environment string
|
|
MetricsPassword string
|
|
MetricsUsername string
|
|
Port int
|
|
SentryDSN string
|
|
|
|
// RetentionSweepInterval is how often the retention reaper runs.
|
|
// Always positive: it becomes a time.NewTicker period.
|
|
RetentionSweepInterval time.Duration
|
|
|
|
// SessionIdleTimeout is the sliding inactivity window after
|
|
// which a session expires. Non-positive disables idle expiry.
|
|
SessionIdleTimeout time.Duration
|
|
|
|
// ReceiverRateLimit is the number of requests per minute each
|
|
// client IP may send to a single webhook receiver entrypoint.
|
|
ReceiverRateLimit int
|
|
|
|
// TrustedProxies is the set of networks whose members are
|
|
// allowed to speak for the client with X-Forwarded-For, the
|
|
// only forwarded header read. It is empty unless
|
|
// TRUSTED_PROXIES is set, and empty means no peer is
|
|
// trusted: forwarded headers are then ignored entirely and
|
|
// clients are identified by the connection's own address.
|
|
// Members can choose their own rate-limit key, so this must
|
|
// name proxy hosts only, never a block that also covers
|
|
// clients.
|
|
TrustedProxies []netip.Prefix
|
|
|
|
params *ConfigParams
|
|
log *slog.Logger
|
|
}
|
|
|
|
// IsDev returns true if running in development environment.
|
|
func (c *Config) IsDev() bool {
|
|
return c.Environment == EnvironmentDev
|
|
}
|
|
|
|
// IsProd returns true if running in production environment.
|
|
func (c *Config) IsProd() bool {
|
|
return c.Environment == EnvironmentProd
|
|
}
|
|
|
|
// envString returns the value of the named environment variable,
|
|
// or an empty string if not set.
|
|
func envString(key string) string {
|
|
return os.Getenv(key)
|
|
}
|
|
|
|
// envBool returns the value of the named environment variable
|
|
// parsed as a boolean. Returns defaultValue if not set. If the
|
|
// variable is set but cannot be parsed, it returns a wrapped error
|
|
// naming the key and the bad value, so startup fails loudly rather
|
|
// than silently falling back to the default.
|
|
//
|
|
// Parsing is strconv.ParseBool, which accepts 1, t, T, TRUE, true,
|
|
// True, 0, f, F, FALSE, false and False. Anything else — "yes",
|
|
// "on", or a typo like "ture" — is an error rather than a silent
|
|
// false.
|
|
func envBool(key string, defaultValue bool) (bool, error) {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return defaultValue, nil
|
|
}
|
|
|
|
b, err := strconv.ParseBool(v)
|
|
if err != nil {
|
|
return false, fmt.Errorf(
|
|
"invalid boolean for %s: %q: %w", key, v, err,
|
|
)
|
|
}
|
|
|
|
return b, nil
|
|
}
|
|
|
|
// envPositiveInt returns the value of the named environment variable
|
|
// parsed as a positive integer. Returns defaultValue if not set. If
|
|
// the variable is set but cannot be parsed, or parses to less than
|
|
// one, it returns a wrapped error naming the key and the bad value,
|
|
// so startup fails loudly rather than silently falling back to the
|
|
// default.
|
|
func envPositiveInt(
|
|
key string,
|
|
defaultValue int,
|
|
) (int, error) {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return defaultValue, nil
|
|
}
|
|
|
|
i, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return 0, fmt.Errorf(
|
|
"invalid integer for %s: %q: %w", key, v, err,
|
|
)
|
|
}
|
|
|
|
if i < 1 {
|
|
return 0, fmt.Errorf(
|
|
"%w: %s must be at least 1, got %q",
|
|
ErrNonPositiveValue, key, v,
|
|
)
|
|
}
|
|
|
|
return i, nil
|
|
}
|
|
|
|
// envPort returns the value of the named environment variable parsed
|
|
// as a TCP port number. Returns defaultValue if not set. A set value
|
|
// that is unparseable, below 1, or above maxPort is a hard error
|
|
// naming the key and the bad value.
|
|
func envPort(key string, defaultValue int) (int, error) {
|
|
port, err := envPositiveInt(key, defaultValue)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if port > maxPort {
|
|
return 0, fmt.Errorf(
|
|
"%w: %s must be at most %d, got %d",
|
|
ErrInvalidPort, key, maxPort, port,
|
|
)
|
|
}
|
|
|
|
return port, nil
|
|
}
|
|
|
|
// envDuration returns the value of the named environment variable
|
|
// parsed as a Go duration (e.g. "1h", "30m"). Returns defaultValue if
|
|
// not set. If the variable is set but cannot be parsed, it returns a
|
|
// wrapped error naming the key and the bad value, so startup fails
|
|
// loudly rather than silently falling back to the default.
|
|
func envDuration(
|
|
key string,
|
|
defaultValue time.Duration,
|
|
) (time.Duration, error) {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return defaultValue, nil
|
|
}
|
|
|
|
d, err := time.ParseDuration(v)
|
|
if err != nil {
|
|
return 0, fmt.Errorf(
|
|
"invalid duration for %s: %q: %w", key, v, err,
|
|
)
|
|
}
|
|
|
|
return d, nil
|
|
}
|
|
|
|
// envPositiveDuration returns the value of the named environment
|
|
// variable parsed as a Go duration that must be greater than zero.
|
|
// Returns defaultValue if not set. A set value that is unparseable or
|
|
// non-positive is a hard error naming the key and the bad value.
|
|
//
|
|
// This is for durations that reach time.NewTicker, which panics on a
|
|
// non-positive period, in a goroutine started after startup has
|
|
// already reported success. It is deliberately not used for durations
|
|
// where non-positive means "disabled" (SESSION_IDLE_TIMEOUT).
|
|
func envPositiveDuration(
|
|
key string,
|
|
defaultValue time.Duration,
|
|
) (time.Duration, error) {
|
|
d, err := envDuration(key, defaultValue)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if d <= 0 {
|
|
return 0, fmt.Errorf(
|
|
"%w: %s must be greater than zero, got %s",
|
|
ErrNonPositiveValue, key, d,
|
|
)
|
|
}
|
|
|
|
return d, nil
|
|
}
|
|
|
|
// parseCIDR parses one trusted-proxy list entry, which may be a
|
|
// CIDR block ("10.0.0.0/8") or a bare address ("10.0.0.1", treated
|
|
// as a single-host block).
|
|
//
|
|
// Both forms are unmapped, because peer addresses are unmapped
|
|
// before they are matched against the list: an IPv4-mapped prefix
|
|
// left in that form would silently never match.
|
|
func parseCIDR(entry string) (netip.Prefix, error) {
|
|
if strings.Contains(entry, "/") {
|
|
prefix, err := netip.ParsePrefix(entry)
|
|
if err != nil {
|
|
return netip.Prefix{}, err //nolint:wrapcheck // wrapped by caller
|
|
}
|
|
|
|
if addr := prefix.Addr(); addr.Is4In6() &&
|
|
prefix.Bits() >= mappedV4Offset {
|
|
prefix = netip.PrefixFrom(
|
|
addr.Unmap(), prefix.Bits()-mappedV4Offset,
|
|
)
|
|
}
|
|
|
|
return prefix.Masked(), nil
|
|
}
|
|
|
|
addr, err := netip.ParseAddr(entry)
|
|
if err != nil {
|
|
return netip.Prefix{}, err //nolint:wrapcheck // wrapped by caller
|
|
}
|
|
|
|
return netip.PrefixFrom(addr.Unmap(), addr.Unmap().BitLen()), nil
|
|
}
|
|
|
|
// envPrefixList returns the value of the named environment variable
|
|
// parsed as a comma-separated list of CIDR blocks (bare addresses
|
|
// allowed). An unset, empty, or blank value yields an empty list. A
|
|
// set value containing an unparseable entry is a hard error naming
|
|
// the key and the bad entry, so startup fails loudly rather than
|
|
// silently running with a list the operator did not intend.
|
|
func envPrefixList(key string) ([]netip.Prefix, error) {
|
|
v := strings.TrimSpace(os.Getenv(key))
|
|
if v == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
var prefixes []netip.Prefix
|
|
|
|
for entry := range strings.SplitSeq(v, ",") {
|
|
entry = strings.TrimSpace(entry)
|
|
if entry == "" {
|
|
continue
|
|
}
|
|
|
|
prefix, err := parseCIDR(entry)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"%w: %s: %q: %w", ErrInvalidCIDR, key, entry, err,
|
|
)
|
|
}
|
|
|
|
prefixes = append(prefixes, prefix)
|
|
}
|
|
|
|
return prefixes, nil
|
|
}
|
|
|
|
// resolveEnvironment reads WEBHOOKER_ENVIRONMENT, defaulting to
|
|
// dev, and rejects unrecognised values.
|
|
func resolveEnvironment() (string, error) {
|
|
environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
|
|
if environment == "" {
|
|
environment = EnvironmentDev
|
|
}
|
|
|
|
if environment != EnvironmentDev &&
|
|
environment != EnvironmentProd {
|
|
return "", fmt.Errorf(
|
|
"%w: WEBHOOKER_ENVIRONMENT must be '%s' or '%s', got '%s'",
|
|
ErrInvalidEnvironment,
|
|
EnvironmentDev, EnvironmentProd, environment,
|
|
)
|
|
}
|
|
|
|
return environment, nil
|
|
}
|
|
|
|
// loadFromEnv builds a Config from the environment. Every value that
|
|
// needs parsing fails loudly when it is set but unparseable: the
|
|
// documented defaults apply only to variables that are unset (or
|
|
// empty), never as a substitute for a value the operator actually
|
|
// provided.
|
|
func loadFromEnv() (*Config, error) {
|
|
environment, err := resolveEnvironment()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
port, err := envPort("PORT", defaultPort)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
debug, err := envBool("DEBUG", false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
maintenanceMode, err := envBool("MAINTENANCE_MODE", false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
retentionSweepInterval, err := envPositiveDuration(
|
|
"RETENTION_SWEEP_INTERVAL",
|
|
defaultRetentionSweepInterval,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Non-positive is "disabled" here, not invalid, so this stays on
|
|
// envDuration.
|
|
sessionIdleTimeout, err := envDuration(
|
|
"SESSION_IDLE_TIMEOUT",
|
|
defaultSessionIdleTimeout,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
receiverRateLimit, err := envPositiveInt(
|
|
"RECEIVER_RATE_LIMIT",
|
|
defaultReceiverRateLimit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
trustedProxies, err := envPrefixList("TRUSTED_PROXIES")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Config{
|
|
DataDir: envString("DATA_DIR"),
|
|
Debug: debug,
|
|
MaintenanceMode: maintenanceMode,
|
|
Environment: environment,
|
|
MetricsUsername: envString("METRICS_USERNAME"),
|
|
MetricsPassword: envString("METRICS_PASSWORD"),
|
|
Port: port,
|
|
SentryDSN: envString("SENTRY_DSN"),
|
|
RetentionSweepInterval: retentionSweepInterval,
|
|
SessionIdleTimeout: sessionIdleTimeout,
|
|
ReceiverRateLimit: receiverRateLimit,
|
|
TrustedProxies: trustedProxies,
|
|
}, nil
|
|
}
|
|
|
|
// warnSharedRateLimitBucket logs a startup warning when a production
|
|
// deployment leaves TRUSTED_PROXIES empty.
|
|
//
|
|
// With no trusted proxies every rate limiter keys on the connecting
|
|
// peer's address. A production deployment is required to run behind a
|
|
// TLS-terminating reverse proxy, and the peer is then that proxy for
|
|
// every request, so all clients share one bucket per limiter. The
|
|
// login limiter's bucket is the dangerous one: any remote client can
|
|
// keep it full, which denies the only administrative login to
|
|
// everyone until the process restarts.
|
|
//
|
|
// The default of trusting nobody is deliberate — trusting forwarded
|
|
// headers from arbitrary peers lets any client choose its own bucket —
|
|
// so this warns rather than failing startup or changing the key.
|
|
func (c *Config) warnSharedRateLimitBucket(log *slog.Logger) {
|
|
if !c.IsProd() || len(c.TrustedProxies) > 0 {
|
|
return
|
|
}
|
|
|
|
log.Warn(
|
|
"TRUSTED_PROXIES is empty: rate limits key on the "+
|
|
"connecting peer, so behind the reverse proxy a "+
|
|
"production deployment runs behind, every client "+
|
|
"shares one bucket per limit. Any remote client can "+
|
|
"then keep the login limit full and deny the admin "+
|
|
"login, the only administrative path, until restart. "+
|
|
"Set TRUSTED_PROXIES to your reverse proxy's address.",
|
|
"environment", c.Environment,
|
|
"trustedProxies", len(c.TrustedProxies),
|
|
)
|
|
}
|
|
|
|
// New creates a Config by reading environment variables.
|
|
//
|
|
//nolint:revive // lc parameter is required by fx even if unused.
|
|
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|
log := params.Logger.Get()
|
|
|
|
// A set-but-unparseable value anywhere in the environment is a
|
|
// hard error, so fx aborts startup rather than running with a
|
|
// silently substituted default.
|
|
s, err := loadFromEnv()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.log = log
|
|
s.params = ¶ms
|
|
|
|
// Set default DataDir. All SQLite databases (main application
|
|
// DB and per-webhook event DBs) live here. The same default is
|
|
// used regardless of environment; override with DATA_DIR if
|
|
// needed.
|
|
if s.DataDir == "" {
|
|
s.DataDir = "/var/lib/webhooker"
|
|
}
|
|
|
|
if s.Debug {
|
|
params.Logger.EnableDebugLogging()
|
|
}
|
|
|
|
// Log configuration summary (without secrets)
|
|
log.Info("Configuration loaded",
|
|
"environment", s.Environment,
|
|
"port", s.Port,
|
|
"debug", s.Debug,
|
|
"maintenanceMode", s.MaintenanceMode,
|
|
"dataDir", s.DataDir,
|
|
"retentionSweepInterval", s.RetentionSweepInterval.String(),
|
|
"receiverRateLimit", s.ReceiverRateLimit,
|
|
"trustedProxies", len(s.TrustedProxies),
|
|
"hasSentryDSN", s.SentryDSN != "",
|
|
"hasMetricsAuth",
|
|
s.MetricsUsername != "" && s.MetricsPassword != "",
|
|
)
|
|
|
|
s.warnSharedRateLimitBucket(log)
|
|
|
|
return s, nil
|
|
}
|