Harden the surface: 0600 databases, checksum-verified installs, rate-limited login and key exchange
coordinator / test (push) Canceled after 0s
python / test (push) Canceled after 0s
release / binaries (amd64, darwin) (push) Canceled after 0s
release / binaries (amd64, linux) (push) Canceled after 0s
release / binaries (amd64, windows) (push) Canceled after 0s
release / binaries (arm64, darwin) (push) Canceled after 0s
release / binaries (arm64, linux) (push) Canceled after 0s
release / binaries (arm64, windows) (push) Canceled after 0s
release / wheel (push) Canceled after 0s
release / image (push) Canceled after 0s
users / test (push) Canceled after 0s
release / release (push) Canceled after 0s

This commit is contained in:
Emil
2026-08-03 18:40:27 +03:00
parent 330f95a375
commit 049113cec8
10 changed files with 378 additions and 4 deletions
@@ -7,6 +7,7 @@ import (
"database/sql"
"errors"
"fmt"
"os"
"time"
_ "modernc.org/sqlite"
@@ -290,6 +291,17 @@ func indexOf(haystack, needle string) int {
}
// Open opens (and creates when missing) the userservice database file.
func lockDownDatabase(path string) error {
for _, candidate := range []string{path, path + "-wal", path + "-shm"} {
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
if err := os.Chmod(candidate, 0o600); err != nil {
return err
}
}
}
return nil
}
func Open(path string) (*sql.DB, error) {
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)", path)
db, err := sql.Open("sqlite", dsn)
@@ -300,5 +312,9 @@ func Open(path string) (*sql.DB, error) {
_ = db.Close()
return nil, fmt.Errorf("ping userservice database: %w", err)
}
if err := lockDownDatabase(path); err != nil {
_ = db.Close()
return nil, fmt.Errorf("lock down userservice database: %w", err)
}
return db, nil
}
@@ -0,0 +1,97 @@
package http
import (
"net"
"net/http"
"sync"
"time"
)
// The same per-address token-bucket policy as the coordinator transport:
// login is the credential brute-force surface, the exchange the only public
// token-minting one.
const (
loginRatePerMinute = 10
loginBurst = 5
exchangeRatePerMinute = 30
exchangeBurst = 10
)
type tokenBucket struct {
mu sync.Mutex
tokens float64
last time.Time
rate float64
burst float64
}
func newTokenBucket(ratePerMinute, burst float64) *tokenBucket {
return &tokenBucket{tokens: burst, last: time.Now(), rate: ratePerMinute / 60, burst: burst}
}
func (b *tokenBucket) allow() bool {
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
b.tokens += now.Sub(b.last).Seconds() * b.rate
if b.tokens > b.burst {
b.tokens = b.burst
}
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
type ipLimiter struct {
mu sync.Mutex
buckets map[string]*tokenBucket
ratePerMinute float64
burst float64
}
func newIPLimiter(ratePerMinute, burst float64) *ipLimiter {
return &ipLimiter{buckets: map[string]*tokenBucket{}, ratePerMinute: ratePerMinute, burst: burst}
}
func (l *ipLimiter) Allow(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
l.mu.Lock()
if len(l.buckets) > 1024 {
cutoff := time.Now().Add(-10 * time.Minute)
for addr, bucket := range l.buckets {
bucket.mu.Lock()
idle := bucket.last.Before(cutoff)
bucket.mu.Unlock()
if idle {
delete(l.buckets, addr)
}
}
}
bucket, ok := l.buckets[host]
if !ok {
bucket = newTokenBucket(l.ratePerMinute, l.burst)
l.buckets[host] = bucket
}
l.mu.Unlock()
return bucket.allow()
}
func rateLimited(limiter *ipLimiter, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow(r) {
w.Header().Set("Retry-After", "60")
writeJSON(w, http.StatusTooManyRequests, errorResponse{
Error: "too many requests, try again shortly",
RequestID: requestIDFrom(r.Context()),
})
return
}
next.ServeHTTP(w, r)
})
}
@@ -51,14 +51,14 @@ func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler {
// Method-aware patterns (Go 1.22+): a GET to /register is a 405, not a match.
mux.HandleFunc("GET /health", h.handleHealth)
mux.HandleFunc("POST /register", h.handleRegister)
mux.HandleFunc("POST /login", h.handleLogin)
mux.Handle("POST /login", rateLimited(newIPLimiter(loginRatePerMinute, loginBurst), http.HandlerFunc(h.handleLogin)))
// /me proves a token round-trips; it sits behind JWT auth.
mux.Handle("GET /me", chain(http.HandlerFunc(h.handleMe), withJWT(issuer)))
// Worker keys: a user mints a long-lived key (JWT-protected), and a worker
// trades it for a short-lived JWT on the public exchange endpoint — the key
// itself is the credential there, so no prior token is required.
mux.HandleFunc("POST /worker-tokens/exchange", h.handleExchangeWorkerKey)
mux.Handle("POST /worker-tokens/exchange", rateLimited(newIPLimiter(exchangeRatePerMinute, exchangeBurst), http.HandlerFunc(h.handleExchangeWorkerKey)))
mux.Handle("POST /worker-keys", chain(http.HandlerFunc(h.handleCreateWorkerKey), withJWT(issuer)))
mux.Handle("GET /worker-keys", chain(http.HandlerFunc(h.handleListWorkerKeys), withJWT(issuer)))
mux.Handle("DELETE /worker-keys/{id}", chain(http.HandlerFunc(h.handleRevokeWorkerKey), withJWT(issuer)))