Security(Go): Login rate limit + WS origin allowlist + REST bearer auth
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
@@ -19,12 +20,38 @@ import (
|
||||
// static assets, the PWA manifest, and JSON APIs backed by the device hub.
|
||||
// WebSocket signaling and screen streaming will be wired up in later phases.
|
||||
type Server struct {
|
||||
port int
|
||||
log *logger.Logger
|
||||
srv *http.Server
|
||||
hub *hub.Hub
|
||||
auth *wsauth.Authenticator
|
||||
ws *wsHub
|
||||
port int
|
||||
log *logger.Logger
|
||||
srv *http.Server
|
||||
hub *hub.Hub
|
||||
auth *wsauth.Authenticator
|
||||
ws *wsHub
|
||||
allowedOrigins []string // for WS Origin allowlist; empty = same-origin only
|
||||
loginIPLimit *wsauth.RateLimiter
|
||||
loginUserLimit *wsauth.RateLimiter
|
||||
trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only)
|
||||
}
|
||||
|
||||
// Config tunes the server's exposed-on-public-HTTPS hardening knobs.
|
||||
// All fields are optional; zero values pick reasonable defaults.
|
||||
type Config struct {
|
||||
// AllowedOrigins is the comma-separated list of Origin header values
|
||||
// the WebSocket upgrade will accept in addition to same-origin
|
||||
// requests. Empty (default) → only same-origin upgrades are allowed,
|
||||
// which is correct when the web UI and the WS endpoint are served
|
||||
// from the same host.
|
||||
AllowedOrigins []string
|
||||
// LoginIPLimit / LoginUserLimit throttle the get_salt + login flow
|
||||
// per source IP and per username respectively. Pass nil to disable
|
||||
// either dimension (e.g. dev mode).
|
||||
LoginIPLimit *wsauth.RateLimiter
|
||||
LoginUserLimit *wsauth.RateLimiter
|
||||
// TrustForwardedFor switches client-IP extraction from RemoteAddr
|
||||
// (default) to the last entry of X-Forwarded-For. Set true only when
|
||||
// running behind a reverse proxy that you control; on direct
|
||||
// exposure the header is client-controlled and would let attackers
|
||||
// evade per-IP rate limits.
|
||||
TrustForwardedFor bool
|
||||
}
|
||||
|
||||
// New creates an HTTP server bound to the given port. port=0 disables the server.
|
||||
@@ -34,6 +61,16 @@ func New(port int, log *logger.Logger, h *hub.Hub, auth *wsauth.Authenticator) *
|
||||
return &Server{port: port, log: log, hub: h, auth: auth}
|
||||
}
|
||||
|
||||
// WithConfig applies hardening configuration. Returns the receiver for
|
||||
// chainable setup. Safe to call before Start; ignored thereafter.
|
||||
func (s *Server) WithConfig(cfg Config) *Server {
|
||||
s.allowedOrigins = cfg.AllowedOrigins
|
||||
s.loginIPLimit = cfg.LoginIPLimit
|
||||
s.loginUserLimit = cfg.LoginUserLimit
|
||||
s.trustForwardedFor = cfg.TrustForwardedFor
|
||||
return s
|
||||
}
|
||||
|
||||
// Start launches the server in a goroutine and returns immediately.
|
||||
// If port is 0, returns nil without starting anything.
|
||||
func (s *Server) Start() error {
|
||||
@@ -42,13 +79,16 @@ func (s *Server) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.ws = newWSHub(s.auth, s.hub, s.log)
|
||||
s.ws = newWSHub(s.auth, s.hub, s.log).
|
||||
withOriginAllowlist(s.allowedOrigins).
|
||||
withLoginRateLimiters(s.loginIPLimit, s.loginUserLimit).
|
||||
withTrustForwardedFor(s.trustForwardedFor)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", s.handleIndex)
|
||||
mux.HandleFunc("/health", s.handleHealth)
|
||||
mux.HandleFunc("/manifest.json", s.handleManifest)
|
||||
mux.HandleFunc("/api/devices", s.handleDevices)
|
||||
mux.HandleFunc("/api/devices", s.requireBearer(s.handleDevices))
|
||||
mux.HandleFunc("/ws", s.ws.serve)
|
||||
mux.HandleFunc("/static/xterm.js", staticHandler(xtermJS, "application/javascript; charset=utf-8"))
|
||||
mux.HandleFunc("/static/xterm.css", staticHandler(xtermCSS, "text/css; charset=utf-8"))
|
||||
@@ -106,7 +146,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleDevices returns a JSON snapshot of currently-online devices. Empty
|
||||
// array (not null) when no clients are connected — matches what the front-end
|
||||
// will eventually expect.
|
||||
// will eventually expect. Auth-gated via requireBearer.
|
||||
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
|
||||
devices := s.hub.ListDevices()
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
@@ -116,6 +156,39 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// requireBearer wraps a handler with `Authorization: Bearer <token>` auth
|
||||
// against the same session-token store the WebSocket uses. Returns 401 on
|
||||
// missing / invalid / expired tokens. Used to gate REST endpoints that
|
||||
// previously fell through with no auth (notably /api/devices, which
|
||||
// otherwise leaks the full online-device list to anyone on the internet).
|
||||
func (s *Server) requireBearer(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
const prefix = "Bearer "
|
||||
hdr := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(hdr, prefix) {
|
||||
s.unauthorized(w)
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(hdr[len(prefix):])
|
||||
if token == "" {
|
||||
s.unauthorized(w)
|
||||
return
|
||||
}
|
||||
if _, err := s.auth.ValidateToken(token); err != nil {
|
||||
s.unauthorized(w)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) unauthorized(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="yama"`)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
}
|
||||
|
||||
// PWA manifest. Referenced by <link rel="manifest"> in index.html.
|
||||
// Static JSON, no template needed.
|
||||
const manifestJSON = `{
|
||||
|
||||
Reference in New Issue
Block a user