All checks were successful
check / check (push) Successful in 3m4s
The SSRF blocklist had no escape hatch, so the thing webhooker is mostly for — taking a public webhook and forwarding it to something on your own network — could not be configured at all. Every private address, Docker sibling and loopback service was permanently unreachable as a delivery destination. ALLOWED_EGRESS_CIDRS (default empty) names blocks that delivery targets may reach despite the default blocklist. It is an allowlist and only ever adds destinations: there is no boolean, and no value disables SSRF protection wholesale. Empty, it adds nothing and the guard permits and refuses the same addresses it did before, save the two spellings named below. A small set of addresses is refused before the allowlist is consulted, so no supplied CIDR opens one — not the exact address, not a supernet, not 0.0.0.0/0 or ::/0. alwaysBlockedNetworks in internal/delivery/ssrf.go is the authoritative list and states the membership criterion in full; it is deliberately not copied here, because a copy drifts out of date. In short: the provider fixes the address, so a host route for it collides with nothing the operator runs, and reaching it discloses credentials or user data. A publicly routable address never qualifies however well it meets both — nothing in this set can be reopened, so blocking one here would leave the operator no escape hatch at all. Those belong in blockedNetworks, which an allowlist can override. Every entry is already inside the default blocklist, which is what makes it unconditional rather than newly blocked, with two exceptions that are the one behaviour change visible when the allowlist is unset: ::a9fe:a9fe and 64:ff9b::a9fe:a9fe, the IPv4-compatible and NAT64 spellings of 169.254.169.254, were reachable before and are refused now. net.IPNet.Contains normalises only the IPv4-mapped form via To4(), so 169.254.0.0/16 never matched those two. The ten it does cover now report a metadata error rather than the generic private-range one. The policy now lives in one function, Guard.checkIP, which both target-creation validation and the delivery dialer call. The two paths previously decided separately, which is how they came to disagree about a destination. The guard is built once from config and injected via fx into both the handlers and the delivery engine, so there is a single instance and a single answer. A set-but-unparseable value aborts startup naming the variable, reusing the existing envPrefixList parser. A non-empty list is logged at startup with the blocks spelled out, not counted, so the hole is visible in the log of any deployment that has one. Tests: an allowlisted loopback CIDR both validates and delivers to a live server (and the same URL still fails without the allowlist); a private address outside the listed block stays refused on both paths; every unconditionally blocked address stays refused on both paths under an allowlist that covers it, and the set itself is pinned entry by entry; public addresses are unaffected either way; and config coverage for parsing, startup abort, and the warning's contents.
648 lines
20 KiB
Go
648 lines
20 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"
|
|
|
|
// DefaultDataDir is where all SQLite databases live when DATA_DIR
|
|
// is unset. The same default applies in every environment.
|
|
DefaultDataDir = "/var/lib/webhooker"
|
|
|
|
// 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")
|
|
|
|
// ErrIncompleteMetricsAuth is returned when exactly one of
|
|
// METRICS_USERNAME and METRICS_PASSWORD carries a value. Neither
|
|
// fallback is acceptable: serving /metrics on the username alone
|
|
// publishes an endpoint whose password is the empty string, and
|
|
// silently leaving it unmounted withholds an endpoint the operator
|
|
// asked for. Half-set is a configuration error, so startup fails.
|
|
var ErrIncompleteMetricsAuth = errors.New(
|
|
"incomplete metrics credentials",
|
|
)
|
|
|
|
//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
|
|
|
|
// AllowedEgressCIDRs is the set of networks a delivery target
|
|
// may reach even though the SSRF guard's default blocklist
|
|
// covers them. It is empty unless ALLOWED_EGRESS_CIDRS is set,
|
|
// and empty means every private/reserved range stays refused.
|
|
//
|
|
// This only ever adds destinations to what the guard would
|
|
// otherwise refuse. The guard itself is always on: there is no
|
|
// setting that disables SSRF protection, and delivery's
|
|
// alwaysBlockedNetworks stays blocked no matter what is listed
|
|
// here. That set is link-local plus the cloud metadata
|
|
// endpoints outside it that disclose credentials or user data
|
|
// at a provider-fixed address; it is not exhaustive of every
|
|
// cloud's metadata address. See alwaysBlockedNetworks for the
|
|
// authoritative list and the criterion it is built from.
|
|
AllowedEgressCIDRs []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
|
|
}
|
|
|
|
// MetricsAuthEnabled reports whether /metrics is served behind basic
|
|
// auth. It is the only answer to that question in the codebase: the
|
|
// route mount, the Prometheus recording middleware and the startup
|
|
// log's hasMetricsAuth field all read this one method, so the log
|
|
// cannot report auth as off while the route is mounted.
|
|
//
|
|
// It requires both credentials rather than the username alone.
|
|
// loadFromEnv already rejects a half-set pair, but a Config built in
|
|
// code bypasses that, and the failure mode this guards is an endpoint
|
|
// mounted with a credential map whose only password is the empty
|
|
// string.
|
|
func (c *Config) MetricsAuthEnabled() bool {
|
|
return c.MetricsUsername != "" && c.MetricsPassword != ""
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// DataDir resolves DATA_DIR, applying DefaultDataDir when it is unset
|
|
// or empty. It is exported so that entry points which must act on the
|
|
// data directory before the fx graph exists — taking the exclusive
|
|
// directory lock, above all — resolve it exactly as Config does.
|
|
func DataDir() string {
|
|
dir := envString("DATA_DIR")
|
|
if dir == "" {
|
|
return DefaultDataDir
|
|
}
|
|
|
|
return dir
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// resolveMetricsAuth reads the /metrics basic-auth credentials and
|
|
// rejects a half-set pair, naming both variables either way. The
|
|
// error carries neither value: the password is a secret.
|
|
func resolveMetricsAuth() (string, string, error) {
|
|
username := envString("METRICS_USERNAME")
|
|
password := envString("METRICS_PASSWORD")
|
|
|
|
if (username == "") == (password == "") {
|
|
return username, password, nil
|
|
}
|
|
|
|
set, empty := "METRICS_USERNAME", "METRICS_PASSWORD"
|
|
if username == "" {
|
|
set, empty = empty, set
|
|
}
|
|
|
|
return "", "", fmt.Errorf(
|
|
"%w: %s is set but %s is empty; METRICS_USERNAME and "+
|
|
"METRICS_PASSWORD must both be set to serve /metrics, "+
|
|
"or both be empty to leave it unmounted",
|
|
ErrIncompleteMetricsAuth, set, empty,
|
|
)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
metricsUsername, metricsPassword, err := resolveMetricsAuth()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Config{
|
|
DataDir: DataDir(),
|
|
Debug: debug,
|
|
MaintenanceMode: maintenanceMode,
|
|
Environment: environment,
|
|
MetricsUsername: metricsUsername,
|
|
MetricsPassword: metricsPassword,
|
|
Port: port,
|
|
SentryDSN: envString("SENTRY_DSN"),
|
|
RetentionSweepInterval: retentionSweepInterval,
|
|
SessionIdleTimeout: sessionIdleTimeout,
|
|
ReceiverRateLimit: receiverRateLimit,
|
|
TrustedProxies: trustedProxies,
|
|
AllowedEgressCIDRs: allowedEgressCIDRs,
|
|
}, nil
|
|
}
|
|
|
|
// PrefixStrings renders a prefix list as its CIDR strings, for
|
|
// logging a list an operator has to be able to read back.
|
|
func PrefixStrings(prefixes []netip.Prefix) []string {
|
|
out := make([]string, 0, len(prefixes))
|
|
|
|
for _, prefix := range prefixes {
|
|
out = append(out, prefix.String())
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// warnEgressAllowlist logs the effective ALLOWED_EGRESS_CIDRS
|
|
// whenever it is non-empty.
|
|
//
|
|
// It prints the blocks themselves rather than a count, because
|
|
// this is the one setting that lets a delivery target reach the
|
|
// host's own network: an operator reading the startup log has to
|
|
// be able to see exactly which hole is open. Silence means the
|
|
// list is empty and the SSRF guard is refusing every
|
|
// private/reserved range, which is the default.
|
|
func (c *Config) warnEgressAllowlist(log *slog.Logger) {
|
|
if len(c.AllowedEgressCIDRs) == 0 {
|
|
return
|
|
}
|
|
|
|
log.Warn(
|
|
"ALLOWED_EGRESS_CIDRS lets delivery targets reach these "+
|
|
"otherwise-blocked private/reserved networks. Anyone "+
|
|
"who can create a delivery target can now make this "+
|
|
"process issue requests into them, and read back the "+
|
|
"response. Link-local and the known cloud instance "+
|
|
"metadata endpoints outside it stay blocked "+
|
|
"regardless of what is listed here.",
|
|
"allowedEgressCIDRs",
|
|
strings.Join(PrefixStrings(c.AllowedEgressCIDRs), ","),
|
|
)
|
|
}
|
|
|
|
// warnSharedRateLimitBucket logs a startup warning whenever
|
|
// TRUSTED_PROXIES is empty, in any environment.
|
|
//
|
|
// With no trusted proxies every rate limiter keys on the connecting
|
|
// peer's address. Whether that is harmless or dangerous depends on
|
|
// what is in front of the process, which this code cannot observe:
|
|
// with nothing in front, the peer is the client and the limits are
|
|
// per-client as intended; behind a reverse proxy the peer is the proxy
|
|
// for every request, so all clients share one bucket per limiter.
|
|
//
|
|
// The login endpoint no longer spends budget on arrival — it verifies
|
|
// credentials first and charges only failures — so a shared bucket
|
|
// cannot deny the operator a correct password. What it does collapse
|
|
// is the failure counting: one client's wrong passwords throttle
|
|
// everyone else's wrong passwords, and the receiver's limits become
|
|
// service-wide ceilings.
|
|
//
|
|
// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT. That
|
|
// variable defaults to dev, so gating on it would silence the warning
|
|
// for exactly the operator who forgot to configure the deployment —
|
|
// the case it exists to catch.
|
|
//
|
|
// 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 len(c.TrustedProxies) > 0 {
|
|
return
|
|
}
|
|
|
|
log.Warn(
|
|
"TRUSTED_PROXIES is empty: every rate limit keys on the "+
|
|
"connecting peer's address. With nothing proxying to "+
|
|
"this process that is the client itself and the limits "+
|
|
"are per-client as intended. Behind a reverse proxy the "+
|
|
"peer is the proxy on every request, so all clients "+
|
|
"share one bucket per limit: the receiver limits become "+
|
|
"service-wide ceilings, and one client's failed logins "+
|
|
"throttle every other client's failed logins — a "+
|
|
"correct password still gets in. If anything proxies to "+
|
|
"this process, set TRUSTED_PROXIES to its 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
|
|
|
|
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(),
|
|
// Logged because a perfectly valid non-positive value here
|
|
// disables idle expiry entirely, and that is worth showing
|
|
// back to the operator.
|
|
"sessionIdleTimeout", s.SessionIdleTimeout.String(),
|
|
"receiverRateLimit", s.ReceiverRateLimit,
|
|
"trustedProxies", len(s.TrustedProxies),
|
|
"allowedEgressCIDRs", len(s.AllowedEgressCIDRs),
|
|
"hasSentryDSN", s.SentryDSN != "",
|
|
"hasMetricsAuth", s.MetricsAuthEnabled(),
|
|
)
|
|
|
|
s.warnSharedRateLimitBucket(log)
|
|
s.warnEgressAllowlist(log)
|
|
|
|
return s, nil
|
|
}
|