Skip to content

Commit 0949ba1

Browse files
authored
chore: production hardening — Go 1.26, security (SSRF, hide signing key), config, pagination (#46)
* chore: upgrade golangci-lint for Go 1.26; add test timeout Made-with: Cursor * chore: config validation, SSRF hardening, auth, pagination invariant Made-with: Cursor * refactor: replace ptrFloat64 with new() pattern Made-with: Cursor * chore: update things to latest version * chore: update tool versions * chore: webhook blacklist improvement * chore: improved testing * chore: added linting exceptions to tests only * chore: hide webhook signing key * chore: assorted fixes
1 parent 50c8603 commit 0949ba1

32 files changed

+1175
-359
lines changed

.env.example

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ API_KEY=your-secret-api-key-here
1414
# Format: postgres://username:password@host:port/database?sslmode=disable
1515
DATABASE_URL=postgres://postgres:postgres@localhost:5432/test_db?sslmode=disable
1616

17+
# Database connection pool (optional). For multi-instance: instances * DATABASE_MAX_CONNS must be below Postgres max_connections.
18+
# DATABASE_MAX_CONNS=25
19+
# DATABASE_MIN_CONNS=0
20+
# DATABASE_MAX_CONN_LIFETIME_SECONDS=3600
21+
# DATABASE_MAX_CONN_IDLE_TIME_SECONDS=1800
22+
# DATABASE_HEALTH_CHECK_PERIOD_SECONDS=60
23+
# DATABASE_CONNECT_TIMEOUT_SECONDS=10
24+
1725
# HTTP server port (optional)
1826
# Default: 8080
1927
PORT=8080
@@ -23,9 +31,6 @@ PORT=8080
2331
# Valid values: debug, info, warn, error
2432
LOG_LEVEL=info
2533

26-
# Max request body size in bytes (optional). Default: 10485760 (10 MiB). Requests exceeding this return 413.
27-
# MAX_REQUEST_BODY_BYTES=10485760
28-
2934
# Message publisher: event channel buffer size (optional). Default: 1024
3035
MESSAGE_PUBLISHER_QUEUE_MAX_SIZE=16384
3136

@@ -43,6 +48,19 @@ WEBHOOK_MAX_FAN_OUT_PER_EVENT=500
4348
# Max total webhooks allowed; creation returns 403 Forbidden when limit reached. Default: 500
4449
WEBHOOK_MAX_COUNT=500
4550

51+
# Webhook HTTP timeout (optional). Timeout for each delivery POST; job timeout = this + 5s. Default: 15
52+
# WEBHOOK_HTTP_TIMEOUT_SECONDS=15
53+
54+
# Webhook URL blacklist (optional). Comma-separated hosts/IPs that cannot be used as webhook endpoints (SSRF mitigation).
55+
# Default: localhost,127.0.0.1,::1,169.254.169.254 (includes AWS metadata endpoint)
56+
# WEBHOOK_BLACKLIST=localhost,127.0.0.1,::1,169.254.169.254
57+
58+
# Webhook enqueue retries (optional). When River InsertMany fails, retry with exponential backoff + jitter.
59+
# Defaults: 3 retries, 100ms initial backoff, 2s max backoff.
60+
# WEBHOOK_ENQUEUE_MAX_RETRIES=3
61+
# WEBHOOK_ENQUEUE_INITIAL_BACKOFF_MS=100
62+
# WEBHOOK_ENQUEUE_MAX_BACKOFF_MS=2000
63+
4664
# Embeddings are optional. To enable, set both EMBEDDING_PROVIDER and EMBEDDING_MODEL; if either is unset, embeddings are disabled and no embedding jobs run.
4765
# EMBEDDING_PROVIDER_API_KEY is required for openai and google.
4866
# Vector size is fixed (768) in code.

.github/workflows/api-contract-tests.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ jobs:
5353
- name: Set up Go
5454
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
5555
with:
56-
go-version: '1.25.7'
56+
go-version: '1.26.1'
5757

5858
- name: Cache Go modules
5959
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
@@ -69,7 +69,7 @@ jobs:
6969
run: go mod download
7070

7171
- name: Install goose
72-
run: go install github.com/pressly/goose/v3/cmd/goose@v3.26.0
72+
run: go install github.com/pressly/goose/v3/cmd/goose@v3.27.0
7373

7474
- name: Validate migrations
7575
run: make migrate-validate
@@ -79,7 +79,7 @@ jobs:
7979

8080
- name: Run River migrations
8181
run: |
82-
go install github.com/riverqueue/river/cmd/river@latest
82+
go install github.com/riverqueue/river/cmd/river@v0.31.0
8383
make river-migrate
8484
8585
- name: Build application

.github/workflows/code-quality.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@ jobs:
3636
run: npx --yes @stoplight/spectral-cli@6 lint openapi.yaml
3737

3838
- name: Set up Go
39+
id: setup-go
3940
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
4041
with:
41-
go-version: '1.25.7'
42+
go-version: '1.26.0'
4243

4344
- name: Cache Go modules
4445
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
@@ -59,21 +60,21 @@ jobs:
5960
go mod tidy
6061
git diff --exit-code go.mod go.sum || (echo "Error: go.mod or go.sum are out of sync. Run 'go mod tidy' and commit the changes." && exit 1)
6162
62-
# Pin tool versions for reproducible builds
63-
# Update these versions periodically and bump the cache key
63+
# Pin tool versions for reproducible builds (must match Makefile)
64+
# Cache key includes Go version so tools are rebuilt when Go upgrades
6465
- name: Cache development tools
6566
id: cache-tools
6667
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
6768
with:
6869
path: |
6970
~/go/bin/golangci-lint
7071
~/go/bin/govulncheck
71-
key: ${{ runner.os }}-dev-tools-lint2.8.0-vuln1.1.4
72+
key: ${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-lint2.10.1-vuln1.1.4
7273

7374
- name: Install development tools
7475
if: steps.cache-tools.outputs.cache-hit != 'true'
7576
run: |
76-
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0
77+
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.10.1
7778
go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
7879
7980
- name: Format check (make fmt)

.github/workflows/migrations-validate.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ jobs:
2525
- name: Set up Go
2626
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
2727
with:
28-
go-version: '1.25.7'
28+
go-version: '1.26.1'
2929

3030
- name: Install goose
31-
run: go install github.com/pressly/goose/v3/cmd/goose@v3.26.0
31+
run: go install github.com/pressly/goose/v3/cmd/goose@v3.27.0
3232

3333
- name: Validate migrations
3434
run: make migrate-validate

.github/workflows/tests.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
- name: Set up Go
3232
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
3333
with:
34-
go-version: '1.25.7'
34+
go-version: '1.26.1'
3535

3636
- name: Cache Go modules
3737
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
@@ -80,7 +80,7 @@ jobs:
8080
- name: Set up Go
8181
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
8282
with:
83-
go-version: '1.25.7'
83+
go-version: '1.26.1'
8484

8585
- name: Cache Go modules
8686
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
@@ -96,7 +96,7 @@ jobs:
9696
run: go mod download
9797

9898
- name: Install goose
99-
run: go install github.com/pressly/goose/v3/cmd/goose@v3.26.0
99+
run: go install github.com/pressly/goose/v3/cmd/goose@v3.27.0
100100

101101
- name: Validate migrations
102102
run: make migrate-validate
@@ -106,7 +106,7 @@ jobs:
106106

107107
- name: Run River migrations
108108
run: |
109-
go install github.com/riverqueue/river/cmd/river@v0.30.2
109+
go install github.com/riverqueue/river/cmd/river@v0.31.0
110110
make river-migrate
111111
112112
- name: Run integration tests
@@ -143,7 +143,7 @@ jobs:
143143
- name: Set up Go
144144
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
145145
with:
146-
go-version: '1.25.7'
146+
go-version: '1.26.1'
147147

148148
- name: Cache Go modules
149149
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
@@ -159,14 +159,14 @@ jobs:
159159
run: go mod download
160160

161161
- name: Install goose
162-
run: go install github.com/pressly/goose/v3/cmd/goose@v3.26.0
162+
run: go install github.com/pressly/goose/v3/cmd/goose@v3.27.0
163163

164164
- name: Initialize database schema
165165
run: make init-db
166166

167167
- name: Run River migrations
168168
run: |
169-
go install github.com/riverqueue/river/cmd/river@v0.30.2
169+
go install github.com/riverqueue/river/cmd/river@v0.31.0
170170
make river-migrate
171171
172172
- name: Check coverage (excludes cmd/api)

.golangci.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ run:
99
timeout: 5m
1010
concurrency: 4
1111
modules-download-mode: readonly
12-
go: "1.25"
12+
go: "1.26.1"
1313
# Allow linting test files but suppress specific strict rules via exclusion below
1414
tests: true
1515

@@ -43,6 +43,15 @@ linters:
4343
linters:
4444
- nilnil # mocks often return (nil, nil) for success
4545
- err113 # mocks and test helpers use dynamic errors
46+
# gosec false positives in tests only: G706 log injection, G704 SSRF (localhost), G101 test DB credential
47+
- path: _test\.go$
48+
linters:
49+
- gosec
50+
text: "G70[46]|G101"
51+
- path: tests/
52+
linters:
53+
- gosec
54+
text: "G70[46]|G101"
4655
settings:
4756
lll:
4857
line-length: 140 # consider 120–140 for new code

Dockerfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Stage 1: Build
33
# =============================================================================
44
# TARGETOS/TARGETARCH are set by Docker Buildx for multi-platform builds (e.g. linux/arm64 on Mac M1).
5-
FROM golang:1.25.7-alpine AS builder
5+
FROM golang:1.26.1-alpine AS builder
66
ARG TARGETOS=linux
77
ARG TARGETARCH
88

@@ -11,8 +11,8 @@ RUN apk add --no-cache git ca-certificates
1111
WORKDIR /build
1212

1313
# Install goose and river CLI for migrations (for target platform)
14-
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go install github.com/pressly/goose/v3/cmd/goose@v3.26.0 && \
15-
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go install github.com/riverqueue/river/cmd/river@v0.30.2
14+
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go install github.com/pressly/goose/v3/cmd/goose@v3.27.0 && \
15+
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go install github.com/riverqueue/river/cmd/river@v0.31.0
1616

1717
# Cache dependencies
1818
COPY go.mod go.sum ./

Makefile

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ lint-openapi:
4343
# Loads .env if present so DATABASE_URL (e.g. port 5433) is used when Postgres runs on a non-default port.
4444
tests:
4545
@echo "Running integration tests..."
46-
@(set -a && [ -f .env ] && . ./.env && set +a; go test ./tests/... -v)
46+
@(set -a && [ -f .env ] && . ./.env && set +a; go test ./tests/... -v -timeout 120s)
4747

4848
# Run unit tests (fast, no database required)
4949
test-unit:
@@ -70,7 +70,7 @@ COVERAGE_THRESHOLD ?= 15
7070
check-coverage:
7171
@echo "Running tests with coverage (threshold: $(COVERAGE_THRESHOLD)%)..."
7272
@(set -a && [ -f .env ] && . ./.env && set +a; go test ./internal/... ./pkg/... ./tests/... -coverprofile=coverage.out)
73-
@COV=$$(go tool cover -func=coverage.out | tail -1 | awk '{gsub(/%/, ""); print $$3}') && \
73+
@COV=$$(go tool cover -func=coverage.out | \tail -1 | awk '{gsub(/%/, ""); print $$3}') && \
7474
if [ -z "$$COV" ] || ! awk -v c="$$COV" -v t="$(COVERAGE_THRESHOLD)" 'BEGIN { exit (c+0 >= t) ? 0 : 1 }'; then \
7575
echo ""; \
7676
echo "❌ Coverage $$COV% is below threshold $(COVERAGE_THRESHOLD)%"; \
@@ -155,7 +155,7 @@ migrate-validate:
155155

156156
# Run River job queue migrations (required for webhook delivery)
157157
river-migrate:
158-
@command -v river >/dev/null 2>&1 || { echo "Error: river CLI not found. Install with: go install github.com/riverqueue/river/cmd/river@latest"; exit 1; }
158+
@command -v river >/dev/null 2>&1 || { echo "Error: river CLI not found. Install with: make install-tools or go install github.com/riverqueue/river/cmd/river@$(RIVER_VERSION)"; exit 1; }
159159
@if [ -f .env ]; then \
160160
export $$(grep -v '^#' .env | xargs) && \
161161
if [ -z "$$DATABASE_URL" ]; then echo "Error: DATABASE_URL not found in .env"; exit 1; fi && \
@@ -200,10 +200,10 @@ deps:
200200

201201
# Install development tools
202202
# Tool versions - update these periodically
203-
GOLANGCI_LINT_VERSION := v2.8.0
203+
GOLANGCI_LINT_VERSION := v2.10.1
204204
GOVULNCHECK_VERSION := v1.1.4
205-
GOOSE_VERSION := v3.26.0
206-
RIVER_VERSION := v0.30.2
205+
GOOSE_VERSION := v3.27.0
206+
RIVER_VERSION := v0.31.0
207207
# Use pinned path so lint uses the version from make install-tools, not PATH
208208
GOLANGCI_LINT ?= $(HOME)/go/bin/golangci-lint
209209

cmd/api/app.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,10 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
167167
messageManager := service.NewMessagePublisherManager(cfg.MessagePublisherBufferSize, cfg.MessagePublisherPerEventTimeout, eventMetrics)
168168

169169
webhooksRepo := repository.NewWebhooksRepository(db)
170-
webhookSender := service.NewWebhookSenderImpl(webhooksRepo, webhookMetrics)
171-
webhookWorker := workers.NewWebhookDispatchWorker(webhooksRepo, webhookSender, webhookMetrics)
170+
webhookSender := service.NewWebhookSenderImpl(
171+
webhooksRepo, webhookMetrics, cfg.WebhookURLBlacklist, cfg.WebhookHTTPTimeout, nil)
172+
webhookWorker := workers.NewWebhookDispatchWorker(
173+
webhooksRepo, webhookSender, cfg.WebhookHTTPTimeout, webhookMetrics)
172174
riverWorkers := river.NewWorkers()
173175
river.AddWorker(riverWorkers, webhookWorker)
174176

@@ -286,6 +288,7 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
286288
webhookProvider := service.NewWebhookProvider(
287289
riverClient, webhooksRepo,
288290
cfg.WebhookDeliveryMaxAttempts, cfg.WebhookMaxFanOutPerEvent,
291+
cfg.WebhookEnqueueMaxRetries, cfg.WebhookEnqueueInitialBackoff, cfg.WebhookEnqueueMaxBackoff,
289292
webhookMetrics,
290293
)
291294
messageManager.RegisterProvider(webhookProvider)
@@ -303,7 +306,7 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
303306
messageManager.RegisterProvider(embeddingProv)
304307
}
305308

306-
webhooksService := service.NewWebhooksService(webhooksRepo, messageManager, cfg.WebhookMaxCount)
309+
webhooksService := service.NewWebhooksService(webhooksRepo, messageManager, cfg.WebhookMaxCount, cfg.WebhookURLBlacklist)
307310
webhooksHandler := handlers.NewWebhooksHandler(webhooksService)
308311

309312
feedbackRecordsHandler := handlers.NewFeedbackRecordsHandler(feedbackRecordsService)

cmd/api/main.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,17 @@ func run() int {
3737

3838
ctx := context.Background()
3939

40-
db, err := database.NewPostgresPool(ctx, cfg.DatabaseURL, database.WithAfterConnect(pgxvec.RegisterTypes))
40+
db, err := database.NewPostgresPool(ctx, cfg.DatabaseURL,
41+
database.WithPoolConfig(database.PoolConfig{
42+
MaxConns: cfg.DatabaseMaxConns,
43+
MinConns: cfg.DatabaseMinConns,
44+
MaxConnLifetime: cfg.DatabaseMaxConnLifetime,
45+
MaxConnIdleTime: cfg.DatabaseMaxConnIdleTime,
46+
HealthCheckPeriod: cfg.DatabaseHealthCheckPeriod,
47+
ConnectTimeout: cfg.DatabaseConnectTimeout,
48+
}),
49+
database.WithAfterConnect(pgxvec.RegisterTypes),
50+
)
4151
if err != nil {
4252
slog.Error("Failed to connect to database", "error", err)
4353

0 commit comments

Comments
 (0)