Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 584d3441fc | |||
| 926aa2d3ec | |||
| a2ca9c25d0 | |||
| 92959cac38 | |||
| f15db0e20a | |||
| b82c10acf4 | |||
| 7cd6fbf571 |
@@ -125,6 +125,9 @@ jobs:
|
||||
- name: Secret scan (gitleaks)
|
||||
run: make security-secrets
|
||||
|
||||
- name: Code security scan (gosec)
|
||||
run: make security-code
|
||||
|
||||
# Host mode, no `container:`: helm is baked into the runner image, and a container job
|
||||
# could not install it -- get.helm.sh is unreachable from the dind bridge. Same reason
|
||||
# release.yaml's chart job runs on the host.
|
||||
|
||||
@@ -24,4 +24,10 @@ FROM scratch
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=builder /terdut /terdut
|
||||
EXPOSE 8080
|
||||
# Numeric, not a name: scratch has no /etc/passwd for one to resolve against,
|
||||
# and Docker's USER accepts a bare UID:GID without it. 65532 is the common
|
||||
# "nonroot" convention (distroless's own uid), chosen so the chart's pod
|
||||
# securityContext (runAsNonRoot, runAsUser: 65532) matches what the image
|
||||
# already runs as rather than fighting it.
|
||||
USER 65532:65532
|
||||
ENTRYPOINT ["/terdut"]
|
||||
|
||||
@@ -143,6 +143,7 @@ BUILDX_BUILDER ?= terdut
|
||||
TRIVY_VERSION := 0.73.0
|
||||
GOVULNCHECK_VERSION := v1.1.4
|
||||
GITLEAKS_VERSION := v8.30.0
|
||||
GOSEC_VERSION := v2.29.0
|
||||
|
||||
# --pull, not --no-cache: refresh the base image without discarding the layer cache.
|
||||
DOCKER_BUILD_FLAGS ?= --pull
|
||||
@@ -222,6 +223,26 @@ release: push helm-package helm-push ## Publish image + chart (the workflow's on
|
||||
security-go: ## Scan Go deps for known CVEs (govulncheck)
|
||||
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
|
||||
|
||||
# Code-level, not dependency- or secret-level: gosec reads this repo's own source for
|
||||
# known-dangerous patterns (weak crypto, SQL/command injection shapes, insecure file
|
||||
# permissions, …) rather than its module graph or working tree for leaked credentials,
|
||||
# which is what security-go and security-secrets above already cover.
|
||||
#
|
||||
# G104 (unchecked error) is excluded. Every hit it found here on first run was this
|
||||
# codebase's existing, deliberate idiom for a best-effort write or an already-reviewed
|
||||
# json.Unmarshal of this server's own JSONB (see the "best-effort" comments in
|
||||
# middleware.go and the //nolint:errcheck lines in alerts.go/deadman.go) -- a style that
|
||||
# predates gosec and that G104 cannot distinguish from a mistake. Reaching the same
|
||||
# green result by adding a dozens of individual #nosec comments would not add
|
||||
# information; it would just make a future *real* G104 regression one more suppressed
|
||||
# line instead of a visible one. Same reasoning as the chi-advisories note on
|
||||
# security-go above: what gosec reports here (nothing, beyond G104) is the useful
|
||||
# property, not a loophole. -exclude-generated skips web.go's embedded, build-time-only
|
||||
# assets.
|
||||
.PHONY: security-code
|
||||
security-code: ## Scan this repo's own source for risky patterns (gosec)
|
||||
go run github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION) -exclude-generated -exclude=G104 ./...
|
||||
|
||||
# --no-git scans the working tree rather than the history, so this catches a secret on the
|
||||
# way in. It is not a history audit and finding nothing here says nothing about what is
|
||||
# already committed. --redact because the finding is printed into a CI log.
|
||||
|
||||
@@ -15,5 +15,5 @@ type: application
|
||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||
# metadata and drives nothing.
|
||||
version: 0.37.2
|
||||
appVersion: "v0.37.2"
|
||||
version: 0.38.0
|
||||
appVersion: "v0.38.0"
|
||||
|
||||
@@ -25,11 +25,30 @@ spec:
|
||||
{{- include "terdut-server.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
# Pod-wide default; both containers below run as this UID regardless of
|
||||
# what their own image would otherwise pick (postgres:17-alpine's
|
||||
# pg_isready needs no particular user, and 65532 is what the app image
|
||||
# itself runs as now — see the Dockerfile's USER). seccompProfile here
|
||||
# rather than per-container: there is no reason it would ever differ
|
||||
# between them.
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
{{- if .Values.database.waitForPostgres.enabled }}
|
||||
initContainers:
|
||||
- name: wait-for-postgres
|
||||
image: "{{ .Values.database.waitForPostgres.image.repository }}:{{ .Values.database.waitForPostgres.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.database.waitForPostgres.image.pullPolicy }}
|
||||
# No capability this loop needs, and nothing in it writes to disk:
|
||||
# sh, pg_isready, echo and sleep all run read-only.
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
env:
|
||||
- name: TERDUT_DB_DSN
|
||||
value: {{ required "database.dsn is required" .Values.database.dsn | quote }}
|
||||
@@ -46,6 +65,13 @@ spec:
|
||||
- name: terdut-server
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
# scratch, nothing to write: the binary keeps no local state and
|
||||
# writes nothing to disk, so the root filesystem can stay read-only.
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.port }}
|
||||
|
||||
@@ -94,10 +94,16 @@ func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc
|
||||
}
|
||||
}
|
||||
|
||||
// maxWebhookBodyBytes is larger than maxBodyBytes: a real Alertmanager batch
|
||||
// can carry many alerts, each with several labels and annotations, and the
|
||||
// sender is a trusted piece of infrastructure rather than an arbitrary
|
||||
// caller.
|
||||
const maxWebhookBodyBytes = 8 << 20
|
||||
|
||||
func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, src alertSource) {
|
||||
teamID := src.teamID
|
||||
var payload amPayload
|
||||
if err := decodeJSON(r, &payload); err != nil {
|
||||
if err := decodeJSONLimit(r, &payload, maxWebhookBodyBytes); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid payload"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAPIKey_DefaultsToNeverExpiring(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
var key struct {
|
||||
Key string `json:"key"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/1/api-keys",
|
||||
map[string]string{"name": "no-expiry"}), &key)
|
||||
|
||||
if key.ExpiresAt != nil {
|
||||
t.Errorf("expires_at = %v, want nil (unset expires_in_days means never expires)", *key.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_ExpiresInDaysSetsExpiresAt(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
var key struct {
|
||||
ID int64 `json:"id"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/1/api-keys",
|
||||
map[string]any{"name": "rotates", "expires_in_days": 30}), &key)
|
||||
|
||||
if key.ExpiresAt == nil {
|
||||
t.Fatal("expires_at = nil, want a timestamp roughly 30 days out")
|
||||
}
|
||||
got, err := time.Parse(time.RFC3339, *key.ExpiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("parse expires_at: %v", err)
|
||||
}
|
||||
want := time.Now().AddDate(0, 0, 30)
|
||||
if diff := want.Sub(got).Abs(); diff > time.Hour {
|
||||
t.Errorf("expires_at = %v, want close to %v (30 days out)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_ExpiresInDaysRejectsOutOfRange(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
for _, days := range []int{-1, 3651} {
|
||||
resp := s.req(t, http.MethodPost, "/api/users/1/api-keys",
|
||||
map[string]any{"name": "bad", "expires_in_days": days})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expires_in_days=%d: status = %d, want %d", days, resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_AnExpiredKeyCannotAuthenticate(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
var key struct {
|
||||
ID int64 `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/1/api-keys",
|
||||
map[string]any{"name": "soon-expired", "expires_in_days": 1}), &key)
|
||||
|
||||
// A fresh key works...
|
||||
req, _ := http.NewRequest(http.MethodGet, s.URL+"/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api/me: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("fresh key: status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
// ...and stops working once its expiry has passed.
|
||||
s.exec(t, "UPDATE api_keys SET expires_at = $1 WHERE id = $2", time.Now().Add(-time.Hour).Unix(), key.ID)
|
||||
|
||||
req2, _ := http.NewRequest(http.MethodGet, s.URL+"/api/me", nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
resp2, err := http.DefaultClient.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api/me: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expired key: status = %d, want %d", resp2.StatusCode, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_ListNeverReturnsTheRawKey(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/1/api-keys",
|
||||
map[string]string{"name": "listed"}), new(struct {
|
||||
Key string `json:"key"`
|
||||
}))
|
||||
|
||||
var keys []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/users/1/api-keys", nil), &keys)
|
||||
|
||||
found := false
|
||||
for _, k := range keys {
|
||||
if k.Name == "listed" {
|
||||
found = true
|
||||
}
|
||||
if k.Key != "" {
|
||||
t.Errorf("key %d (%s): raw key present in listing", k.ID, k.Name)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the key just created does not appear in the listing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_ListIsSelfOrAdmin(t *testing.T) {
|
||||
s := newTS(t)
|
||||
a := newTeam(t, s, "apikeys-a")
|
||||
|
||||
resp := a.call(http.MethodGet, "/api/users/1/api-keys", nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d (not self, not an admin)", resp.StatusCode, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ func Sweep(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Durati
|
||||
archiveResolvedIncidents(ctx, db, archiveAfter)
|
||||
purgeAckTokens(ctx, db)
|
||||
purgeSessions(ctx, db)
|
||||
purgeRateLimits(ctx, db)
|
||||
}
|
||||
|
||||
// expireStale resolves firing alerts that Alertmanager has stopped refreshing.
|
||||
@@ -121,6 +122,10 @@ func expireStale(ctx context.Context, db *sql.DB, staleAfter time.Duration, skip
|
||||
for i, id := range ids {
|
||||
idList[i] = id
|
||||
}
|
||||
// #nosec G202 -- sqlArgs.add/addList only ever splice in the "$N"
|
||||
// placeholder they hand back, never a value; every value travels through
|
||||
// args.all() as a bound parameter. See the sqlArgs doc comment in
|
||||
// helpers.go.
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
UPDATE alerts
|
||||
SET status = 'resolved',
|
||||
|
||||
+83
-42
@@ -45,57 +45,85 @@ var dummyHash = sync.OnceValue(func() []byte {
|
||||
return h
|
||||
})
|
||||
|
||||
// loginLimiter counts failed logins in a fixed window, per username and per
|
||||
// client address. The username limit is what stops guessing one account; the
|
||||
// address limit is looser because every user behind the same gateway or NAT
|
||||
// shares it.
|
||||
// loginLimiter counts failed logins (and other unauthenticated attempts:
|
||||
// sign-up, OIDC/device start) in a fixed window, per key — a username, a
|
||||
// client address, or both, depending on the caller.
|
||||
//
|
||||
// Backed by Postgres rather than an in-memory map: this server runs more
|
||||
// than one replica in production (v0.37.0), and a counter that only ever
|
||||
// sees its own pod's traffic would quietly let every limit through
|
||||
// multiplied by the replica count — two loginLimiter values pointed at the
|
||||
// same db, standing in for two replicas, now share exactly one count per
|
||||
// key instead of each keeping their own.
|
||||
//
|
||||
// The window resets rather than slides, the same behavior the in-memory
|
||||
// version it replaces had: once a key's window is older than loginWindow,
|
||||
// the next fail() starts a fresh one instead of extending the stale one.
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
failures map[string]*loginWindowCount
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type loginWindowCount struct {
|
||||
start time.Time
|
||||
n int
|
||||
func newLoginLimiter(db *sql.DB) *loginLimiter {
|
||||
return &loginLimiter{db: db}
|
||||
}
|
||||
|
||||
func newLoginLimiter() *loginLimiter {
|
||||
return &loginLimiter{failures: map[string]*loginWindowCount{}}
|
||||
}
|
||||
|
||||
func (l *loginLimiter) blocked(key string, max int) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
c, ok := l.failures[key]
|
||||
if !ok || time.Since(c.start) > loginWindow {
|
||||
func (l *loginLimiter) blocked(ctx context.Context, key string, max int) bool {
|
||||
cutoff := time.Now().Unix() - int64(loginWindow.Seconds())
|
||||
var count int
|
||||
err := l.db.QueryRowContext(ctx, `
|
||||
SELECT count FROM rate_limit_counters
|
||||
WHERE key = $1 AND window_start > $2`,
|
||||
key, cutoff,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
// No row (never failed, or its window already expired): not blocked.
|
||||
// A real query error fails the same way — a rate limiter that locks
|
||||
// everyone out during a brief database hiccup is worse than one that
|
||||
// is briefly too generous.
|
||||
return false
|
||||
}
|
||||
return c.n >= max
|
||||
return count >= max
|
||||
}
|
||||
|
||||
func (l *loginLimiter) fail(keys ...string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
for k, c := range l.failures {
|
||||
if now.Sub(c.start) > loginWindow {
|
||||
delete(l.failures, k)
|
||||
}
|
||||
}
|
||||
func (l *loginLimiter) fail(ctx context.Context, keys ...string) {
|
||||
now := time.Now().Unix()
|
||||
windowSecs := int64(loginWindow.Seconds())
|
||||
for _, key := range keys {
|
||||
c, ok := l.failures[key]
|
||||
if !ok {
|
||||
c = &loginWindowCount{start: now}
|
||||
l.failures[key] = c
|
||||
if _, err := l.db.ExecContext(ctx, `
|
||||
INSERT INTO rate_limit_counters (key, window_start, count)
|
||||
VALUES ($1, $2, 1)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
window_start = CASE WHEN rate_limit_counters.window_start <= $2 - $3
|
||||
THEN $2 ELSE rate_limit_counters.window_start END,
|
||||
count = CASE WHEN rate_limit_counters.window_start <= $2 - $3
|
||||
THEN 1 ELSE rate_limit_counters.count + 1 END`,
|
||||
key, now, windowSecs,
|
||||
); err != nil {
|
||||
log.Printf("rate limiter: record failure for %q: %v", key, err) // #nosec G706 -- %q
|
||||
}
|
||||
c.n++
|
||||
}
|
||||
}
|
||||
|
||||
func (l *loginLimiter) clear(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.failures, key)
|
||||
func (l *loginLimiter) clear(ctx context.Context, key string) {
|
||||
if _, err := l.db.ExecContext(ctx, "DELETE FROM rate_limit_counters WHERE key = $1", key); err != nil {
|
||||
log.Printf("rate limiter: clear %q: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// purgeRateLimits deletes rate-limit windows that have expired, from the
|
||||
// sweeper — otherwise every distinct username and address this server has
|
||||
// ever seen a failed attempt from would stay a row forever.
|
||||
func purgeRateLimits(ctx context.Context, db *sql.DB) {
|
||||
cutoff := time.Now().Unix() - int64(loginWindow.Seconds())
|
||||
res, err := db.ExecContext(ctx,
|
||||
"DELETE FROM rate_limit_counters WHERE window_start <= $1", cutoff)
|
||||
if err != nil {
|
||||
log.Printf("sweeper: purge rate limit counters: %v", err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
log.Printf("sweeper: purged %d expired rate limit counter(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// clientAddr is the address a login is counted against. Behind the gateway
|
||||
@@ -171,6 +199,9 @@ func startSessionCapped(w http.ResponseWriter, r *http.Request, db *sql.DB, user
|
||||
return err
|
||||
}
|
||||
|
||||
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
|
||||
// cookieSecure(publicURL, r), not a literal true, which is what trips
|
||||
// this rule. See cookieSecure's own doc comment above.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: raw,
|
||||
@@ -198,7 +229,7 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
|
||||
userKey := "user:" + strings.ToLower(username)
|
||||
addrKey := "addr:" + clientAddr(r)
|
||||
|
||||
if limiter.blocked(userKey, loginMaxPerUser) || limiter.blocked(addrKey, loginMaxPerAddr) {
|
||||
if limiter.blocked(r.Context(), userKey, loginMaxPerUser) || limiter.blocked(r.Context(), addrKey, loginMaxPerAddr) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
|
||||
respond(w, http.StatusTooManyRequests, errResp("too many failed attempts, try again later"))
|
||||
return
|
||||
@@ -220,11 +251,11 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
|
||||
}
|
||||
match := bcrypt.CompareHashAndPassword(stored, []byte(req.Password)) == nil
|
||||
if !match || !hash.Valid {
|
||||
limiter.fail(userKey, addrKey)
|
||||
limiter.fail(r.Context(), userKey, addrKey)
|
||||
respond(w, http.StatusUnauthorized, errResp("invalid username or password"))
|
||||
return
|
||||
}
|
||||
limiter.clear(userKey)
|
||||
limiter.clear(r.Context(), userKey)
|
||||
|
||||
if err := startSession(w, r, db, userID, publicURL); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
@@ -252,6 +283,9 @@ func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
|
||||
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE token_hash = $1", hashToken(c.Value))
|
||||
}
|
||||
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
|
||||
// cookieSecure(publicURL, r), not a literal true, which is what
|
||||
// trips this rule. See cookieSecure's own doc comment above.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
@@ -291,9 +325,16 @@ func handleMe(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
var hash sql.NullString
|
||||
var dismissed *int64
|
||||
db.QueryRowContext(r.Context(),
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1",
|
||||
caller.ID).Scan(&hash, &dismissed)
|
||||
caller.ID).Scan(&hash, &dismissed); err != nil {
|
||||
// fetchUser above already found this row, so an error here is a
|
||||
// transient database problem, not a missing user — worth a 500
|
||||
// rather than silently answering "no password, not dismissed",
|
||||
// which a client would otherwise take at face value.
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, meResponse{
|
||||
User: user,
|
||||
HasPassword: hash.Valid,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// This file is the regression test for the pattern documented throughout
|
||||
// middleware.go: every team-scoped handler calls requireTeamMember or
|
||||
// requireTeamOwner before touching data, every self-or-admin handler calls
|
||||
// requireSelfOrAdmin, and every admin-only route sits behind AdminOnly. That
|
||||
// pattern is enforced by convention, not by the type system — a new handler
|
||||
// that forgets the call would compile and pass review on a quick read just
|
||||
// as easily as one that remembers it. These tests exercise every route that
|
||||
// carries one of those guards as a caller who should be refused, so a future
|
||||
// handler missing its guard fails CI instead of becoming a silent IDOR.
|
||||
|
||||
// TestAuthzScope_TeamScopedRoutesRefuseANonMember builds two teams and, for
|
||||
// every team-scoped route, calls it as team A's owner against team B's
|
||||
// resources. requireTeamMember and requireTeamOwner both answer a non-member
|
||||
// with 404 (team.go's own reasoning: whether a team exists is itself
|
||||
// something only its members should learn), so every one of these must come
|
||||
// back 404 regardless of which of the two guards its handler uses.
|
||||
func TestAuthzScope_TeamScopedRoutesRefuseANonMember(t *testing.T) {
|
||||
s := newTS(t)
|
||||
a := newTeam(t, s, "authz-a")
|
||||
b := newTeam(t, s, "authz-b")
|
||||
|
||||
// An incident in B, to cover the ID-based routes under /api/incidents —
|
||||
// scoped by the incident's own team_id rather than a {teamID} path
|
||||
// segment, but through the same single chokepoint (incidentIDParam).
|
||||
postToIntegration(t, s, b.key, "fp-authz-scope", "AuthzScopeAlert")
|
||||
var incidents []struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, b.call(http.MethodGet, "/api/incidents", nil), &incidents)
|
||||
if len(incidents) == 0 {
|
||||
t.Fatal("setup: no incident in team B to test against")
|
||||
}
|
||||
incidentPath := "/api/incidents/" + id64(incidents[0].ID)
|
||||
|
||||
bPath := "/api/teams/" + id64(b.id)
|
||||
tests := []struct {
|
||||
method, path string
|
||||
}{
|
||||
// Team membership/ownership itself.
|
||||
{http.MethodPut, bPath},
|
||||
{http.MethodDelete, bPath},
|
||||
{http.MethodGet, bPath + "/members"},
|
||||
{http.MethodPost, bPath + "/members"},
|
||||
{http.MethodDelete, bPath + "/members/1"},
|
||||
|
||||
// OIDC group binding.
|
||||
{http.MethodGet, bPath + "/oidc-groups"},
|
||||
{http.MethodPut, bPath + "/oidc-groups"},
|
||||
|
||||
// Invites.
|
||||
{http.MethodGet, bPath + "/invites"},
|
||||
{http.MethodPost, bPath + "/invites"},
|
||||
{http.MethodDelete, bPath + "/invites/1"},
|
||||
|
||||
// Escalation.
|
||||
{http.MethodGet, bPath + "/escalation"},
|
||||
{http.MethodPut, bPath + "/escalation"},
|
||||
|
||||
// Dead man's switches.
|
||||
{http.MethodGet, bPath + "/deadman/switches"},
|
||||
{http.MethodPost, bPath + "/deadman/switches"},
|
||||
{http.MethodPut, bPath + "/deadman/switches/1"},
|
||||
{http.MethodDelete, bPath + "/deadman/switches/1"},
|
||||
|
||||
// Integrations.
|
||||
{http.MethodGet, bPath + "/integrations"},
|
||||
{http.MethodPost, bPath + "/integrations"},
|
||||
{http.MethodPatch, bPath + "/integrations/1"},
|
||||
{http.MethodDelete, bPath + "/integrations/1"},
|
||||
|
||||
// Schedule.
|
||||
{http.MethodGet, bPath + "/schedule"},
|
||||
{http.MethodPost, bPath + "/schedule"},
|
||||
{http.MethodDelete, bPath + "/schedule/1"},
|
||||
|
||||
// Incidents, scoped by the incident's own team rather than a
|
||||
// {teamID} segment.
|
||||
{http.MethodGet, incidentPath},
|
||||
{http.MethodGet, incidentPath + "/alerts"},
|
||||
{http.MethodGet, incidentPath + "/timeline"},
|
||||
{http.MethodGet, incidentPath + "/similar"},
|
||||
{http.MethodPost, incidentPath + "/acknowledge"},
|
||||
{http.MethodDelete, incidentPath + "/acknowledge"},
|
||||
{http.MethodPost, incidentPath + "/resolve"},
|
||||
{http.MethodPost, incidentPath + "/assign"},
|
||||
{http.MethodPost, incidentPath + "/snooze"},
|
||||
{http.MethodDelete, incidentPath + "/snooze"},
|
||||
{http.MethodPost, incidentPath + "/archive"},
|
||||
{http.MethodDelete, incidentPath + "/archive"},
|
||||
{http.MethodPost, incidentPath + "/notes"},
|
||||
{http.MethodDelete, incidentPath + "/notes/1"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
resp := a.call(tc.method, tc.path, nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want %d (A is not a member of B)", resp.StatusCode, http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthzScope_AdminOnlyRoutesRefuseANonAdmin exercises AdminOnly's group
|
||||
// in router.go directly: a signed-in, non-admin caller gets 403 from every
|
||||
// route in it, before any handler body runs.
|
||||
func TestAuthzScope_AdminOnlyRoutesRefuseANonAdmin(t *testing.T) {
|
||||
s := newTS(t)
|
||||
a := newTeam(t, s, "authz-admin")
|
||||
|
||||
tests := []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodPost, "/api/users"},
|
||||
{http.MethodDelete, "/api/users/1"},
|
||||
{http.MethodPut, "/api/users/1/admin"},
|
||||
{http.MethodPut, "/api/users/1/disabled"},
|
||||
{http.MethodGet, "/api/admin/teams"},
|
||||
{http.MethodGet, "/api/admin/teams/" + id64(a.id)},
|
||||
{http.MethodGet, "/api/admin/settings"},
|
||||
{http.MethodPut, "/api/admin/settings"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
resp := a.call(tc.method, tc.path, nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d (not an admin)", resp.StatusCode, http.StatusForbidden)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthzScope_SelfOrAdminRoutesRefuseAnotherNonAdminUser exercises
|
||||
// requireSelfOrAdmin's call sites: a non-admin caller acting on a *different*
|
||||
// user's account must be refused, the same as AdminOnly's routes, even
|
||||
// though these sit in the general authenticated group rather than behind
|
||||
// AdminOnly itself.
|
||||
func TestAuthzScope_SelfOrAdminRoutesRefuseAnotherNonAdminUser(t *testing.T) {
|
||||
s := newTS(t)
|
||||
a := newTeam(t, s, "authz-self-a")
|
||||
b := newTeam(t, s, "authz-self-b")
|
||||
|
||||
var members []struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/teams/"+id64(b.id)+"/members", nil), &members)
|
||||
if len(members) == 0 {
|
||||
t.Fatal("setup: team B has no members")
|
||||
}
|
||||
bUserID := id64(members[0].UserID)
|
||||
|
||||
tests := []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodGet, "/api/users/" + bUserID + "/teams"},
|
||||
{http.MethodPut, "/api/users/" + bUserID + "/notify"},
|
||||
{http.MethodPut, "/api/users/" + bUserID + "/password"},
|
||||
{http.MethodGet, "/api/users/" + bUserID + "/api-keys"},
|
||||
{http.MethodPost, "/api/users/" + bUserID + "/api-keys"},
|
||||
{http.MethodDelete, "/api/users/" + bUserID + "/api-keys/1"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
resp := a.call(tc.method, tc.path, nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d (not self, not an admin)", resp.StatusCode, http.StatusForbidden)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -283,6 +283,10 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg deadmanSet
|
||||
nameList[i] = n
|
||||
}
|
||||
|
||||
// #nosec G202 -- sqlArgs.add/addList only ever splice in the "$N"
|
||||
// placeholder they hand back, never a value; every value travels through
|
||||
// args.all() as a bound parameter. See the sqlArgs doc comment in
|
||||
// helpers.go.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, team_id, fingerprint, labels, status, received_at
|
||||
FROM alerts
|
||||
|
||||
@@ -72,12 +72,12 @@ func normalizeUserCode(s string) string {
|
||||
func handleDeviceStart(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
addrKey := "device:" + clientAddr(r)
|
||||
if limiter.blocked(addrKey, deviceStartMaxPerAddr) {
|
||||
if limiter.blocked(r.Context(), addrKey, deviceStartMaxPerAddr) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
|
||||
respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later"))
|
||||
return
|
||||
}
|
||||
limiter.fail(addrKey)
|
||||
limiter.fail(r.Context(), addrKey)
|
||||
|
||||
deviceCode, deviceHash, err := randomToken()
|
||||
if err != nil {
|
||||
|
||||
@@ -72,8 +72,25 @@ func respond(w http.ResponseWriter, status int, v any) {
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// maxBodyBytes caps an ordinary JSON request body. 1 MiB is far more than any
|
||||
// endpoint below needs — it exists so an unauthenticated caller (signup,
|
||||
// login, bootstrap) can't make the server buffer an arbitrarily large body
|
||||
// before the request is even validated.
|
||||
const maxBodyBytes = 1 << 20
|
||||
|
||||
func decodeJSON(r *http.Request, v any) error {
|
||||
return decodeJSONLimit(r, v, maxBodyBytes)
|
||||
}
|
||||
|
||||
// decodeJSONLimit is decodeJSON with an explicit cap, for the one endpoint
|
||||
// (the Alertmanager webhook, see maxWebhookBodyBytes) whose real payloads can
|
||||
// legitimately be larger than maxBodyBytes.
|
||||
func decodeJSONLimit(r *http.Request, v any, limit int64) error {
|
||||
defer r.Body.Close()
|
||||
// w is nil: there is no ResponseWriter here to disable keep-alive with,
|
||||
// which net/http documents as fine — the limit is still enforced, the
|
||||
// connection just isn't closed early on a request that blows past it.
|
||||
r.Body = http.MaxBytesReader(nil, r.Body, limit)
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,28 @@ func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// securityHeaders sets headers that cost nothing to send on every response,
|
||||
// API or static site alike. nosniff is unconditional; HSTS only fires once
|
||||
// cookieSecure's signal says the browser is actually looking at this server
|
||||
// over HTTPS — TLS terminates at the gateway, which (as of this writing) sets
|
||||
// neither header itself.
|
||||
//
|
||||
// max-age is 180 days rather than the usual year-plus: short enough that if
|
||||
// HTTPS here ever broke for real, the header would age out of a browser's
|
||||
// cache well within a release cycle instead of locking anyone out of a
|
||||
// working server. Raise it once this has run clean for a while.
|
||||
func securityHeaders(publicURL string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if cookieSecure(publicURL, r) {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AdminOnly rejects a caller who is not a system administrator. It runs inside
|
||||
// AuthMiddleware's group, so by the time it sees a request the caller is known.
|
||||
//
|
||||
@@ -113,10 +135,16 @@ func requireSelfOrAdmin(w http.ResponseWriter, r *http.Request, targetID int64)
|
||||
}
|
||||
|
||||
// apiKeyUser resolves an API key to its user and stamps its last use.
|
||||
// expires_at IS NULL OR > now is part of the lookup itself, the same way
|
||||
// serveAs's disabled_at check is: an expired key is one that cannot
|
||||
// authenticate, by construction, rather than one that happens to still
|
||||
// resolve and has to be caught afterwards.
|
||||
func apiKeyUser(ctx context.Context, db *sql.DB, token string) (int64, bool) {
|
||||
var keyID, userID int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT id, user_id FROM api_keys WHERE key_hash = $1", hashToken(token),
|
||||
`SELECT id, user_id FROM api_keys
|
||||
WHERE key_hash = $1 AND (expires_at IS NULL OR expires_at > $2)`,
|
||||
hashToken(token), time.Now().Unix(),
|
||||
).Scan(&keyID, &userID)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
|
||||
+18
-6
@@ -121,12 +121,12 @@ func ssoRedirect(w http.ResponseWriter, r *http.Request, code ssoError) {
|
||||
func handleOIDCLogin(db *sql.DB, prov *oidc.Provider, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
addrKey := "oidc:" + clientAddr(r)
|
||||
if limiter.blocked(addrKey, oidcStartMaxPerAddr) {
|
||||
if limiter.blocked(r.Context(), addrKey, oidcStartMaxPerAddr) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
|
||||
respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later"))
|
||||
return
|
||||
}
|
||||
limiter.fail(addrKey)
|
||||
limiter.fail(r.Context(), addrKey)
|
||||
|
||||
state, stateHash, err := randomToken()
|
||||
if err != nil {
|
||||
@@ -160,6 +160,9 @@ func handleOIDCLogin(db *sql.DB, prov *oidc.Provider, limiter *loginLimiter, pub
|
||||
return
|
||||
}
|
||||
|
||||
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
|
||||
// cookieSecure(publicURL, r), not a literal true, which is what
|
||||
// trips this rule. See cookieSecure's own doc comment in auth.go.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: oidcStateCookie,
|
||||
Value: state,
|
||||
@@ -182,6 +185,9 @@ func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// The state cookie has done its job once the callback arrives, whatever
|
||||
// the outcome.
|
||||
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
|
||||
// cookieSecure(publicURL, r), not a literal true, which is what
|
||||
// trips this rule. See cookieSecure's own doc comment in auth.go.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: oidcStateCookie, Value: "", Path: "/api/oidc", MaxAge: -1,
|
||||
HttpOnly: true, Secure: cookieSecure(publicURL, r), SameSite: http.SameSiteLaxMode,
|
||||
@@ -189,7 +195,13 @@ func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.
|
||||
|
||||
q := r.URL.Query()
|
||||
if e := q.Get("error"); e != "" {
|
||||
log.Printf("oidc: provider returned error %q: %s", e, q.Get("error_description"))
|
||||
// %q on both: this runs before state is checked against the
|
||||
// cookie, so error and error_description are still whatever the
|
||||
// request's query string says, not yet known to be the real
|
||||
// provider's. %q keeps a crafted value (say, one holding a
|
||||
// newline) from forging a second log line rather than just
|
||||
// being a quoted string within this one.
|
||||
log.Printf("oidc: provider returned error %q: %q", e, q.Get("error_description")) // #nosec G706 -- both %q
|
||||
ssoRedirect(w, r, ssoDenied)
|
||||
return
|
||||
}
|
||||
@@ -226,7 +238,7 @@ func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.
|
||||
|
||||
grants := oidc.ComputeGrants(cfg, identity.Groups)
|
||||
if !grants.Admitted {
|
||||
log.Printf("oidc: %q (%s) is in none of the allowed groups", identity.Username, identity.Subject)
|
||||
log.Printf("oidc: %q (%q) is in none of the allowed groups", identity.Username, identity.Subject) // #nosec G706 -- both %q
|
||||
ssoRedirect(w, r, ssoNotAllowed)
|
||||
return
|
||||
}
|
||||
@@ -243,11 +255,11 @@ func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.
|
||||
if err != nil {
|
||||
var se ssoError
|
||||
if errors.As(err, &se) {
|
||||
log.Printf("oidc: refused %q (%s): %v", identity.Username, identity.Subject, se)
|
||||
log.Printf("oidc: refused %q (%q): %v", identity.Username, identity.Subject, se) // #nosec G706 -- both %q
|
||||
ssoRedirect(w, r, se)
|
||||
return
|
||||
}
|
||||
log.Printf("oidc: sign in %q: %v", identity.Username, err)
|
||||
log.Printf("oidc: sign in %q: %v", identity.Username, err) // #nosec G706 -- %q
|
||||
ssoRedirect(w, r, ssoFailed)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package api
|
||||
|
||||
// This file is internal (package api, not api_test) because loginLimiter and
|
||||
// its blocked/fail/clear methods are unexported, and TestLoginLimiter_SharedAcrossReplicas
|
||||
// specifically needs to construct two separate loginLimiter values pointed at
|
||||
// one database — standing in for two replicas — which only this package can
|
||||
// do. It duplicates testdb_test.go's newTestDB/withSearchPath rather than
|
||||
// importing them: those live in the separate api_test package, compiled from
|
||||
// this directory's external test files, and are not visible here. Same
|
||||
// reasoning as advisory_lock_test.go, which makes the same trade for the
|
||||
// same reason.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
var rateLimiterSchemaSeq int
|
||||
|
||||
// rateLimiterTestDB returns a migrated database private to this test.
|
||||
func rateLimiterTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
dsn := os.Getenv("TERDUT_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Fatalf("TERDUT_TEST_DSN is not set: these tests need Postgres.\n" +
|
||||
"Run `make test-db` for a local one, then\n" +
|
||||
" export TERDUT_TEST_DSN=postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable")
|
||||
}
|
||||
|
||||
rateLimiterSchemaSeq++
|
||||
schema := fmt.Sprintf("test_rl_%d_%d", os.Getpid(), rateLimiterSchemaSeq)
|
||||
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect to TERDUT_TEST_DSN: %v", err)
|
||||
}
|
||||
defer admin.Close()
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
t.Fatalf("create schema %s: %v", schema, err)
|
||||
}
|
||||
|
||||
database, err := db.Open(rateLimiterWithSearchPath(dsn, schema))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.Migrate(database); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
database.Close()
|
||||
cleanup, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cleanup.Close()
|
||||
if _, err := cleanup.Exec("DROP SCHEMA " + schema + " CASCADE"); err != nil {
|
||||
t.Logf("drop schema %s: %v", schema, err)
|
||||
}
|
||||
})
|
||||
|
||||
return database
|
||||
}
|
||||
|
||||
func rateLimiterWithSearchPath(dsn, schema string) string {
|
||||
opt := "-csearch_path=" + schema
|
||||
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||
u, err := url.Parse(dsn)
|
||||
if err == nil {
|
||||
q := u.Query()
|
||||
q.Set("options", opt)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
}
|
||||
return dsn + " options='" + opt + "'"
|
||||
}
|
||||
|
||||
func TestLoginLimiter_BlocksAtMax(t *testing.T) {
|
||||
database := rateLimiterTestDB(t)
|
||||
ctx := context.Background()
|
||||
l := newLoginLimiter(database)
|
||||
|
||||
for range 3 {
|
||||
if l.blocked(ctx, "k", 3) {
|
||||
t.Fatal("blocked before reaching max")
|
||||
}
|
||||
l.fail(ctx, "k")
|
||||
}
|
||||
if !l.blocked(ctx, "k", 3) {
|
||||
t.Fatal("not blocked after reaching max")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiter_ClearResetsTheCount(t *testing.T) {
|
||||
database := rateLimiterTestDB(t)
|
||||
ctx := context.Background()
|
||||
l := newLoginLimiter(database)
|
||||
|
||||
l.fail(ctx, "k")
|
||||
l.fail(ctx, "k")
|
||||
l.clear(ctx, "k")
|
||||
|
||||
if l.blocked(ctx, "k", 1) {
|
||||
t.Fatal("still blocked after clear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiter_KeysAreIndependent(t *testing.T) {
|
||||
database := rateLimiterTestDB(t)
|
||||
ctx := context.Background()
|
||||
l := newLoginLimiter(database)
|
||||
|
||||
l.fail(ctx, "a")
|
||||
if l.blocked(ctx, "b", 1) {
|
||||
t.Fatal("failing one key blocked an unrelated one")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginLimiter_SharedAcrossReplicas is the regression test for the gap
|
||||
// this migration closes: an in-memory limiter would let each replica count
|
||||
// independently, so a caller hitting two different pods could rack up
|
||||
// max*replicaCount failures before either one blocked. Two loginLimiter
|
||||
// values sharing one database, standing in for two replicas behind the same
|
||||
// load balancer, must instead see one combined count.
|
||||
func TestLoginLimiter_SharedAcrossReplicas(t *testing.T) {
|
||||
database := rateLimiterTestDB(t)
|
||||
ctx := context.Background()
|
||||
replicaA := newLoginLimiter(database)
|
||||
replicaB := newLoginLimiter(database)
|
||||
|
||||
const max = 4
|
||||
// Alternate which "replica" records the failure, as a real deployment
|
||||
// would split requests across pods.
|
||||
for i := range max {
|
||||
replica := replicaA
|
||||
if i%2 == 1 {
|
||||
replica = replicaB
|
||||
}
|
||||
if replicaA.blocked(ctx, "k", max) || replicaB.blocked(ctx, "k", max) {
|
||||
t.Fatalf("blocked after only %d of %d failures", i, max)
|
||||
}
|
||||
replica.fail(ctx, "k")
|
||||
}
|
||||
|
||||
if !replicaA.blocked(ctx, "k", max) {
|
||||
t.Fatal("replica A does not see the combined count as blocked")
|
||||
}
|
||||
if !replicaB.blocked(ctx, "k", max) {
|
||||
t.Fatal("replica B does not see the combined count as blocked")
|
||||
}
|
||||
}
|
||||
@@ -23,13 +23,14 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
|
||||
// One limiter each, both process-wide for the life of the router: login
|
||||
// counts failed passwords, sign-up counts account creation, and mixing the
|
||||
// two would let a burst of sign-ups lock somebody out of logging in.
|
||||
loginLimit := newLoginLimiter()
|
||||
signupLimiter := newLoginLimiter()
|
||||
oidcLimit := newLoginLimiter()
|
||||
loginLimit := newLoginLimiter(db)
|
||||
signupLimiter := newLoginLimiter(db)
|
||||
oidcLimit := newLoginLimiter(db)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(securityHeaders(notify.PublicURL))
|
||||
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
@@ -111,6 +112,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
|
||||
r.Get("/api/users/{id}/teams", handleUserTeams(db))
|
||||
r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db))
|
||||
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
||||
r.Get("/api/users/{id}/api-keys", handleListAPIKeys(db))
|
||||
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
||||
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db))
|
||||
|
||||
|
||||
@@ -235,6 +235,9 @@ func scheduleRange(ctx context.Context, db *sql.DB, teamID int64, from, to strin
|
||||
|
||||
clause := strings.Join(where, " AND ")
|
||||
|
||||
// #nosec G202 -- clause is built from sqlArgs.add's "$N" placeholders
|
||||
// only, never a value; every value travels through args.all() as a
|
||||
// bound parameter. See the sqlArgs doc comment in helpers.go.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at
|
||||
FROM schedule_entries s
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security headers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSecurityHeaders_NosniffAlwaysSet(t *testing.T) {
|
||||
s := newTS(t) // no PublicURL: the HTTPS signal is off
|
||||
resp := s.req(t, http.MethodGet, "/api/me", nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
|
||||
}
|
||||
if got := resp.Header.Get("Strict-Transport-Security"); got != "" {
|
||||
t.Errorf("Strict-Transport-Security = %q, want unset without an https PublicURL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders_HSTSWhenPublicURLIsHTTPS(t *testing.T) {
|
||||
s := newTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||
resp := s.req(t, http.MethodGet, "/api/me", nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
got := resp.Header.Get("Strict-Transport-Security")
|
||||
if !strings.HasPrefix(got, "max-age=") || !strings.Contains(got, "includeSubDomains") {
|
||||
t.Errorf("Strict-Transport-Security = %q, want a max-age with includeSubDomains", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request body size limits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestBodySizeLimit_OrdinaryEndpointRejectsOversizedBody confirms an
|
||||
// unauthenticated endpoint can't be made to buffer an arbitrarily large body:
|
||||
// past maxBodyBytes, decodeJSON fails exactly as it would on any other
|
||||
// malformed body, rather than the server reading the whole thing first.
|
||||
func TestBodySizeLimit_OrdinaryEndpointRejectsOversizedBody(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
huge := bytes.Repeat([]byte("a"), 2<<20) // 2 MiB, past the 1 MiB default
|
||||
body := []byte(`{"username":"` + string(huge) + `","password":"x"}`)
|
||||
|
||||
resp, err := http.Post(s.URL+"/api/login", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("POST /api/login: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d (oversized body treated as invalid)", resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodySizeLimit_WebhookAllowsLargerBodyThanDefault confirms the
|
||||
// Alertmanager webhook's separate, larger cap actually takes effect: a body
|
||||
// bigger than the ordinary default but within maxWebhookBodyBytes is still
|
||||
// accepted, not rejected by the smaller limit every other endpoint gets.
|
||||
func TestBodySizeLimit_WebhookAllowsLargerBodyThanDefault(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
// Padding kept inside one alert's annotation, comfortably past the 1 MiB
|
||||
// default and still well under the webhook's 8 MiB cap.
|
||||
padding := strings.Repeat("a", 3<<20) // 3 MiB
|
||||
payload := `{"version":"4","status":"firing","groupKey":"big-group",` +
|
||||
`"groupLabels":{"alertname":"BigAlert"},"alerts":[{"status":"firing",` +
|
||||
`"labels":{"alertname":"BigAlert"},"annotations":{"note":"` + padding + `"},` +
|
||||
`"startsAt":"2026-05-20T10:00:00Z","endsAt":"0001-01-01T00:00:00Z",` +
|
||||
`"fingerprint":"fp-big"}]}`
|
||||
|
||||
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||
"application/json", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("POST webhook: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d (body under the webhook's own cap)", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodySizeLimit_WebhookRejectsPastItsOwnCap confirms the webhook's larger
|
||||
// cap is still a cap, not an exemption from one.
|
||||
func TestBodySizeLimit_WebhookRejectsPastItsOwnCap(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
huge := strings.Repeat("a", 9<<20) // 9 MiB, past the 8 MiB webhook cap
|
||||
payload := `{"version":"4","status":"firing","groupKey":"huge-group",` +
|
||||
`"groupLabels":{"alertname":"HugeAlert"},"alerts":[{"status":"firing",` +
|
||||
`"labels":{"alertname":"HugeAlert"},"annotations":{"note":"` + huge + `"},` +
|
||||
`"startsAt":"2026-05-20T10:00:00Z","endsAt":"0001-01-01T00:00:00Z",` +
|
||||
`"fingerprint":"fp-huge"}]}`
|
||||
|
||||
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||
"application/json", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("POST webhook: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d (body past the webhook's own cap)", resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ var errInviteUnusable = errors.New("invite is not usable")
|
||||
func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
addr := clientAddr(r)
|
||||
if limiter.blocked("signup:"+addr, maxSignupsPerAddr) {
|
||||
if limiter.blocked(r.Context(), "signup:"+addr, maxSignupsPerAddr) {
|
||||
respond(w, http.StatusTooManyRequests, errResp("too many sign-ups from this address"))
|
||||
return
|
||||
}
|
||||
@@ -148,7 +148,7 @@ func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.Hand
|
||||
var err error
|
||||
inv, err = loadInvite(r.Context(), db, req.Invite)
|
||||
if err != nil {
|
||||
limiter.fail("signup:" + addr)
|
||||
limiter.fail(r.Context(), "signup:"+addr)
|
||||
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
|
||||
return
|
||||
}
|
||||
|
||||
+76
-3
@@ -234,6 +234,11 @@ func handleDeleteUser(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// maxAPIKeyExpiryDays bounds expires_in_days: generous enough for any real
|
||||
// rotation policy, tight enough to reject a typo (a year in hours, say) that
|
||||
// would otherwise mint a key that outlives the server by decades.
|
||||
const maxAPIKeyExpiryDays = 3650 // ~10 years
|
||||
|
||||
func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
@@ -247,6 +252,11 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
// ExpiresInDays is optional and, left zero, means the key never
|
||||
// expires — the only behavior any key had before this field
|
||||
// existed, so an existing integration that does not send it is
|
||||
// unaffected.
|
||||
ExpiresInDays int64 `json:"expires_in_days,omitempty"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
@@ -256,6 +266,10 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||
return
|
||||
}
|
||||
if req.ExpiresInDays < 0 || req.ExpiresInDays > maxAPIKeyExpiryDays {
|
||||
respond(w, http.StatusBadRequest, errResp("expires_in_days must be 0 (never expires) or up to "+strconv.Itoa(maxAPIKeyExpiryDays)))
|
||||
return
|
||||
}
|
||||
|
||||
var exists int
|
||||
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = $1", userID).Scan(&exists); err != nil {
|
||||
@@ -268,18 +282,77 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
var expiresAt *int64
|
||||
var expiresAtTime *time.Time
|
||||
if req.ExpiresInDays > 0 {
|
||||
t := time.Now().AddDate(0, 0, int(req.ExpiresInDays)).UTC()
|
||||
u := t.Unix()
|
||||
expiresAt = &u
|
||||
expiresAtTime = &t
|
||||
}
|
||||
var keyID int64
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id",
|
||||
userID, hash, req.Name).Scan(&keyID); err != nil {
|
||||
"INSERT INTO api_keys (user_id, key_hash, name, expires_at) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
userID, hash, req.Name, expiresAt).Scan(&keyID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
key := models.APIKey{ID: keyID, UserID: userID, Name: req.Name, Key: raw, CreatedAt: time.Now().UTC()}
|
||||
key := models.APIKey{
|
||||
ID: keyID, UserID: userID, Name: req.Name, Key: raw,
|
||||
CreatedAt: time.Now().UTC(), ExpiresAt: expiresAtTime,
|
||||
}
|
||||
respond(w, http.StatusCreated, key)
|
||||
}
|
||||
}
|
||||
|
||||
// handleListAPIKeys lists a user's own API keys: never the raw key itself
|
||||
// (only ever returned once, at creation), just enough to tell them apart,
|
||||
// see which are stale (last_used_at) and which are about to stop working
|
||||
// (expires_at) — the data handleCreateAPIKey and apiKeyUser's last-use stamp
|
||||
// already produce, with no endpoint to read it back until now.
|
||||
func handleListAPIKeys(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||
return
|
||||
}
|
||||
if !requireSelfOrAdmin(w, r, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(r.Context(),
|
||||
`SELECT id, name, created_at, last_used_at, expires_at
|
||||
FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC`, userID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := []models.APIKey{}
|
||||
for rows.Next() {
|
||||
var k models.APIKey
|
||||
var created int64
|
||||
var lastUsed, expires *int64
|
||||
if err := rows.Scan(&k.ID, &k.Name, &created, &lastUsed, &expires); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
k.UserID = userID
|
||||
k.CreatedAt = time.Unix(created, 0).UTC()
|
||||
k.LastUsedAt = unixPtr(lastUsed)
|
||||
k.ExpiresAt = unixPtr(expires)
|
||||
keys = append(keys, k)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, keys)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Backs the rate limiters (failed logins, sign-ups, OIDC/device start) with
|
||||
-- Postgres instead of an in-memory map, now that the server runs more than
|
||||
-- one replica in production (v0.37.0): a counter that only ever sees its own
|
||||
-- pod's traffic quietly let every one of these limits through multiplied by
|
||||
-- the replica count.
|
||||
--
|
||||
-- window_start is the start of the current fixed window for key, in the same
|
||||
-- "unix seconds" shape every other timestamp in this schema uses. The window
|
||||
-- resets rather than slides, matching the in-memory limiter it replaces:
|
||||
-- once a key's window is older than the limiter's window length, the next
|
||||
-- failure starts a fresh one instead of extending the stale one.
|
||||
CREATE TABLE rate_limit_counters (
|
||||
key TEXT PRIMARY KEY,
|
||||
window_start BIGINT NOT NULL,
|
||||
count INT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Optional expiry on a user's own API keys. NULL (the existing default for
|
||||
-- every row already in this table) means "never expires" -- the same
|
||||
-- behavior these keys have always had, so no existing integration breaks.
|
||||
-- Service account keys are deliberately NOT touched: they are a different
|
||||
-- table, managed by automation, and already distinguished by their own
|
||||
-- "tdsa_" prefix.
|
||||
ALTER TABLE api_keys ADD COLUMN expires_at BIGINT;
|
||||
@@ -37,5 +37,13 @@ type APIKey struct {
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
Key string `json:"key,omitempty"` // populated only on creation, never stored
|
||||
|
||||
// ExpiresAt is nil for a key that never expires, which is every key
|
||||
// created before this field existed and still the default for a new one
|
||||
// unless its creator asks otherwise (see handleCreateAPIKey's
|
||||
// expires_in_days). AuthMiddleware stops accepting a key once this
|
||||
// passes; nothing deletes the row for it.
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
|
||||
Key string `json:"key,omitempty"` // populated only on creation, never stored
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user