mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 22: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>
105 lines
2.9 KiB
Go
105 lines
2.9 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// applyCORSEnv overlays cfg.CORSAllowedOrigins from the CORS_ALLOWED_ORIGINS
|
|
// env var when it is set and non-empty. Tokens are comma-separated, trimmed,
|
|
// and empties dropped. The env var is the ops-friendly override; it lets
|
|
// operators add cross-domain embed origins without editing config.json
|
|
// (issue #1369). An unset or empty env var leaves cfg untouched, so
|
|
// per-deployment config.json values still apply.
|
|
func applyCORSEnv(cfg *Config) {
|
|
raw, ok := os.LookupEnv("CORS_ALLOWED_ORIGINS")
|
|
if !ok {
|
|
return
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
s := strings.TrimSpace(p)
|
|
if s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
// Env var present but only whitespace — treat as unset, do not clobber.
|
|
return
|
|
}
|
|
cfg.CORSAllowedOrigins = out
|
|
}
|
|
|
|
// corsMiddleware returns a middleware that sets CORS headers based on the
|
|
// configured allowed origins. When CORSAllowedOrigins is empty (default),
|
|
// no Access-Control-* headers are added, preserving browser same-origin policy.
|
|
//
|
|
// Embed contract (issue #1369): the cross-domain surface is read-only. The
|
|
// middleware advertises only GET, HEAD, and OPTIONS in Access-Control-Allow-
|
|
// Methods so iframes / server-side fetchers cannot opt into POST/PUT/DELETE
|
|
// via CORS. Same-origin writes (admin UI, API-key holders on the canonical
|
|
// origin) are unaffected — they never go through the preflight path.
|
|
// Credentialed CORS is intentionally NOT enabled.
|
|
func (s *Server) corsMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origins := s.cfg.CORSAllowedOrigins
|
|
if len(origins) == 0 {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
reqOrigin := r.Header.Get("Origin")
|
|
if reqOrigin == "" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Check if origin is allowed
|
|
allowed := false
|
|
wildcard := false
|
|
for _, o := range origins {
|
|
if o == "*" {
|
|
allowed = true
|
|
wildcard = true
|
|
break
|
|
}
|
|
if o == reqOrigin {
|
|
allowed = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !allowed {
|
|
// Origin not in allowlist — don't add CORS headers
|
|
if r.Method == http.MethodOptions {
|
|
// Still reject preflight with 403
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Set CORS headers
|
|
if wildcard {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
} else {
|
|
w.Header().Set("Access-Control-Allow-Origin", reqOrigin)
|
|
w.Header().Set("Vary", "Origin")
|
|
}
|
|
// Read-only embed contract — see comment above.
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key")
|
|
|
|
// Handle preflight
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|