Rely on loopback bind plus Host/Origin checks; open http://127.0.0.1:8787/ without a query token after SSH tunnel. Co-authored-by: Cursor <cursoragent@cursor.com>
192 lines
5.3 KiB
Go
192 lines
5.3 KiB
Go
package engineeringdashboard
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Server is the local read-only HTTP surface.
|
|
// Access control is loopback bind + Host/Origin checks (personal local tool; no startup token).
|
|
type Server struct {
|
|
Collector *Collector
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// NewServer creates a local read-only server.
|
|
func NewServer(c *Collector) (*Server, error) {
|
|
return &Server{Collector: c}, nil
|
|
}
|
|
|
|
// Handler returns the HTTP handler.
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/healthz", s.handleHealthz)
|
|
mux.HandleFunc("/api/v1/snapshot", s.handleSnapshot)
|
|
mux.HandleFunc("/api/v1/doc/", s.handleDoc)
|
|
mux.HandleFunc("/", s.handleStatic)
|
|
return s.secure(mux)
|
|
}
|
|
|
|
func (s *Server) secure(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.Contains(r.URL.Path, "..") || strings.Contains(r.URL.RawPath, "..") || strings.Contains(r.URL.RawQuery, "..") {
|
|
http.Error(w, "path traversal denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
host := r.Host
|
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
|
host = h
|
|
}
|
|
if host != "127.0.0.1" && host != "localhost" && host != "::1" {
|
|
http.Error(w, "host not allowed", http.StatusForbidden)
|
|
return
|
|
}
|
|
origin := r.Header.Get("Origin")
|
|
if origin != "" && !isLocalOrigin(origin) {
|
|
http.Error(w, "origin not allowed", http.StatusForbidden)
|
|
return
|
|
}
|
|
site := r.Header.Get("Sec-Fetch-Site")
|
|
if site == "cross-site" {
|
|
http.Error(w, "cross-site fetch denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; img-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func isLocalOrigin(origin string) bool {
|
|
return strings.HasPrefix(origin, "http://127.0.0.1") ||
|
|
strings.HasPrefix(origin, "http://localhost") ||
|
|
strings.HasPrefix(origin, "http://[::1]")
|
|
}
|
|
|
|
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}
|
|
|
|
func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
snap := s.Collector.Current()
|
|
if snap == nil || r.URL.Query().Get("refresh") == "1" {
|
|
var err error
|
|
snap, err = s.Collector.Refresh(ctx)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
} else {
|
|
if time.Since(snap.GeneratedAt) > s.Collector.TTL {
|
|
go func() { _, _ = s.Collector.Refresh(ctx) }()
|
|
}
|
|
}
|
|
etag := `"` + snap.RevisionID + `"`
|
|
w.Header().Set("ETag", etag)
|
|
if match := r.Header.Get("If-None-Match"); match == etag {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
raw, err := MarshalSnapshotJSON(*snap)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_, _ = w.Write(raw)
|
|
}
|
|
|
|
func (s *Server) handleDoc(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/v1/doc/")
|
|
id = strings.TrimPrefix(id, "/")
|
|
if id == "" {
|
|
http.Error(w, "missing doc id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if _, err := SafeDocPath(s.Collector.RepoRoot, id); err != nil {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
snap := s.Collector.Current()
|
|
if snap == nil {
|
|
var err error
|
|
snap, err = s.Collector.Refresh(r.Context())
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
}
|
|
for _, d := range snap.Documents {
|
|
if d.ID == id || d.Path == id {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(d)
|
|
return
|
|
}
|
|
}
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
}
|
|
|
|
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
|
path := r.URL.Path
|
|
switch path {
|
|
case "/", "/index.html":
|
|
b, err := readAsset("assets/index.html")
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write(b)
|
|
case "/assets/app.js":
|
|
b, err := readAsset("assets/app.js")
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
|
_, _ = w.Write(b)
|
|
case "/assets/app.css":
|
|
b, err := readAsset("assets/app.css")
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
|
_, _ = w.Write(b)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
// ListenAndServe binds loopback-only.
|
|
func ListenAndServe(addr string, h http.Handler) error {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ip := net.ParseIP(host)
|
|
if ip == nil || !ip.IsLoopback() {
|
|
return fmt.Errorf("refusing non-loopback bind address: %s", addr)
|
|
}
|
|
ln, err := net.Listen("tcp", net.JoinHostPort(host, port))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return http.Serve(ln, h)
|
|
}
|