mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-03 05:31:37 +00:00
367265eb59
Closes #1369. ## What Cross-domain embed support, shipped as two halves: ### Part A — CORS env override + read-only contract * `applyCORSEnv()` reads `CORS_ALLOWED_ORIGINS` (comma-separated, trimmed, empties dropped). Set in env → overrides `cfg.CORSAllowedOrigins`. Unset/empty → config.json value wins. * `Access-Control-Allow-Methods` tightened from `GET, POST, OPTIONS` → `GET, HEAD, OPTIONS`. The cross-domain surface is read-only by contract; same-origin admin writes don't go through preflight and are unaffected. * `config.example.json` adds `corsAllowedOrigins: []` + a comment explaining the env override and the embed URL pattern. * No wildcards introduced (still supported as `["*"]` for ops that opt in). No credentialed CORS. ### Part B — `?embed=1` chrome suppression * `shouldEmbedRoute(basePage, hashSearch)` — pure helper, allowlisted to `map` and `channels`, requires `embed=1` in the hash querystring. * `navigate()` toggles `body.embed` based on the helper. * CSS hides `.top-nav`, `[data-bottom-nav]`, `.nav-drawer`, `.nav-drawer-backdrop`, zeroes body padding/margin, reclaims `100dvh` for `#app.app-fixed`. Use: `<iframe src="https://analyzer.example/#/map?embed=1">`. For iframe-only display, no CORS entry is needed (the iframe loads the document, not a JSON API). The CORS allowlist only matters when the embedding origin's own JS calls `/api/*` directly. ## Tests | File | Asserts | Status | |---|---|---| | `cmd/server/cors_embed_1369_test.go` | 4 (env override, env-empty, env-trim, GET/HEAD contract, preflight POST rejected) | green | | `test-embed-mode-1369.js` | 9 (helper allowlist + param parsing) | green | | `cmd/server/cors_test.go` | existing | updated to read-only method-set assertion | TDD: 2 red commits (one per part, both compile, both fail on assertions) → 2 green commits. ## Out of scope (per the issue's narrow ask) * Other SPA routes do not honor `?embed=1` (their chrome makes layout assumptions; defer until requested). * No iframe sandboxing recommendation — that's the embedder's responsibility. * No CSP / `X-Frame-Options` change in this PR — frames are already permitted; add an explicit `frame-ancestors` policy in a follow-up if operators want to whitelist embedders at the HTTP layer too. ## Security notes (DJB lens) * Allowlist is exact-match, case-sensitive string compare — no normalization, no scheme/host parsing, no surprises. * No `Access-Control-Allow-Credentials` (would let third parties read auth'd state via cookies). * No reflection of arbitrary origins (every echoed origin came from the allowlist). * Methods narrowed to read-only; even a misconfigured allowlist can't grant cross-origin writes through this middleware. 🤖 Generated with OpenClaw --------- Co-authored-by: bot <bot@corescope.local>
150 lines
4.4 KiB
Go
150 lines
4.4 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// newTestServerWithCORS creates a minimal Server with the given CORS config.
|
|
func newTestServerWithCORS(origins []string) *Server {
|
|
cfg := &Config{CORSAllowedOrigins: origins}
|
|
srv := &Server{cfg: cfg}
|
|
return srv
|
|
}
|
|
|
|
// dummyHandler is a simple handler that writes 200 OK.
|
|
var dummyHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("ok"))
|
|
})
|
|
|
|
func TestCORS_DefaultNoHeaders(t *testing.T) {
|
|
srv := newTestServerWithCORS(nil)
|
|
handler := srv.corsMiddleware(dummyHandler)
|
|
|
|
req := httptest.NewRequest("GET", "/api/health", nil)
|
|
req.Header.Set("Origin", "https://evil.example")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != 200 {
|
|
t.Fatalf("expected 200, got %d", rr.Code)
|
|
}
|
|
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "" {
|
|
t.Fatalf("expected no ACAO header, got %q", v)
|
|
}
|
|
}
|
|
|
|
func TestCORS_AllowlistMatch(t *testing.T) {
|
|
srv := newTestServerWithCORS([]string{"https://good.example"})
|
|
handler := srv.corsMiddleware(dummyHandler)
|
|
|
|
req := httptest.NewRequest("GET", "/api/health", nil)
|
|
req.Header.Set("Origin", "https://good.example")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != 200 {
|
|
t.Fatalf("expected 200, got %d", rr.Code)
|
|
}
|
|
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "https://good.example" {
|
|
t.Fatalf("expected origin echo, got %q", v)
|
|
}
|
|
if v := rr.Header().Get("Access-Control-Allow-Methods"); v != "GET, HEAD, OPTIONS" {
|
|
t.Fatalf("expected methods header, got %q", v)
|
|
}
|
|
if v := rr.Header().Get("Access-Control-Allow-Headers"); v != "Content-Type, X-API-Key" {
|
|
t.Fatalf("expected headers header, got %q", v)
|
|
}
|
|
if v := rr.Header().Get("Vary"); v != "Origin" {
|
|
t.Fatalf("expected Vary: Origin, got %q", v)
|
|
}
|
|
}
|
|
|
|
func TestCORS_AllowlistNoMatch(t *testing.T) {
|
|
srv := newTestServerWithCORS([]string{"https://good.example"})
|
|
handler := srv.corsMiddleware(dummyHandler)
|
|
|
|
req := httptest.NewRequest("GET", "/api/health", nil)
|
|
req.Header.Set("Origin", "https://evil.example")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != 200 {
|
|
t.Fatalf("expected 200, got %d", rr.Code)
|
|
}
|
|
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "" {
|
|
t.Fatalf("expected no ACAO header for non-matching origin, got %q", v)
|
|
}
|
|
}
|
|
|
|
func TestCORS_PreflightAllowed(t *testing.T) {
|
|
srv := newTestServerWithCORS([]string{"https://good.example"})
|
|
handler := srv.corsMiddleware(dummyHandler)
|
|
|
|
req := httptest.NewRequest("OPTIONS", "/api/health", nil)
|
|
req.Header.Set("Origin", "https://good.example")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204, got %d", rr.Code)
|
|
}
|
|
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "https://good.example" {
|
|
t.Fatalf("expected origin echo, got %q", v)
|
|
}
|
|
}
|
|
|
|
func TestCORS_PreflightRejected(t *testing.T) {
|
|
srv := newTestServerWithCORS([]string{"https://good.example"})
|
|
handler := srv.corsMiddleware(dummyHandler)
|
|
|
|
req := httptest.NewRequest("OPTIONS", "/api/health", nil)
|
|
req.Header.Set("Origin", "https://evil.example")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestCORS_Wildcard(t *testing.T) {
|
|
srv := newTestServerWithCORS([]string{"*"})
|
|
handler := srv.corsMiddleware(dummyHandler)
|
|
|
|
req := httptest.NewRequest("GET", "/api/health", nil)
|
|
req.Header.Set("Origin", "https://anything.example")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != 200 {
|
|
t.Fatalf("expected 200, got %d", rr.Code)
|
|
}
|
|
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "*" {
|
|
t.Fatalf("expected *, got %q", v)
|
|
}
|
|
// Wildcard should NOT set Vary: Origin
|
|
if v := rr.Header().Get("Vary"); v == "Origin" {
|
|
t.Fatalf("wildcard should not set Vary: Origin")
|
|
}
|
|
}
|
|
|
|
func TestCORS_NoOriginHeader(t *testing.T) {
|
|
srv := newTestServerWithCORS([]string{"https://good.example"})
|
|
handler := srv.corsMiddleware(dummyHandler)
|
|
|
|
req := httptest.NewRequest("GET", "/api/health", nil)
|
|
// No Origin header
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != 200 {
|
|
t.Fatalf("expected 200, got %d", rr.Code)
|
|
}
|
|
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "" {
|
|
t.Fatalf("expected no ACAO without Origin header, got %q", v)
|
|
}
|
|
}
|