1 Commits

Author SHA1 Message Date
641a8ebd66 Update golangci-lint to v2.12.2 with canonical config
All checks were successful
check / check (push) Successful in 2m51s
Bump the golangci-lint Docker image pin in Dockerfile and the
release-archive sha256 pins in script/bootstrap from 2.11.3 to
2.12.2, and replace .golangci.yml with the canonical config. The
canonical config moves lll/funlen/cyclop/dupl settings from the
top-level linters-settings key (ignored by the v2 schema) to
linters.settings, so those thresholds now actually apply.

Fix all findings the newly applied thresholds surfaced:

- lll: wrap or shorten seven over-length lines (struct tag
  comments, test logger construction, a func signature, and a
  nosec comment)
- goconst: use http.MethodPost/http.MethodPut and new shared
  constants for repeated test strings; add tmplKeyError and
  tmplKeyWebhook constants for template data keys in handlers
- dupl: merge buildHTTPTargetConfig and buildSlackTargetConfig
  into a parameterized buildURLTargetConfig; drop the duplicate
  iWebhookDB test helper in favor of testWebhookDB; extract
  shared helpers in middleware and session tests
2026-08-07 16:51:42 +00:00
27 changed files with 255 additions and 589 deletions

View File

@@ -1,5 +1,9 @@
version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run:
timeout: 5m
modules-download-mode: readonly
@@ -14,8 +18,7 @@ linters:
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
linters-settings:
settings:
lll:
line-length: 88
funlen:
@@ -27,6 +30,5 @@ linters-settings:
threshold: 100
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

View File

@@ -1,8 +1,8 @@
# Lint stage
# golangci/golangci-lint:v2.11.3 (Debian-based), 2026-03-17
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
# Using Debian-based image because mattn/go-sqlite3 (CGO) does not
# compile on Alpine musl (off64_t is a glibc type).
FROM golangci/golangci-lint:v2.11.3@sha256:e838e8ab68aaefe83e2408691510867ade9329c0e0b895a3fb35eb93d1c2a4ba AS lint
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*

View File

@@ -92,7 +92,6 @@ TTY detection, and security headers are always applied.
| `METRICS_USERNAME` | Basic auth username for `/metrics` | `""` |
| `METRICS_PASSWORD` | Basic auth password for `/metrics` | `""` |
| `SENTRY_DSN` | Sentry error reporting DSN | `""` |
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint | `120` |
On first startup, webhooker automatically generates a cryptographically
secure session encryption key and stores it in the database. This key
@@ -677,24 +676,17 @@ just delayed until the target is healthy again.
### Rate Limiting
Global blanket rate limiting middleware (e.g., a per-IP throttle shared
with the web UI) **must not** apply to webhook receiver endpoints.
Webhook endpoints receive automated traffic from external services at
unpredictable rates, and blanket limits shared with other routes would
cause legitimate deliveries to be dropped.
Global rate limiting middleware (e.g., per-IP throttling applied at the
router level) **must not** apply to webhook receiver endpoints. Webhook
endpoints receive automated traffic from external services at
unpredictable rates, and blanket rate limits would cause legitimate
deliveries to be dropped.
The receiver instead has its own dedicated abuse limit, scoped to the
`/webhook/{uuid}` route only and keyed per client IP per entrypoint: one
misbehaving sender is throttled without affecting other senders of the
same entrypoint or the same sender's other entrypoints. The limit is
`RECEIVER_RATE_LIMIT` requests per minute (default 120, generous for
legitimate webhook senders). Requests over the limit receive HTTP 429
with a `Retry-After` header. A set-but-invalid `RECEIVER_RATE_LIMIT`
value aborts startup rather than silently falling back to the default.
Finer-grained per-webhook rate limits (configured in the web UI and
enforced in the webhook handler) can layer on top of this env-level
abuse limit later; they are tracked as future work.
Instead, each webhook has its own individually configurable rate limit,
applied within the webhook handler itself. By default, no rate limit is
applied — webhook endpoints accept traffic as fast as it arrives. Rate
limits can be configured per-webhook when needed (e.g., to protect
against a misbehaving sender).
### API Endpoints

36
TODO.md
View File

