fix(engineering-dashboard): drop startup token for personal local use
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>
This commit is contained in:
@@ -91,7 +91,7 @@ func runServe(args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("engineering-dashboard listening on http://%s/?token=%s\n", addr, srv.Token)
|
||||
fmt.Printf("engineering-dashboard listening on http://%s/\n", addr)
|
||||
fmt.Printf("repo=%s\n", root)
|
||||
return engineeringdashboard.ListenAndServe(addr, srv.Handler())
|
||||
}
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
(function () {
|
||||
function resolveToken() {
|
||||
const fromURL = new URL(window.location.href).searchParams.get("token");
|
||||
if (fromURL) return fromURL;
|
||||
const meta = document.querySelector('meta[name="engineering-dashboard-token"]');
|
||||
const fromMeta = meta && meta.getAttribute("content");
|
||||
if (fromMeta && fromMeta !== "__TOKEN__") return fromMeta;
|
||||
return "";
|
||||
}
|
||||
const token = resolveToken();
|
||||
const state = {
|
||||
snap: null,
|
||||
view: "overview",
|
||||
@@ -33,7 +24,7 @@
|
||||
|
||||
function syncURL() {
|
||||
const u = new URL(window.location.href);
|
||||
if (token) u.searchParams.set("token", token);
|
||||
u.searchParams.delete("token");
|
||||
u.searchParams.set("view", state.view);
|
||||
if (state.q) u.searchParams.set("q", state.q); else u.searchParams.delete("q");
|
||||
if (state.selectedDoc) u.searchParams.set("doc", state.selectedDoc); else u.searchParams.delete("doc");
|
||||
@@ -52,13 +43,8 @@
|
||||
}
|
||||
|
||||
async function fetchSnapshot(force) {
|
||||
if (!token) {
|
||||
el.status.textContent = "缺少访问令牌";
|
||||
renderHardError("缺少 token:请使用服务启动时打印的完整 URL(含 ?token=)打开页面");
|
||||
return false;
|
||||
}
|
||||
el.status.textContent = state.snap ? "刷新中…" : "首次加载中…";
|
||||
const url = "/api/v1/snapshot?token=" + encodeURIComponent(token) + (force ? "&refresh=1" : "");
|
||||
const url = "/api/v1/snapshot" + (force ? "?refresh=1" : "");
|
||||
const headers = {};
|
||||
if (state.snap && state.snap.revision_id) headers["If-None-Match"] = '"' + state.snap.revision_id + '"';
|
||||
const res = await fetch(url, { headers, credentials: "same-origin" });
|
||||
@@ -294,10 +280,6 @@
|
||||
|
||||
async function loop() {
|
||||
loadURL();
|
||||
if (!token) {
|
||||
renderHardError("缺少 token:请使用服务启动时打印的完整 URL(含 ?token=)打开页面");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const changed = await fetchSnapshot(true);
|
||||
if (changed || state.snap) render();
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="engineering-dashboard-token" content="__TOKEN__">
|
||||
<title>个人工程中控台</title>
|
||||
<link rel="stylesheet" href="/assets/app.css?token=__TOKEN__">
|
||||
<link rel="stylesheet" href="/assets/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main">跳到主内容</a>
|
||||
@@ -38,6 +37,6 @@
|
||||
<div id="content"></div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/assets/app.js?token=__TOKEN__"></script>
|
||||
<script src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package engineeringdashboard
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -13,27 +11,15 @@ import (
|
||||
)
|
||||
|
||||
// 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
|
||||
Token string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewServer creates a server with a random startup token.
|
||||
// NewServer creates a local read-only server.
|
||||
func NewServer(c *Collector) (*Server, error) {
|
||||
tok, err := randomToken(16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{Collector: c, Token: tok}, nil
|
||||
}
|
||||
|
||||
func randomToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
return &Server{Collector: c}, nil
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler.
|
||||
@@ -76,17 +62,6 @@ func (s *Server) secure(next http.Handler) http.Handler {
|
||||
http.Error(w, "cross-site fetch denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// token required for API and page (query or header)
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/" || strings.HasPrefix(r.URL.Path, "/assets") {
|
||||
tok := r.URL.Query().Get("token")
|
||||
if tok == "" {
|
||||
tok = r.Header.Get("X-Dashboard-Token")
|
||||
}
|
||||
if tok != s.Token {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
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")
|
||||
@@ -117,7 +92,6 @@ func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// soft refresh if TTL expired
|
||||
if time.Since(snap.GeneratedAt) > s.Collector.TTL {
|
||||
go func() { _, _ = s.Collector.Refresh(ctx) }()
|
||||
}
|
||||
@@ -176,9 +150,8 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
html := strings.ReplaceAll(string(b), "__TOKEN__", s.Token)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(html))
|
||||
_, _ = w.Write(b)
|
||||
case "/assets/app.js":
|
||||
b, err := readAsset("assets/app.js")
|
||||
if err != nil {
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestServer_RejectsNonGETAndBadHost(t *testing.T) {
|
||||
}
|
||||
h := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/snapshot?token="+srv.Token, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/snapshot", nil)
|
||||
req.Host = "127.0.0.1:8787"
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
@@ -30,7 +30,7 @@ func TestServer_RejectsNonGETAndBadHost(t *testing.T) {
|
||||
t.Fatalf("POST: %d", rr.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/snapshot?token="+srv.Token, nil)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/snapshot", nil)
|
||||
req.Host = "evil.example"
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
@@ -39,7 +39,7 @@ func TestServer_RejectsNonGETAndBadHost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_TokenAndSnapshotIdentity(t *testing.T) {
|
||||
func TestServer_SnapshotWithoutToken(t *testing.T) {
|
||||
c := NewCollector(t.TempDir(), &FakeRunner{Map: map[string]FakeResult{}})
|
||||
snap := Snapshot{
|
||||
SchemaVersion: SchemaVersion, GenerationID: "gen_9", RevisionID: "rev_abc",
|
||||
@@ -59,16 +59,8 @@ func TestServer_TokenAndSnapshotIdentity(t *testing.T) {
|
||||
req.Host = "127.0.0.1:8787"
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("no token: %d", rr.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/snapshot?token="+srv.Token, nil)
|
||||
req.Host = "127.0.0.1:8787"
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("snapshot: %d %s", rr.Code, rr.Body.String())
|
||||
t.Fatalf("snapshot without token: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `"generation_id": "gen_9"`) || !strings.Contains(body, `"revision_id": "rev_abc"`) {
|
||||
@@ -78,7 +70,7 @@ func TestServer_TokenAndSnapshotIdentity(t *testing.T) {
|
||||
t.Fatalf("etag %q", rr.Header().Get("ETag"))
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/snapshot?token="+srv.Token, nil)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/snapshot", nil)
|
||||
req.Host = "127.0.0.1:8787"
|
||||
req.Header.Set("If-None-Match", `"rev_abc"`)
|
||||
rr = httptest.NewRecorder()
|
||||
@@ -96,7 +88,7 @@ func TestServer_DocPathTraversalDenied(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/doc/not-a-real.md?token="+srv.Token, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/doc/not-a-real.md", nil)
|
||||
req.Host = "127.0.0.1:8787"
|
||||
req.URL.Path = "/api/v1/doc/../../.env"
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -119,7 +111,7 @@ func TestServer_CrossOriginDenied(t *testing.T) {
|
||||
c.current = &Snapshot{RevisionID: "r", GenerationID: "g", GeneratedAt: time.Now().UTC()}
|
||||
c.mu.Unlock()
|
||||
srv, _ := NewServer(c)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/snapshot?token="+srv.Token, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/snapshot", nil)
|
||||
req.Host = "127.0.0.1:8787"
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -129,7 +121,7 @@ func TestServer_CrossOriginDenied(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_IndexUsesMetaTokenNoInlineScript(t *testing.T) {
|
||||
func TestServer_IndexHasNoTokenMachinery(t *testing.T) {
|
||||
c := NewCollector(t.TempDir(), &FakeRunner{})
|
||||
c.mu.Lock()
|
||||
c.current = &Snapshot{RevisionID: "r", GenerationID: "g", GeneratedAt: time.Now().UTC()}
|
||||
@@ -138,7 +130,7 @@ func TestServer_IndexUsesMetaTokenNoInlineScript(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/?token="+srv.Token, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "127.0.0.1:8787"
|
||||
rr := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rr, req)
|
||||
@@ -146,25 +138,20 @@ func TestServer_IndexUsesMetaTokenNoInlineScript(t *testing.T) {
|
||||
t.Fatalf("index: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if strings.Contains(body, "window.__DASHBOARD_TOKEN__") {
|
||||
t.Fatal("index must not use inline token script (CSP script-src 'self')")
|
||||
for _, banned := range []string{"__DASHBOARD_TOKEN__", "engineering-dashboard-token", "?token="} {
|
||||
if strings.Contains(body, banned) {
|
||||
t.Fatalf("index still contains %q", banned)
|
||||
}
|
||||
meta := `name="engineering-dashboard-token" content="` + srv.Token + `"`
|
||||
if !strings.Contains(body, meta) {
|
||||
t.Fatalf("missing token meta: %s", body[:min(400, len(body))])
|
||||
}
|
||||
if !strings.Contains(rr.Header().Get("Content-Security-Policy"), "script-src 'self'") {
|
||||
t.Fatalf("unexpected CSP: %s", rr.Header().Get("Content-Security-Policy"))
|
||||
}
|
||||
// app.js must not reference the removed global
|
||||
js, err := readAsset("assets/app.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(js), "__DASHBOARD_TOKEN__") {
|
||||
t.Fatal("app.js still references __DASHBOARD_TOKEN__")
|
||||
jsBody := string(js)
|
||||
if strings.Contains(jsBody, "token=") || strings.Contains(jsBody, "__DASHBOARD_TOKEN__") {
|
||||
t.Fatal("app.js still references token auth")
|
||||
}
|
||||
if !strings.Contains(string(js), "engineering-dashboard-token") {
|
||||
t.Fatal("app.js should read meta engineering-dashboard-token")
|
||||
if !strings.Contains(rr.Header().Get("Content-Security-Policy"), "script-src 'self'") {
|
||||
t.Fatalf("unexpected CSP: %s", rr.Header().Get("Content-Security-Policy"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user