fix(server): constant-time secret comparison, request size limits
Replace string equality checks on cluster secret with crypto/subtle.ConstantTimeCompare to prevent timing attacks. Add http.MaxBytesReader (1MB) to all POST endpoints that decode JSON bodies. Change Start() to return *http.Server for graceful shutdown support. Replace log.Fatalf with log.Printf in HTTP server goroutine.
This commit is contained in:
+22
-11
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"go-upkeep/internal/importer"
|
"go-upkeep/internal/importer"
|
||||||
@@ -15,6 +16,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func checkSecret(got, want string) bool {
|
||||||
|
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
var statusTpl = template.Must(template.New("status").Parse(`
|
var statusTpl = template.Must(template.New("status").Parse(`
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -153,7 +158,7 @@ type ServerConfig struct {
|
|||||||
ClusterKey string // Shared Secret for Security
|
ClusterKey string // Shared Secret for Security
|
||||||
}
|
}
|
||||||
|
|
||||||
func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
|
func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
||||||
if cfg.ClusterKey == "" {
|
if cfg.ClusterKey == "" {
|
||||||
fmt.Println("WARNING: No UPKEEP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.")
|
fmt.Println("WARNING: No UPKEEP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.")
|
||||||
}
|
}
|
||||||
@@ -176,7 +181,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
|
|||||||
|
|
||||||
// 2. Health Check (For Cluster Follower)
|
// 2. Health Check (For Cluster Follower)
|
||||||
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if cfg.ClusterKey != "" && r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey {
|
if cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
|
||||||
http.Error(w, "Unauthorized", 401)
|
http.Error(w, "Unauthorized", 401)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -186,7 +191,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
|
|||||||
|
|
||||||
// 3. Config Export
|
// 3. Config Export
|
||||||
mux.HandleFunc("/api/backup/export", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/backup/export", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey {
|
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
|
||||||
http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", 401)
|
http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", 401)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -205,10 +210,11 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
|
|||||||
http.Error(w, "POST required", 405)
|
http.Error(w, "POST required", 405)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey {
|
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
|
||||||
http.Error(w, "Unauthorized", 401)
|
http.Error(w, "Unauthorized", 401)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||||
var data models.Backup
|
var data models.Backup
|
||||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||||
http.Error(w, "Invalid JSON", 400)
|
http.Error(w, "Invalid JSON", 400)
|
||||||
@@ -228,10 +234,11 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
|
|||||||
http.Error(w, "POST required", 405)
|
http.Error(w, "POST required", 405)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey {
|
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
|
||||||
http.Error(w, "Unauthorized", 401)
|
http.Error(w, "Unauthorized", 401)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||||
var kb importer.KumaBackup
|
var kb importer.KumaBackup
|
||||||
if err := json.NewDecoder(r.Body).Decode(&kb); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&kb); err != nil {
|
||||||
log.Printf("Invalid Kuma JSON: %v", err)
|
log.Printf("Invalid Kuma JSON: %v", err)
|
||||||
@@ -253,10 +260,11 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
|
|||||||
http.Error(w, "POST required", 405)
|
http.Error(w, "POST required", 405)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey {
|
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
|
||||||
http.Error(w, "Unauthorized", 401)
|
http.Error(w, "Unauthorized", 401)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||||
var req struct {
|
var req struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -283,7 +291,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
|
|||||||
|
|
||||||
// 7. Probe Assignment Fetch
|
// 7. Probe Assignment Fetch
|
||||||
mux.HandleFunc("/api/probe/assignments", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/probe/assignments", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey {
|
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
|
||||||
http.Error(w, "Unauthorized", 401)
|
http.Error(w, "Unauthorized", 401)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -324,10 +332,11 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
|
|||||||
http.Error(w, "POST required", 405)
|
http.Error(w, "POST required", 405)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey {
|
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
|
||||||
http.Error(w, "Unauthorized", 401)
|
http.Error(w, "Unauthorized", 401)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||||
var req struct {
|
var req struct {
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
Results []struct {
|
Results []struct {
|
||||||
@@ -387,13 +396,15 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||||
|
srv := &http.Server{Addr: addr, Handler: mux}
|
||||||
go func() {
|
go func() {
|
||||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
|
||||||
fmt.Printf("HTTP Server listening on %s\n", addr)
|
fmt.Printf("HTTP Server listening on %s\n", addr)
|
||||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
log.Fatalf("HTTP server failed: %v", err)
|
log.Printf("HTTP server error: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
return srv
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) {
|
func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) {
|
||||||
|
|||||||
Reference in New Issue
Block a user