@@ -10,27 +10,29 @@
# Status
pre-1.0. No git tags exist. main (81413c5) is a working webhook proxy
pre-1.0. No git tags exist. main (afe88c6) is a working webhook proxy
with auth, CSRF/SSRF protections, login rate limiting, Slack target,
policy compliance (#6), pinned lint tooling (#55), a per-webhook event
retention reaper (#63), and delivery targets behind a Target interface
(#77). Work is tracked as Gitea issues (the authoritative TODO); this
file is a summary. Note: TODO.md was deliberately deleted from this
repo in f9a9569 (2026-03-01, #6); its content was folded into the
README TODO section, which this draft reconstructs as of 2026-07-06.
policy compliance (#6), and pinned lint tooling (#55). Note: TODO.md was
deliberately deleted from this repo in f9a9569 (2026-03-01, #6); its
content was folded into the README TODO section, which this draft
reconstructs as of 2026-07-06.
# Next Step
Manual event redelivery from the web UI (replay is a core promised
capability in the README rationale).
Implement automatic event retention cleanup based on retention_days: a
periodic maintenance job that deletes Events, Deliveries, and
DeliveryResults older than the parent webhook's retention_days from each
per-webhook event database. The field exists on the Webhook model and
the README promises the behavior, but nothing enforces it, so event
databases currently grow without bound.
# Completed Steps
- 2026-08-07 Rate-limit the public webhook receiver per client IP per
entrypoint, env-configurable with fail-loud parsing (#64)
- 2026-08-07 Per-webhook event retention reaper (#63); NoCache
middleware for authenticated pages (#61); Target interface refactor
(#77)
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`),
adopt the canonical `.golangci.yml` (v2 `linters.settings` layout so
`lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix
all newly surfaced lint findings
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section
- 2026-03-25 pin golangci-lint Docker image for linting (#55)
@@ -54,10 +56,12 @@ capability in the README rationale).
# Future Steps
- Manual event redelivery from the web UI (replay is a core promised
capability in the README rationale)
- Delivery status and retry management UI
- Per-webhook rate limiting in the receiver handler (per-webhook config
plus handler enforcement, layered on the env-level receiver limit
from #64; global limits must not apply to receiver endpoints)
plus handler enforcement; global limits must not apply to receiver
endpoints)
- Webhook signature verification for GitHub and Stripe HMAC formats
- API key authentication for programmatic access (APIKey model exists;
Bearer token middleware does not)

View File

@@ -31,23 +31,12 @@ const (
// defaultRetentionSweepInterval is how often the retention
// reaper deletes events older than each webhook's RetentionDays.
defaultRetentionSweepInterval = 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
)
// 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")
//nolint:revive // ConfigParams is a standard fx naming convention.
type ConfigParams struct {
fx.In
@@ -71,10 +60,6 @@ type Config struct {
// RetentionSweepInterval is how often the retention reaper runs.
RetentionSweepInterval 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
}
@@ -119,38 +104,6 @@ func envInt(key string, defaultValue int) int {
return defaultValue
}
// 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
}
// 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
@@ -175,35 +128,27 @@ func envDuration(
return d, 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
}
// 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()
environment, err := resolveEnvironment()
if err != nil {
return nil, err
// Determine environment from WEBHOOKER_ENVIRONMENT env var,
// default to dev
environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
if environment == "" {
environment = EnvironmentDev
}
// Validate environment
if environment != EnvironmentDev &&
environment != EnvironmentProd {
return nil, fmt.Errorf(
"%w: WEBHOOKER_ENVIRONMENT must be '%s' or '%s', got '%s'",
ErrInvalidEnvironment,
EnvironmentDev, EnvironmentProd, environment,
)
}
// Parse the retention sweep interval; a set-but-unparseable value
@@ -217,17 +162,6 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
return nil, err
}
// Parse the receiver rate limit; a set-but-unparseable or
// non-positive value is a hard error so fx aborts startup
// rather than silently using the default.
receiverRateLimit, err := envPositiveInt(
"RECEIVER_RATE_LIMIT",
defaultReceiverRateLimit,
)
if err != nil {
return nil, err
}
// Load configuration values from environment variables
s := &Config{
DataDir: envString("DATA_DIR"),
@@ -239,7 +173,6 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
Port: envInt("PORT", defaultPort),
SentryDSN: envString("SENTRY_DSN"),
RetentionSweepInterval: retentionSweepInterval,
ReceiverRateLimit: receiverRateLimit,
log: log,
params: &params,
}
@@ -264,7 +197,6 @@ 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

@@ -258,109 +258,3 @@ func TestDefaultDataDir(t *testing.T) {
})
}
}
func TestReceiverRateLimit(t *testing.T) {
tests := []struct {
name string
set bool
value string
expectError bool
expected int
}{
{
name: "unset uses default",
set: false,
expected: 120,
},
{
name: "valid value is parsed",
set: true,
value: "30",
expected: 30,
},
{
name: "unparseable value fails startup",
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 {
testReceiverRateLimitError(t)
} else {
testReceiverRateLimitSuccess(t, tt.expected)
}
})
}
}
func testReceiverRateLimitError(t *testing.T) {
t.Helper()
var cfg *config.Config
app := fx.New(
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
config.New,
),
fx.Populate(&cfg),
)
assert.Error(t, app.Err())
}
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)
}

View File

@@ -11,6 +11,15 @@ import (
"sneak.berlin/go/webhooker/internal/logger"
)
const (
// testAppname is the Globals.Appname used in tests.
testAppname = "webhooker-test"
// testVersion is the Globals.Version used in tests.
testVersion = "test"
// testContentType is the event content type used in tests.
testContentType = "application/json"
)
func setupTestDB(
t *testing.T,
) (*database.Database, *fxtest.Lifecycle) {
@@ -19,8 +28,8 @@ func setupTestDB(
lc := fxtest.NewLifecycle(t)
g := &globals.Globals{
Appname: "webhooker-test",
Version: "test",
Appname: testAppname,
Version: testVersion,
}
l, err := logger.New(

View File

@@ -5,7 +5,10 @@ type Entrypoint struct {
BaseModel
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
Path string `gorm:"uniqueIndex;not null" json:"path"` // URL path for this entrypoint
// Path is the URL path for this entrypoint.
Path string `gorm:"uniqueIndex;not null" json:"path"`
Description string `json:"description"`
Active bool `gorm:"default:true" json:"active"`

View File

@@ -23,7 +23,8 @@ type Target struct {
// Configuration fields (JSON stored based on type)
Config string `gorm:"type:text" json:"config"` // JSON configuration
// For HTTP targets (max_retries=0 means fire-and-forget, >0 enables retries with backoff)
// For HTTP targets (max_retries=0 means fire-and-forget,
// >0 enables retries with backoff)
MaxRetries int `json:"maxRetries,omitempty"`
MaxQueueSize int `json:"maxQueueSize,omitempty"`

View File

@@ -7,7 +7,9 @@ type Webhook struct {
UserID string `gorm:"type:uuid;not null" json:"userId"`
Name string `gorm:"not null" json:"name"`
Description string `json:"description"`
RetentionDays int `gorm:"default:30" json:"retentionDays"` // Days to retain events
// RetentionDays is the number of days to retain events.
RetentionDays int `gorm:"default:30" json:"retentionDays"`
// Relations
User User `json:"user,omitzero"`

View File

@@ -2,6 +2,7 @@ package database_test
import (
"context"
"net/http"
"testing"
"time"
@@ -30,8 +31,8 @@ func setupRetentionTest(t *testing.T) *retentionTestEnv {
lc := fxtest.NewLifecycle(t)
g := &globals.Globals{
Appname: "webhooker-test",
Version: "test",
Appname: testAppname,
Version: testVersion,
}
l, err := logger.New(lc, logger.LoggerParams{Globals: g})
@@ -117,9 +118,9 @@ func seedEventChain(
event := &database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: "POST",
Method: http.MethodPost,
Body: `{"seed": true}`,
ContentType: "application/json",
ContentType: testContentType,
}
event.CreatedAt = createdAt
require.NoError(t, db.Create(event).Error)

View File

@@ -14,7 +14,10 @@ import (
func NewTestDatabase(db *gorm.DB) *Database {
return &Database{
db: db,
log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})),
log: slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
)),
}
}
@@ -23,6 +26,9 @@ func NewTestDatabase(db *gorm.DB) *Database {
func NewTestWebhookDBManager(dataDir string) *WebhookDBManager {
return &WebhookDBManager{
dataDir: dataDir,
log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})),
log: slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
)),
}
}

View File

@@ -2,6 +2,7 @@ package database_test
import (
"context"
"net/http"
"os"
"path/filepath"
"testing"
@@ -25,8 +26,8 @@ func setupTestWebhookDBManager(
lc := fxtest.NewLifecycle(t)
g := &globals.Globals{
Appname: "webhooker-test",
Version: "test",
Appname: testAppname,
Version: testVersion,
}
l, err := logger.New(
@@ -83,10 +84,10 @@ func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
event := &database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: "POST",
Method: http.MethodPost,
Headers: `{"Content-Type":["application/json"]}`,
Body: `{"test": true}`,
ContentType: "application/json",
ContentType: testContentType,
}
require.NoError(t, db.Create(event).Error)
assert.NotEmpty(t, event.ID)
@@ -99,7 +100,7 @@ func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
db.First(&readEvent, "id = ?", event.ID).Error,
)
assert.Equal(t, webhookID, readEvent.WebhookID)
assert.Equal(t, "POST", readEvent.Method)
assert.Equal(t, http.MethodPost, readEvent.Method)
assert.Equal(t, `{"test": true}`, readEvent.Body)
}
@@ -123,9 +124,9 @@ func TestWebhookDBManager_DeleteDB(t *testing.T) {
event := &database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: "POST",
Method: http.MethodPost,
Body: `{"test": true}`,
ContentType: "application/json",
ContentType: testContentType,
}
require.NoError(t, db.Create(event).Error)
@@ -196,10 +197,10 @@ func seedDeliveryWorkflow(
event := &database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: "POST",
Method: http.MethodPost,
Headers: `{"Content-Type":["application/json"]}`,
Body: `{"payload": "test"}`,
ContentType: "application/json",
ContentType: testContentType,
}
require.NoError(t, db.Create(event).Error)
@@ -231,7 +232,7 @@ func verifyPendingDeliveries(
)
require.Len(t, pending, 1)
assert.Equal(t, event.ID, pending[0].EventID)
assert.Equal(t, "POST", pending[0].Event.Method)
assert.Equal(t, http.MethodPost, pending[0].Event.Method)
}
func completeDelivery(
@@ -303,16 +304,16 @@ func TestWebhookDBManager_MultipleWebhooks(t *testing.T) {
event1 := &database.Event{
WebhookID: webhook1,
EntrypointID: uuid.New().String(),
Method: "POST",
Method: http.MethodPost,
Body: `{"webhook": 1}`,
ContentType: "application/json",
ContentType: testContentType,
}
event2 := &database.Event{
WebhookID: webhook2,
EntrypointID: uuid.New().String(),
Method: "PUT",
Method: http.MethodPut,
Body: `{"webhook": 2}`,
ContentType: "application/json",
ContentType: testContentType,
}
require.NoError(t, db1.Create(event1).Error)

View File

@@ -126,36 +126,6 @@ func iHTTPConfig(url string) string {
return string(data)
}
func iWebhookDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(
t.TempDir(), "events-test.db",
)
dsn := fmt.Sprintf(
"file:%s?cache=shared&mode=rwc", dbPath,
)
sqlDB, err := sql.Open("sqlite", dsn)
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
)
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&database.Event{},
&database.Delivery{},
&database.DeliveryResult{},
))
return db
}
func iEngine(
t *testing.T, workers int,
) *delivery.Engine {
@@ -182,10 +152,10 @@ func iSeedEvent(
event := database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: "POST",
Method: http.MethodPost,
Headers: `{}`,
Body: body,
ContentType: "application/json",
ContentType: testContentType,
}
require.NoError(t, db.Create(&event).Error)
@@ -935,7 +905,7 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
func TestDeliverHTTP_TargetTimeout(t *testing.T) {
t.Parallel()
db := iWebhookDB(t)
db := testWebhookDB(t)
e := iEngine(t, 1)
ts := httptest.NewServer(
@@ -987,10 +957,10 @@ func iSeedEventAndDelivery(
event := database.Event{
WebhookID: uuid.New().String(),
EntrypointID: uuid.New().String(),
Method: "POST",
Method: http.MethodPost,
Headers: `{"Content-Type":["application/json"]}`,
Body: body,
ContentType: "application/json",
ContentType: testContentType,
}
require.NoError(t, db.Create(&event).Error)
@@ -1067,7 +1037,7 @@ func iAssertResultFailed(
func TestDeliverHTTP_InvalidConfig(t *testing.T) {
t.Parallel()
db := iWebhookDB(t)
db := testWebhookDB(t)
e := iEngine(t, 1)
event, del := iSeedEventAndDelivery(

View File

@@ -27,6 +27,9 @@ import (
"sneak.berlin/go/webhooker/internal/delivery"
)
// testContentType is the event content type used in tests.
const testContentType = "application/json"
func testWebhookDB(t *testing.T) *gorm.DB {
t.Helper()
@@ -94,10 +97,10 @@ func seedEvent(
event := database.Event{
WebhookID: uuid.New().String(),
EntrypointID: uuid.New().String(),
Method: "POST",
Method: http.MethodPost,
Headers: `{"Content-Type":["application/json"]}`,
Body: body,
ContentType: "application/json",
ContentType: testContentType,
}
require.NoError(t, db.Create(&event).Error)
@@ -1117,10 +1120,10 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
}
event := &database.Event{
Method: "POST",
Method: http.MethodPost,
Headers: `{"X-Custom":["value1"],"Content-Type":["application/json"]}`,
Body: `{"test":true}`,
ContentType: "application/json",
ContentType: testContentType,
}
statusCode, _, _, err := e.ExportDoHTTPRequest(
@@ -1142,7 +1145,7 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
)
assert.Equal(t,
"application/json",
testContentType,
receivedHeaders.Get("Content-Type"),
)
@@ -1289,8 +1292,8 @@ func TestFormatSlackMessage_JSONBody(t *testing.T) {
t.Parallel()
event := &database.Event{
Method: "POST",
ContentType: "application/json",
Method: http.MethodPost,
ContentType: testContentType,
Body: `{"action":"push",` +
`"repo":"test/repo",` +
`"ref":"refs/heads/main"}`,
@@ -1315,7 +1318,7 @@ func TestFormatSlackMessage_NonJSONBody(t *testing.T) {
t.Parallel()
event := &database.Event{
Method: "POST",
Method: http.MethodPost,
ContentType: "text/plain",
Body: "hello world plain text",
}
@@ -1338,8 +1341,8 @@ func TestFormatSlackMessage_EmptyBody(t *testing.T) {
t.Parallel()
event := &database.Event{
Method: "POST",
ContentType: "application/json",
Method: http.MethodPost,
ContentType: testContentType,
Body: "",
}
event.CreatedAt = time.Date(
@@ -1367,8 +1370,8 @@ func TestFormatSlackMessage_LargeJSONTruncated(
require.NoError(t, err)
event := &database.Event{
Method: "POST",
ContentType: "application/json",
Method: http.MethodPost,
ContentType: testContentType,
Body: string(largeJSON),
}
event.CreatedAt = time.Date(
@@ -1697,7 +1700,7 @@ func assertLogLineComplete(
"log line must contain the webhook id",
)
assert.Contains(t, out, "application/json",
assert.Contains(t, out, testContentType,
"log line must contain the content type",
)
}

View File

@@ -495,5 +495,5 @@ func applyRequestHeaders(
func executeHTTPRequest(
client *http.Client, req *http.Request,
) (*http.Response, error) {
return client.Do(req) //#nosec G704 -- URL validated by parseHTTPConfig/parseSlackConfig and SSRF-safe transport
return client.Do(req) //#nosec G704 -- validated URL, SSRF-safe transport
}

View File

@@ -19,7 +19,7 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
// Render login page
data := map[string]any{
"Error": "",
tmplKeyError: "",
}
h.renderTemplate(w, r, "login.html", data)
@@ -86,7 +86,7 @@ func (h *Handlers) renderLoginError(
status int,
) {
data := map[string]any{
"Error": msg,
tmplKeyError: msg,
}
w.WriteHeader(status)

View File

@@ -13,12 +13,16 @@ func (s *Handlers) RenderTemplateForTest(
s.renderTemplate(w, r, pageTemplate, data)
}
// BuildSlackTargetConfigForTest exposes buildSlackTargetConfig
// for use in the handlers_test package.
// BuildSlackTargetConfigForTest exposes buildURLTargetConfig
// with the Slack target parameters for use in the
// handlers_test package.
func (s *Handlers) BuildSlackTargetConfigForTest(
w http.ResponseWriter,
r *http.Request,
targetURL string,
) (string, error) {
return s.buildSlackTargetConfig(w, r, targetURL)
return s.buildURLTargetConfig(
w, r, targetURL, "webhookUrl",
"Webhook URL is required for Slack targets",
)
}

View File

@@ -30,6 +30,11 @@ const (
defaultRetentionDays = 30
// paginationPerPage is the number of items per page.
paginationPerPage = 25
// tmplKeyError is the template data key for an error message.
tmplKeyError = "Error"
// tmplKeyWebhook is the template data key for a webhook.
tmplKeyWebhook = "Webhook"
)
// errInvalidPassword is returned when a password does not match.

View File

@@ -106,7 +106,7 @@ func (h *Handlers) buildWebhookListItems(
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := map[string]any{
"Error": "",
tmplKeyError: "",
}
h.renderTemplate(w, r, "sources_new.html", data)
@@ -145,7 +145,7 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
if name == "" {
data := map[string]any{
"Error": "Name is required",
tmplKeyError: "Name is required",
}
w.WriteHeader(http.StatusBadRequest)
@@ -315,7 +315,7 @@ func (h *Handlers) renderSourceDetail(
}
data := map[string]any{
"Webhook": webhook,
tmplKeyWebhook: webhook,
"Entrypoints": entrypoints,
"Targets": targets,
"Events": events,
@@ -351,8 +351,8 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
}
data := map[string]any{
"Webhook": webhook,
"Error": "",
tmplKeyWebhook: webhook,
tmplKeyError: "",
}
h.renderTemplate(w, r, "source_edit.html", data)
@@ -415,8 +415,8 @@ func (h *Handlers) applyWebhookEdit(
name := r.FormValue("name")
if name == "" {
data := map[string]any{
"Webhook": *webhook,
"Error": "Name is required",
tmplKeyWebhook: *webhook,
tmplKeyError: "Name is required",
}
w.WriteHeader(http.StatusBadRequest)
@@ -589,7 +589,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
}
data := map[string]any{
"Webhook": webhook,
tmplKeyWebhook: webhook,
"Events": evts,
"Page": page,
"TotalPages": totalPages,
@@ -900,9 +900,15 @@ func (h *Handlers) buildTargetConfig(
) (string, error) {
switch targetType {
case database.TargetTypeHTTP:
return h.buildHTTPTargetConfig(w, r, targetURL)
return h.buildURLTargetConfig(
w, r, targetURL, "url",
"URL is required for HTTP targets",
)
case database.TargetTypeSlack:
return h.buildSlackTargetConfig(w, r, targetURL)
return h.buildURLTargetConfig(
w, r, targetURL, "webhookUrl",
"Webhook URL is required for Slack targets",
)
case database.TargetTypeDatabase, database.TargetTypeLog:
return "", nil
default:
@@ -915,16 +921,18 @@ func (h *Handlers) buildTargetConfig(
}
}
// buildHTTPTargetConfig builds config JSON for an HTTP target.
func (h *Handlers) buildHTTPTargetConfig(
// buildURLTargetConfig builds config JSON for a target whose
// configuration is a single SSRF-validated URL stored under
// configKey. missingMsg is the error shown when no URL is given.
func (h *Handlers) buildURLTargetConfig(
w http.ResponseWriter,
r *http.Request,
targetURL string,
targetURL, configKey, missingMsg string,
) (string, error) {
if targetURL == "" {
http.Error(
w,
"URL is required for HTTP targets",
missingMsg,
http.StatusBadRequest,
)
@@ -949,56 +957,7 @@ func (h *Handlers) buildHTTPTargetConfig(
return "", err
}
cfg := map[string]any{"url": targetURL}
configBytes, err := json.Marshal(cfg)
if err != nil {
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return "", err
}
return string(configBytes), nil
}
// buildSlackTargetConfig builds config JSON for a Slack target.
func (h *Handlers) buildSlackTargetConfig(
w http.ResponseWriter,
r *http.Request,
targetURL string,
) (string, error) {
if targetURL == "" {
http.Error(
w,
"Webhook URL is required for Slack targets",
http.StatusBadRequest,
)
return "", errMissingURL
}
err := delivery.ValidateTargetURL(
r.Context(), targetURL,
)
if err != nil {
h.log.Warn(
"target URL blocked by SSRF protection",
"url", targetURL,
"error", err,
)
http.Error(
w,
"Invalid target URL: "+err.Error(),
http.StatusBadRequest,
)
return "", err
}
cfg := map[string]any{"webhookUrl": targetURL}
cfg := map[string]any{configKey: targetURL}
configBytes, err := json.Marshal(cfg)
if err != nil {

View File

@@ -484,8 +484,13 @@ func metricsAuthMiddleware(
return middleware.NewForTest(log, cfg, sessManager)
}
func TestMetricsAuth_ValidCredentials(t *testing.T) {
t.Parallel()
// runMetricsAuthRequest sends a GET /metrics request with the
// given basic-auth password through MetricsAuth and reports
// whether the wrapped handler ran plus the recorded response.
func runMetricsAuthRequest(
t *testing.T, password string,
) (bool, *httptest.ResponseRecorder) {
t.Helper()
m := metricsAuthMiddleware(t)
@@ -503,12 +508,20 @@ func TestMetricsAuth_ValidCredentials(t *testing.T) {
context.Background(),
http.MethodGet, "/metrics", nil,
)
req.SetBasicAuth("admin", "secret")
req.SetBasicAuth("admin", password)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return called, w
}
func TestMetricsAuth_ValidCredentials(t *testing.T) {
t.Parallel()
called, w := runMetricsAuthRequest(t, "secret")
assert.True(
t, called,
"handler should be called with valid basic auth",
@@ -519,27 +532,7 @@ func TestMetricsAuth_ValidCredentials(t *testing.T) {
func TestMetricsAuth_InvalidCredentials(t *testing.T) {
t.Parallel()
m := metricsAuthMiddleware(t)
var called bool
handler := m.MetricsAuth()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
},
))
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/metrics", nil,
)
req.SetBasicAuth("admin", "wrong-password")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
called, w := runMetricsAuthRequest(t, "wrong-password")
assert.False(
t, called,

View File

@@ -14,11 +14,6 @@ const (
// loginRateInterval is the time window for the rate limit.
loginRateInterval = 1 * time.Minute
// receiverRateInterval is the time window for the webhook
// receiver rate limit. The configured limit is expressed in
// requests per minute.
receiverRateInterval = 1 * time.Minute
)
// LoginRateLimit returns middleware that enforces per-IP rate
@@ -67,37 +62,3 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
})
}
}
// ReceiverRateLimit returns middleware that rate-limits the
// public webhook receiver endpoint per client IP per request
// path (the path contains the entrypoint UUID, so each sender
// is limited per entrypoint without affecting other senders or
// other entrypoints). The limit is Config.ReceiverRateLimit
// requests per minute. Requests over the limit receive a 429;
// httprate adds the Retry-After header (RFC 6585). IP
// extraction honours X-Forwarded-For, X-Real-IP, and
// True-Client-IP headers for reverse-proxy setups.
func (m *Middleware) ReceiverRateLimit() func(http.Handler) http.Handler {
return httprate.Limit(
m.params.Config.ReceiverRateLimit,
receiverRateInterval,
httprate.WithKeyFuncs(
httprate.KeyByRealIP,
httprate.KeyByEndpoint,
),
httprate.WithLimitHandler(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
m.log.Warn(
"webhook receiver rate limit exceeded",
"path", r.URL.Path,
)
http.Error(
w,
"Too many requests. "+
"Please slow down.",
http.StatusTooManyRequests,
)
},
)),
)
}

View File

@@ -2,10 +2,8 @@ package middleware_test
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/stretchr/testify/assert"
@@ -147,94 +145,3 @@ func TestLoginRateLimit_IndependentPerIP(t *testing.T) {
"different IP should not be affected",
)
}
// receiverLimitedHandler builds a ReceiverRateLimit-wrapped
// handler with the given per-minute limit.
func receiverLimitedHandler(
t *testing.T, limit int,
) http.Handler {
t.Helper()
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
m := middleware.NewForTest(
log,
&config.Config{ReceiverRateLimit: limit},
nil,
)
return m.ReceiverRateLimit()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
))
}
// receiverPost sends one POST to the handler from the given IP
// and path and returns the recorder.
func receiverPost(
handler http.Handler, ip, path string,
) *httptest.ResponseRecorder {
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, path, nil,
)
req.RemoteAddr = ip
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
t.Parallel()
const limit = 3
handler := receiverLimitedHandler(t, limit)
// The first limit requests from one IP to one entrypoint
// pass.
for i := range limit {
w := receiverPost(
handler, "9.9.9.9:1234", "/webhook/uuid-a",
)
assert.Equal(
t, http.StatusOK, w.Code,
"request %d should pass", i,
)
}
// The next request over the limit is rejected with a 429
// carrying a Retry-After header.
w := receiverPost(
handler, "9.9.9.9:1234", "/webhook/uuid-a",
)
assert.Equal(t, http.StatusTooManyRequests, w.Code)
assert.NotEmpty(
t, w.Header().Get("Retry-After"),
"429 must carry a Retry-After header",
)
// The same IP is not limited on a different entrypoint.
w = receiverPost(
handler, "9.9.9.9:1234", "/webhook/uuid-b",
)
assert.Equal(
t, http.StatusOK, w.Code,
"a different entrypoint must not be affected",
)
// A different IP is not limited on the same entrypoint.
w = receiverPost(
handler, "8.8.8.8:1234", "/webhook/uuid-a",
)
assert.Equal(
t, http.StatusOK, w.Code,
"a different client IP must not be affected",
)
}

View File

@@ -159,7 +159,7 @@ func (s *Server) setupSourceRoutes() {
}
func (s *Server) setupWebhookRoutes() {
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
s.router.HandleFunc(
"/webhook/{uuid}",
s.h.HandleWebhook(),
)

View File

@@ -173,8 +173,18 @@ func TestSetUser_SetsAllFields(t *testing.T) {
)
}
func TestGetUserID(t *testing.T) {
t.Parallel()
// testSessionGetter exercises a session string getter before and
// after SetUser: it must report false with an empty value on a
// fresh session, then true with the expected value once
// SetUser(sess, "user-xyz", "bob") has run.
func testSessionGetter(
t *testing.T,
get func(
*session.Session, *sessions.Session,
) (string, bool),
expected string,
) {
t.Helper()
s := testSession(t)
@@ -185,44 +195,46 @@ func TestGetUserID(t *testing.T) {
require.NoError(t, err)
// Before setting user
userID, ok := s.GetUserID(sess)
val, ok := get(s, sess)
assert.False(
t, ok, "should return false when no user ID is set",
t, ok, "should return false before SetUser",
)
assert.Empty(t, userID)
assert.Empty(t, val)
// After setting user
s.SetUser(sess, "user-xyz", "bob")
userID, ok = s.GetUserID(sess)
val, ok = get(s, sess)
assert.True(t, ok)
assert.Equal(t, "user-xyz", userID)
assert.Equal(t, expected, val)
}
func TestGetUserID(t *testing.T) {
t.Parallel()
testSessionGetter(
t,
func(
s *session.Session, sess *sessions.Session,
) (string, bool) {
return s.GetUserID(sess)
},
"user-xyz",
)
}
func TestGetUsername(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
sess, err := s.Get(req)
require.NoError(t, err)
// Before setting user
username, ok := s.GetUsername(sess)
assert.False(
t, ok, "should return false when no username is set",
testSessionGetter(
t,
func(
s *session.Session, sess *sessions.Session,
) (string, bool) {
return s.GetUsername(sess)
},
"bob",
)
assert.Empty(t, username)
// After setting user
s.SetUser(sess, "user-xyz", "bob")
username, ok = s.GetUsername(sess)
assert.True(t, ok)
assert.Equal(t, "bob", username)
}
// --- IsAuthenticated Tests ---

View File

@@ -12,7 +12,12 @@ import (
// middleware and handler tests to use real session functionality. The key
// parameter is the raw 32-byte authentication key used for session encryption
// and CSRF cookie signing.
func NewForTest(store *sessions.CookieStore, cfg *config.Config, log *slog.Logger, key []byte) *Session {
func NewForTest(
store *sessions.CookieStore,
cfg *config.Config,
log *slog.Logger,
key []byte,
) *Session {
return &Session{
store: store,
key: key,

View File

@@ -10,11 +10,11 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-07. Never "latest"; exact versions only.
GOLANGCI_LINT_VERSION="2.11.3"
# sha256 of golangci-lint-2.11.3-linux-<arch>.tar.gz release archives
GOLANGCI_LINT_SHA256_AMD64="87bb8cddbcc825d5778b64e8a91b46c0526b247f4e2f2904dea74ec7450475d1"
GOLANGCI_LINT_SHA256_ARM64="ee3d95f301359e7d578e6d99c8ad5aeadbabc5a13009a30b2b0df11c8058afe9"
# Pinned versions, 2026-08-07. Never "latest"; exact versions only.
GOLANGCI_LINT_VERSION="2.12.2"
# sha256 of golangci-lint-2.12.2-linux-<arch>.tar.gz release archives
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
PKGMGR=""
SUDO=""