Files
meshcore-analyzer/cmd/server/cors_test.go
T
Kpa-clawbot 367265eb59 feat(#1369): cross-domain embed support (CORS env override + ?embed=1 chrome suppression) (#1500)
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>
2026-05-30 13:22:41 -07:00

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)
}
}