Rate-limit the public webhook receiver endpoint (closes #64)
All checks were successful
check / check (push) Successful in 5s

The receiver was the one unauthenticated, internet-facing endpoint with no
rate limit, so a misbehaving or hostile sender could flood a webhook
without bound. RECEIVER_RATE_LIMIT (default 120/min) now caps it, keyed on
client IP plus entrypoint path so one entrypoint cannot exhaust another's
budget. Over-limit requests get 429 with Retry-After.

The limiter deliberately does not reuse postRateLimit: that helper is
POST-only and keys on IP alone, whereas the receiver must count every
method. A test locks that property in.

Config parsing follows the fail-loudly idiom: a set-but-unparseable or
non-positive value aborts startup rather than falling back to the default.

Known limitation, tracked in #88: the key still trusts forwarded headers
unconditionally, so the limit is evadable by rotating X-Forwarded-For until
trusted-proxy gating lands.
This commit was merged in pull request #87.
This commit is contained in:
2026-08-11 14:47:21 +02:00
parent d51cd0fd29
commit 84b758b785
6 changed files with 316 additions and 19 deletions

View File

@@ -35,6 +35,13 @@ const (
// 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
@@ -79,6 +86,10 @@ type Config struct {
// 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
params *ConfigParams
log *slog.Logger
}
@@ -263,6 +274,14 @@ func loadFromEnv() (*Config, error) {
return nil, err
}
receiverRateLimit, err := envPositiveInt(
"RECEIVER_RATE_LIMIT",
defaultReceiverRateLimit,
)
if err != nil {
return nil, err
}
return &Config{
DataDir: envString("DATA_DIR"),
Debug: debug,
@@ -274,6 +293,7 @@ func loadFromEnv() (*Config, error) {
SentryDSN: envString("SENTRY_DSN"),
RetentionSweepInterval: retentionSweepInterval,
SessionIdleTimeout: sessionIdleTimeout,
ReceiverRateLimit: receiverRateLimit,
}, nil
}
@@ -314,6 +334,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
"maintenanceMode", s.MaintenanceMode,
"dataDir", s.DataDir,
"retentionSweepInterval", s.RetentionSweepInterval.String(),
"receiverRateLimit", s.ReceiverRateLimit,
"hasSentryDSN", s.SentryDSN != "",
"hasMetricsAuth",
s.MetricsUsername != "" && s.MetricsPassword != "",

View File

@@ -14,6 +14,14 @@ import (
"sneak.berlin/go/webhooker/internal/logger"
)
// Shared subtest names for the env-parsing tables below, which all
// exercise the same three cases against different variables.
const (
caseUnsetUsesDefault = "unset uses default"
caseValidValueParsed = "valid value is parsed"
caseUnparseableFails = "unparseable value fails startup"
)
func TestEnvironmentConfig(t *testing.T) {
tests := []struct {
name string
@@ -130,18 +138,18 @@ func TestRetentionSweepInterval(t *testing.T) {
expected time.Duration
}{
{
name: "unset uses default",
name: caseUnsetUsesDefault,
set: false,
expected: time.Hour,
},
{
name: "valid value is parsed",
name: caseValidValueParsed,
set: true,
value: "15m",
expected: 15 * time.Minute,
},
{
name: "unparseable value fails startup",
name: caseUnparseableFails,
set: true,
value: "not-a-duration",
expectError: true,
@@ -172,7 +180,7 @@ func TestRetentionSweepInterval(t *testing.T) {
}
// expectStartupError asserts that fx refuses to build the app,
// which is what a set-but-unparseable duration must cause.
// which is what a set-but-invalid environment value must cause.
func expectStartupError(t *testing.T) {
t.Helper()
@@ -226,18 +234,18 @@ func TestSessionIdleTimeout(t *testing.T) {
expected time.Duration
}{
{
name: "unset uses default",
name: caseUnsetUsesDefault,
set: false,
expected: 24 * time.Hour,
},
{
name: "valid value is parsed",
name: caseValidValueParsed,
set: true,
value: "30m",
expected: 30 * time.Minute,
},
{
name: "unparseable value fails startup",
name: caseUnparseableFails,
set: true,
value: "not-a-duration",
expectError: true,
@@ -336,3 +344,91 @@ func TestDefaultDataDir(t *testing.T) {
})
}
}
func TestReceiverRateLimit(t *testing.T) {
tests := []struct {
name string
set bool
value string
expectError bool
expected int
}{
{
name: caseUnsetUsesDefault,
set: false,
expected: 120,
},
{
name: caseValidValueParsed,
set: true,
value: "30",
expected: 30,
},
{
name: caseUnparseableFails,
set: true,
value: "not-a-number",
expectError: true,
},
{
name: "zero fails startup",
set: true,
value: "0",
expectError: true,
},
{
name: "negative fails startup",
set: true,
value: "-5",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set {
t.Setenv("RECEIVER_RATE_LIMIT", tt.value)
} else {
require.NoError(t, os.Unsetenv(
"RECEIVER_RATE_LIMIT",
))
}
if tt.expectError {
expectStartupError(t)
} else {
testReceiverRateLimitSuccess(t, tt.expected)
}
})
}
}
func testReceiverRateLimitSuccess(
t *testing.T,
expected int,
) {
t.Helper()
var cfg *config.Config
app := fxtest.New(
t,
fx.Provide(
globals.New,
logger.New,
config.New,
),
fx.Populate(&cfg),
)
require.NoError(t, app.Err())
app.RequireStart()
defer app.RequireStop()
assert.Equal(t, expected, cfg.ReceiverRateLimit)
}