Added panic mode protections to make the server more secure
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"log"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -55,6 +56,9 @@ type Server struct {
|
||||
signingKey []byte
|
||||
discord DiscordProvider
|
||||
|
||||
// rate limiter
|
||||
rl *rateLimiter
|
||||
|
||||
// device-key challenge cache
|
||||
chMu sync.Mutex
|
||||
chal map[string]time.Time // nonce -> expires
|
||||
@@ -70,7 +74,7 @@ type Server struct {
|
||||
|
||||
func New(store BlobStore, idx *index.Index, coarseTS, zeroTrust bool, providers AuthProviders, encRequired bool) *Server {
|
||||
key, _ := hex.DecodeString(strings.TrimSpace(providers.SigningSecretHex))
|
||||
return &Server{
|
||||
s := &Server{
|
||||
store: store,
|
||||
idx: idx,
|
||||
zeroTrust: zeroTrust,
|
||||
@@ -83,6 +87,9 @@ func New(store BlobStore, idx *index.Index, coarseTS, zeroTrust bool, providers
|
||||
replay: make(map[string]time.Time),
|
||||
sseSub: make(map[chan string]struct{}),
|
||||
}
|
||||
// Default limiter: ~2 req/sec, burst 20, 10 min idle eviction
|
||||
s.rl = newRateLimiter(2.0, 20, 10*time.Minute)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) ListenHTTP(addr string) error {
|
||||
@@ -108,19 +115,22 @@ func (s *Server) routes() *http.ServeMux {
|
||||
mux.HandleFunc("/v1/auth/key/verify", s.handleAuthKeyVerify)
|
||||
|
||||
// Objects
|
||||
mux.HandleFunc("/v1/object", s.handlePutObject) // PUT
|
||||
mux.HandleFunc("/v1/object/", s.handleObjectByHash) // GET/DELETE
|
||||
mux.HandleFunc("/v1/object", s.handlePutObject) // PUT (requires bearer+PoP)
|
||||
mux.HandleFunc("/v1/object/", s.handleObjectByHash) // GET/DELETE (DELETE requires bearer+PoP)
|
||||
|
||||
// Index
|
||||
mux.HandleFunc("/v1/index", s.handleIndexList)
|
||||
mux.HandleFunc("/v1/index/stream", s.handleIndexStream)
|
||||
|
||||
// Admin
|
||||
// Admin (requires admin device sub)
|
||||
mux.HandleFunc("/v1/admin/reindex", s.handleAdminReindex)
|
||||
|
||||
// GDPR
|
||||
mux.HandleFunc("/v1/gdpr/policy", s.handleGDPRPolicy)
|
||||
|
||||
// Session panic wipe (no auth; clears browser caches)
|
||||
mux.HandleFunc("/v1/session/clear", s.handleSessionClear)
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -138,14 +148,42 @@ func (s *Server) cors(next http.Handler) http.Handler {
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload")
|
||||
|
||||
// Preflight is free (don’t rate-limit OPTIONS)
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limit (by device key CNF when available; else IP)
|
||||
key := ""
|
||||
if b := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "); b != "" {
|
||||
if ac, err := s.parseBearer(b); err == nil && ac.CNF != "" {
|
||||
key = ac.CNF
|
||||
}
|
||||
}
|
||||
if key == "" {
|
||||
key = "ip:" + clientIP(r)
|
||||
}
|
||||
if !s.rl.allow(key) {
|
||||
w.Header().Set("Retry-After", "5")
|
||||
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionClear(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Clear-Site-Data", `"cache","storage"`)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
func b64u(b []byte) string {
|
||||
@@ -159,6 +197,21 @@ func b64ud(s string) ([]byte, error) {
|
||||
}
|
||||
func nowUTC() time.Time { return time.Now().UTC() }
|
||||
|
||||
// Admin allow-list: env GC_ADMIN_SUBS="sub1,sub2"
|
||||
func (s *Server) isAdmin(ac authContext) bool {
|
||||
raw := strings.TrimSpace(os.Getenv("GC_ADMIN_SUBS"))
|
||||
if raw == "" || ac.CNF == "" {
|
||||
return false
|
||||
}
|
||||
sub := thumbprintFromCNF(ac.CNF)
|
||||
for _, x := range strings.Split(raw, ",") {
|
||||
if sub == strings.TrimSpace(x) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---------- Health ----------
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -202,7 +255,7 @@ type keyVerifyReq struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Alg string `json:"alg"` // "p256"
|
||||
Pub string `json:"pub"` // base64url raw (uncompressed point, 65 bytes)
|
||||
Sig string `json:"sig"` // base64url DER(ECDSA)
|
||||
Sig string `json:"sig"` // base64url DER(ECDSA) or raw r||s (64B)
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthKeyVerify(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -246,7 +299,7 @@ func (s *Server) handleAuthKeyVerify(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(http.StatusBadRequest, "bad pub")
|
||||
return
|
||||
}
|
||||
sigDER, err := b64ud(req.Sig)
|
||||
sig, err := b64ud(req.Sig)
|
||||
if err != nil {
|
||||
writeErr(http.StatusBadRequest, "bad sig")
|
||||
return
|
||||
@@ -256,8 +309,8 @@ func (s *Server) handleAuthKeyVerify(w http.ResponseWriter, r *http.Request) {
|
||||
x := new(big.Int).SetBytes(pubRaw[1:33])
|
||||
y := new(big.Int).SetBytes(pubRaw[33:])
|
||||
ek := &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
|
||||
if !ecdsaVerify(ek, msg, sigDER) {
|
||||
http.Error(w, "verify failed", http.StatusUnauthorized)
|
||||
if !ecdsaVerify(ek, msg, sig) {
|
||||
writeErr(http.StatusUnauthorized, "verify failed")
|
||||
return
|
||||
}
|
||||
// mint token
|
||||
@@ -293,13 +346,13 @@ func ecdsaVerify(pub *ecdsa.PublicKey, msg []byte, sig []byte) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Minimal DER parser that tolerates long-form lengths and leading 0x00 in INTEGERs.
|
||||
// Minimal DER parser tolerant to long-form lengths and leading 0x00.
|
||||
func parseECDSADER(der []byte) (*big.Int, *big.Int, bool) {
|
||||
if len(der) < 8 || der[0] != 0x30 {
|
||||
return nil, nil, false
|
||||
}
|
||||
i := 1
|
||||
// Read SEQUENCE length (short or long form)
|
||||
// sequence len
|
||||
if i >= len(der) {
|
||||
return nil, nil, false
|
||||
}
|
||||
@@ -318,12 +371,10 @@ func parseECDSADER(der []byte) (*big.Int, *big.Int, bool) {
|
||||
seqLen = int(der[i])
|
||||
i++
|
||||
}
|
||||
if i >= len(der) {
|
||||
return nil, nil, false
|
||||
}
|
||||
_ = seqLen // not strictly needed
|
||||
|
||||
// INTEGER R
|
||||
if der[i] != 0x02 {
|
||||
if i >= len(der) || der[i] != 0x02 {
|
||||
return nil, nil, false
|
||||
}
|
||||
i++
|
||||
@@ -337,6 +388,7 @@ func parseECDSADER(der []byte) (*big.Int, *big.Int, bool) {
|
||||
}
|
||||
rb := der[i : i+rLen]
|
||||
i += rLen
|
||||
|
||||
// INTEGER S
|
||||
if i >= len(der) || der[i] != 0x02 {
|
||||
return nil, nil, false
|
||||
@@ -418,7 +470,6 @@ func (s *Server) gc2Mint(cnf string, exp time.Time) (string, error) {
|
||||
}
|
||||
hdr := map[string]string{"alg": "HS256", "typ": "GC2"}
|
||||
iat := nowUTC().Unix()
|
||||
// Fix: assign the sum before slicing to avoid "unaddressable value"
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
|
||||
jti := b64u(sum[:16])
|
||||
|
||||
@@ -480,7 +531,8 @@ func (s *Server) verifyPoP(w http.ResponseWriter, r *http.Request, ac authContex
|
||||
return false
|
||||
}
|
||||
now := nowUTC().Unix()
|
||||
if ts < now-600 || ts > now+600 {
|
||||
// Tight window: ±120s
|
||||
if ts < now-120 || ts > now+120 {
|
||||
http.Error(w, "ts window", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
@@ -496,7 +548,7 @@ func (s *Server) verifyPoP(w http.ResponseWriter, r *http.Request, ac authContex
|
||||
http.Error(w, "replay", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
s.replay[proof] = nowUTC().Add(10 * time.Minute)
|
||||
s.replay[proof] = nowUTC().Add(2 * time.Minute)
|
||||
s.rpMu.Unlock()
|
||||
|
||||
// Verify signature over METHOD \n PATH \n TS \n SHA256(bodyHex)
|
||||
@@ -513,7 +565,7 @@ func (s *Server) verifyPoP(w http.ResponseWriter, r *http.Request, ac authContex
|
||||
http.Error(w, "bad key", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
sigDER, err := b64ud(proof)
|
||||
sig, err := b64ud(proof)
|
||||
if err != nil {
|
||||
http.Error(w, "bad proof", http.StatusUnauthorized)
|
||||
return false
|
||||
@@ -521,7 +573,7 @@ func (s *Server) verifyPoP(w http.ResponseWriter, r *http.Request, ac authContex
|
||||
x := new(big.Int).SetBytes(pubRaw[1:33])
|
||||
y := new(big.Int).SetBytes(pubRaw[33:])
|
||||
ek := &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
|
||||
if !ecdsaVerify(ek, msg, sigDER) {
|
||||
if !ecdsaVerify(ek, msg, sig) {
|
||||
http.Error(w, "pop verify", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
@@ -706,7 +758,6 @@ func (s *Server) sseBroadcastJSON(v any) {
|
||||
select {
|
||||
case ch <- string(b):
|
||||
default:
|
||||
// drop if slow
|
||||
}
|
||||
}
|
||||
s.sseMu.Unlock()
|
||||
@@ -720,7 +771,8 @@ func (s *Server) handleAdminReindex(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
ac, _ := s.parseBearer(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
if s.requirePOP && !s.verifyPoP(w, r, ac, nil) {
|
||||
if !s.verifyPoP(w, r, ac, nil) || !s.isAdmin(ac) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var walked, indexed int64
|
||||
|
Reference in New Issue
Block a user