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

@@ -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)
}