Security hardening
- JWT: validate signing algorithm (prevent alg confusion) - Login: rate limiting (10 attempts per 5 min per IP) - Request body: 10MB size limit (prevent DoS) - WebSocket: require JWT auth (token query param or cookie) - Daemon endpoints: require admin role (not just any user) - io.LimitReader on all request body decoding
This commit is contained in:
@@ -2,7 +2,9 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -12,6 +14,8 @@ import (
|
||||
"markdownhub/internal/git"
|
||||
)
|
||||
|
||||
const maxBodySize = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
@@ -20,12 +24,47 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
|
||||
func decodeBody(r *http.Request, v interface{}) error {
|
||||
defer r.Body.Close()
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
limited := io.LimitReader(r.Body, maxBodySize)
|
||||
return json.NewDecoder(limited).Decode(v)
|
||||
}
|
||||
|
||||
// Simple rate limiter for login
|
||||
var loginAttempts = struct {
|
||||
sync.Mutex
|
||||
m map[string][]time.Time
|
||||
}{m: make(map[string][]time.Time)}
|
||||
|
||||
func isRateLimited(ip string) bool {
|
||||
loginAttempts.Lock()
|
||||
defer loginAttempts.Unlock()
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-5 * time.Minute)
|
||||
// Clean old entries
|
||||
valid := loginAttempts.m[ip][:0]
|
||||
for _, t := range loginAttempts.m[ip] {
|
||||
if t.After(cutoff) {
|
||||
valid = append(valid, t)
|
||||
}
|
||||
}
|
||||
loginAttempts.m[ip] = valid
|
||||
return len(valid) >= 10 // max 10 attempts per 5 minutes
|
||||
}
|
||||
|
||||
func recordLoginAttempt(ip string) {
|
||||
loginAttempts.Lock()
|
||||
defer loginAttempts.Unlock()
|
||||
loginAttempts.m[ip] = append(loginAttempts.m[ip], time.Now())
|
||||
}
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.RemoteAddr
|
||||
if isRateLimited(ip) {
|
||||
writeJSON(w, 429, map[string]string{"error": "too many attempts, try again later"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
@@ -43,6 +82,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
"SELECT id, password_hash, is_admin, totp_secret FROM users WHERE email = ?", req.Email,
|
||||
).Scan(&id, &hash, &isAdmin, &totpSecret)
|
||||
if err != nil || !auth.CheckPassword(hash, req.Password) {
|
||||
recordLoginAttempt(ip)
|
||||
writeJSON(w, 401, map[string]string{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,10 +69,10 @@ func NewRouter(db *sql.DB, dataDir, secret string) http.Handler {
|
||||
mux.HandleFunc("POST /api/build/status", s.requireAuth(s.handleBuildStatus))
|
||||
mux.HandleFunc("POST /api/build/cancel", s.requireAuth(s.handleBuildCancel))
|
||||
|
||||
// Daemon endpoints
|
||||
mux.HandleFunc("POST /api/daemon/poll", s.requireAuth(s.handleDaemonPoll))
|
||||
mux.HandleFunc("POST /api/daemon/heartbeat", s.requireAuth(s.handleDaemonHeartbeat))
|
||||
mux.HandleFunc("POST /api/daemon/report", s.requireAuth(s.handleDaemonReport))
|
||||
// Daemon endpoints (admin only)
|
||||
mux.HandleFunc("POST /api/daemon/poll", s.requireAdmin(s.handleDaemonPoll))
|
||||
mux.HandleFunc("POST /api/daemon/heartbeat", s.requireAdmin(s.handleDaemonHeartbeat))
|
||||
mux.HandleFunc("POST /api/daemon/report", s.requireAdmin(s.handleDaemonReport))
|
||||
|
||||
// Static frontend
|
||||
frontendDir := filepath.Join(dataDir, "..", "frontend", "dist")
|
||||
|
||||
Reference in New Issue
Block a user