1 Commits

Author SHA1 Message Date
8cf9d0525a feat: add receiver rate limiting (refs #64)
Some checks failed
check / check (push) Failing after 59s
2026-08-07 18:32:00 +00:00
27 changed files with 566 additions and 240 deletions

View File

@@ -1,9 +1,5 @@
version: "2" 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: run:
timeout: 5m timeout: 5m
modules-download-mode: readonly modules-download-mode: readonly
@@ -18,17 +14,19 @@ linters:
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
settings:
lll: linters-settings:
line-length: 88 lll:
funlen: line-length: 88
lines: 80 funlen:
statements: 50 lines: 80
cyclop: statements: 50
max-complexity: 15 cyclop:
dupl: max-complexity: 15
threshold: 100 dupl:
threshold: 100
issues: issues:
exclude-use-default: false
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0

View File

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

View File

@@ -92,6 +92,7 @@ TTY detection, and security headers are always applied.
| `METRICS_USERNAME` | Basic auth username for `/metrics` | `""` | | `METRICS_USERNAME` | Basic auth username for `/metrics` | `""` |
| `METRICS_PASSWORD` | Basic auth password for `/metrics` | `""` | | `METRICS_PASSWORD` | Basic auth password for `/metrics` | `""` |
| `SENTRY_DSN` | Sentry error reporting DSN | `""` | | `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 On first startup, webhooker automatically generates a cryptographically
secure session encryption key and stores it in the database. This key secure session encryption key and stores it in the database. This key
@@ -676,17 +677,24 @@ just delayed until the target is healthy again.
### Rate Limiting ### Rate Limiting
Global rate limiting middleware (e.g., per-IP throttling applied at the Global blanket rate limiting middleware (e.g., a per-IP throttle shared
router level) **must not** apply to webhook receiver endpoints. Webhook with the web UI) **must not** apply to webhook receiver endpoints.
endpoints receive automated traffic from external services at Webhook endpoints receive automated traffic from external services at
unpredictable rates, and blanket rate limits would cause legitimate unpredictable rates, and blanket limits shared with other routes would
deliveries to be dropped. cause legitimate deliveries to be dropped.
Instead, each webhook has its own individually configurable rate limit, The receiver instead has its own dedicated abuse limit, scoped to the
applied within the webhook handler itself. By default, no rate limit is `/webhook/{uuid}` route only and keyed per client IP per entrypoint: one
applied — webhook endpoints accept traffic as fast as it arrives. Rate misbehaving sender is throttled without affecting other senders of the
limits can be configured per-webhook when needed (e.g., to protect same entrypoint or the same sender's other entrypoints. The limit is
against a misbehaving sender). `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.
### API Endpoints ### API Endpoints

36
TODO.md
View File

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

View File

@@ -31,12 +31,23 @@ const (
// defaultRetentionSweepInterval is how often the retention // defaultRetentionSweepInterval is how often the retention
// reaper deletes events older than each webhook's RetentionDays. // reaper deletes events older than each webhook's RetentionDays.
defaultRetentionSweepInterval = time.Hour 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 // ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT
// contains an unrecognised value. // contains an unrecognised value.
var ErrInvalidEnvironment = errors.New("invalid environment") 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. //nolint:revive // ConfigParams is a standard fx naming convention.
type ConfigParams struct { type ConfigParams struct {
fx.In fx.In
@@ -60,6 +71,10 @@ type Config struct {
// RetentionSweepInterval is how often the retention reaper runs. // RetentionSweepInterval is how often the retention reaper runs.
RetentionSweepInterval time.Duration 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 params *ConfigParams
log *slog.Logger log *slog.Logger
} }
@@ -104,6 +119,38 @@ func envInt(key string, defaultValue int) int {
return defaultValue 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 // envDuration returns the value of the named environment variable
// parsed as a Go duration (e.g. "1h", "30m"). Returns defaultValue if // 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 // not set. If the variable is set but cannot be parsed, it returns a
@@ -162,6 +209,17 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
return nil, err 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 // Load configuration values from environment variables
s := &Config{ s := &Config{
DataDir: envString("DATA_DIR"), DataDir: envString("DATA_DIR"),
@@ -173,6 +231,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
Port: envInt("PORT", defaultPort), Port: envInt("PORT", defaultPort),
SentryDSN: envString("SENTRY_DSN"), SentryDSN: envString("SENTRY_DSN"),
RetentionSweepInterval: retentionSweepInterval, RetentionSweepInterval: retentionSweepInterval,
ReceiverRateLimit: receiverRateLimit,
log: log, log: log,
params: &params, params: &params,
} }
@@ -197,6 +256,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
"maintenanceMode", s.MaintenanceMode, "maintenanceMode", s.MaintenanceMode,
"dataDir", s.DataDir, "dataDir", s.DataDir,
"retentionSweepInterval", s.RetentionSweepInterval.String(), "retentionSweepInterval", s.RetentionSweepInterval.String(),
"receiverRateLimit", s.ReceiverRateLimit,
"hasSentryDSN", s.SentryDSN != "", "hasSentryDSN", s.SentryDSN != "",
"hasMetricsAuth", "hasMetricsAuth",
s.MetricsUsername != "" && s.MetricsPassword != "", s.MetricsUsername != "" && s.MetricsPassword != "",

View File

@@ -258,3 +258,109 @@ 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,15 +11,6 @@ import (
"sneak.berlin/go/webhooker/internal/logger" "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( func setupTestDB(
t *testing.T, t *testing.T,
) (*database.Database, *fxtest.Lifecycle) { ) (*database.Database, *fxtest.Lifecycle) {
@@ -28,8 +19,8 @@ func setupTestDB(
lc := fxtest.NewLifecycle(t) lc := fxtest.NewLifecycle(t)
g := &globals.Globals{ g := &globals.Globals{
Appname: testAppname, Appname: "webhooker-test",
Version: testVersion, Version: "test",
} }
l, err := logger.New( l, err := logger.New(

View File

@@ -4,13 +4,10 @@ package database
type Entrypoint struct { type Entrypoint struct {
BaseModel BaseModel
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"` 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"` Description string `json:"description"`
Active bool `gorm:"default:true" json:"active"` Active bool `gorm:"default:true" json:"active"`
// Relations // Relations
Webhook Webhook `json:"webhook,omitzero"` Webhook Webhook `json:"webhook,omitzero"`

View File

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

View File

@@ -4,12 +4,10 @@ package database
type Webhook struct { type Webhook struct {
BaseModel BaseModel
UserID string `gorm:"type:uuid;not null" json:"userId"` UserID string `gorm:"type:uuid;not null" json:"userId"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Description string `json:"description"` 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 // Relations
User User `json:"user,omitzero"` User User `json:"user,omitzero"`

View File

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

View File

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

View File

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

View File

@@ -126,6 +126,36 @@ func iHTTPConfig(url string) string {
return string(data) 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( func iEngine(
t *testing.T, workers int, t *testing.T, workers int,
) *delivery.Engine { ) *delivery.Engine {
@@ -152,10 +182,10 @@ func iSeedEvent(
event := database.Event{ event := database.Event{
WebhookID: webhookID, WebhookID: webhookID,
EntrypointID: uuid.New().String(), EntrypointID: uuid.New().String(),
Method: http.MethodPost, Method: "POST",
Headers: `{}`, Headers: `{}`,
Body: body, Body: body,
ContentType: testContentType, ContentType: "application/json",
} }
require.NoError(t, db.Create(&event).Error) require.NoError(t, db.Create(&event).Error)
@@ -905,7 +935,7 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
func TestDeliverHTTP_TargetTimeout(t *testing.T) { func TestDeliverHTTP_TargetTimeout(t *testing.T) {
t.Parallel() t.Parallel()
db := testWebhookDB(t) db := iWebhookDB(t)
e := iEngine(t, 1) e := iEngine(t, 1)
ts := httptest.NewServer( ts := httptest.NewServer(
@@ -957,10 +987,10 @@ func iSeedEventAndDelivery(
event := database.Event{ event := database.Event{
WebhookID: uuid.New().String(), WebhookID: uuid.New().String(),
EntrypointID: uuid.New().String(), EntrypointID: uuid.New().String(),
Method: http.MethodPost, Method: "POST",
Headers: `{"Content-Type":["application/json"]}`, Headers: `{"Content-Type":["application/json"]}`,
Body: body, Body: body,
ContentType: testContentType, ContentType: "application/json",
} }
require.NoError(t, db.Create(&event).Error) require.NoError(t, db.Create(&event).Error)
@@ -1037,7 +1067,7 @@ func iAssertResultFailed(
func TestDeliverHTTP_InvalidConfig(t *testing.T) { func TestDeliverHTTP_InvalidConfig(t *testing.T) {
t.Parallel() t.Parallel()
db := testWebhookDB(t) db := iWebhookDB(t)
e := iEngine(t, 1) e := iEngine(t, 1)
event, del := iSeedEventAndDelivery( event, del := iSeedEventAndDelivery(

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,11 +30,6 @@ const (
defaultRetentionDays = 30 defaultRetentionDays = 30
// paginationPerPage is the number of items per page. // paginationPerPage is the number of items per page.
paginationPerPage = 25 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. // 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 { func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
data := map[string]any{ data := map[string]any{
tmplKeyError: "", "Error": "",
} }
h.renderTemplate(w, r, "sources_new.html", data) h.renderTemplate(w, r, "sources_new.html", data)
@@ -145,7 +145,7 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
if name == "" { if name == "" {
data := map[string]any{ data := map[string]any{
tmplKeyError: "Name is required", "Error": "Name is required",
} }
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
@@ -315,11 +315,11 @@ func (h *Handlers) renderSourceDetail(
} }
data := map[string]any{ data := map[string]any{
tmplKeyWebhook: webhook, "Webhook": webhook,
"Entrypoints": entrypoints, "Entrypoints": entrypoints,
"Targets": targets, "Targets": targets,
"Events": events, "Events": events,
"BaseURL": scheme + "://" + host, "BaseURL": scheme + "://" + host,
} }
h.renderTemplate(w, r, "source_detail.html", data) h.renderTemplate(w, r, "source_detail.html", data)
@@ -351,8 +351,8 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
} }
data := map[string]any{ data := map[string]any{
tmplKeyWebhook: webhook, "Webhook": webhook,
tmplKeyError: "", "Error": "",
} }
h.renderTemplate(w, r, "source_edit.html", data) h.renderTemplate(w, r, "source_edit.html", data)
@@ -415,8 +415,8 @@ func (h *Handlers) applyWebhookEdit(
name := r.FormValue("name") name := r.FormValue("name")
if name == "" { if name == "" {
data := map[string]any{ data := map[string]any{
tmplKeyWebhook: *webhook, "Webhook": *webhook,
tmplKeyError: "Name is required", "Error": "Name is required",
} }
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
@@ -589,15 +589,15 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
} }
data := map[string]any{ data := map[string]any{
tmplKeyWebhook: webhook, "Webhook": webhook,
"Events": evts, "Events": evts,
"Page": page, "Page": page,
"TotalPages": totalPages, "TotalPages": totalPages,
"TotalEvents": total, "TotalEvents": total,
"HasPrev": page > 1, "HasPrev": page > 1,
"HasNext": page < totalPages, "HasNext": page < totalPages,
"PrevPage": page - 1, "PrevPage": page - 1,
"NextPage": page + 1, "NextPage": page + 1,
} }
h.renderTemplate(w, r, "source_logs.html", data) h.renderTemplate(w, r, "source_logs.html", data)
@@ -900,15 +900,9 @@ func (h *Handlers) buildTargetConfig(
) (string, error) { ) (string, error) {
switch targetType { switch targetType {
case database.TargetTypeHTTP: case database.TargetTypeHTTP:
return h.buildURLTargetConfig( return h.buildHTTPTargetConfig(w, r, targetURL)
w, r, targetURL, "url",
"URL is required for HTTP targets",
)
case database.TargetTypeSlack: case database.TargetTypeSlack:
return h.buildURLTargetConfig( return h.buildSlackTargetConfig(w, r, targetURL)
w, r, targetURL, "webhookUrl",
"Webhook URL is required for Slack targets",
)
case database.TargetTypeDatabase, database.TargetTypeLog: case database.TargetTypeDatabase, database.TargetTypeLog:
return "", nil return "", nil
default: default:
@@ -921,18 +915,16 @@ func (h *Handlers) buildTargetConfig(
} }
} }
// buildURLTargetConfig builds config JSON for a target whose // buildHTTPTargetConfig builds config JSON for an HTTP target.
// configuration is a single SSRF-validated URL stored under func (h *Handlers) buildHTTPTargetConfig(
// configKey. missingMsg is the error shown when no URL is given.
func (h *Handlers) buildURLTargetConfig(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
targetURL, configKey, missingMsg string, targetURL string,
) (string, error) { ) (string, error) {
if targetURL == "" { if targetURL == "" {
http.Error( http.Error(
w, w,
missingMsg, "URL is required for HTTP targets",
http.StatusBadRequest, http.StatusBadRequest,
) )
@@ -957,7 +949,56 @@ func (h *Handlers) buildURLTargetConfig(
return "", err return "", err
} }
cfg := map[string]any{configKey: targetURL} 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}
configBytes, err := json.Marshal(cfg) configBytes, err := json.Marshal(cfg)
if err != nil { if err != nil {

View File

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

View File

@@ -14,6 +14,11 @@ const (
// loginRateInterval is the time window for the rate limit. // loginRateInterval is the time window for the rate limit.
loginRateInterval = 1 * time.Minute 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 // LoginRateLimit returns middleware that enforces per-IP rate
@@ -62,3 +67,37 @@ 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,8 +2,10 @@ package middleware_test
import ( import (
"context" "context"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -145,3 +147,94 @@ func TestLoginRateLimit_IndependentPerIP(t *testing.T) {
"different IP should not be affected", "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() { func (s *Server) setupWebhookRoutes() {
s.router.HandleFunc( s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
"/webhook/{uuid}", "/webhook/{uuid}",
s.h.HandleWebhook(), s.h.HandleWebhook(),
) )

View File

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

View File

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

View File

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