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
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:
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user