From d66a219bcbbc6c4fdba57b510a29f70e893446bf Mon Sep 17 00:00:00 2001 From: Paul Picazo Date: Sun, 8 Nov 2026 08:46:17 -0800 Subject: [PATCH] feat(cors): let a UI on another origin connect, static host previews included (#2362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this does Server changes so the UI can be served from another origin, such as a Cloudflare Pages project and its previews, against an existing backend. It ships before the frontend, which is parked on [`backup/static-frontend-1a`](https://github.com/ppicazo/CoreScope/tree/backup/static-frontend-1a) and will come as its own PR. - **Exposed headers:** allowed origins get `Access-Control-Expose-Headers: Retry-After, X-CoreScope-Load-Status`. The UI reads both, and cross-origin they are `null` without this. - **Wildcard origins:** an entry may be `scheme://*.host[:port]`, such as `https://*.myproject.pages.dev`. It matches exactly one leftmost label (each deploy and branch preview) for both CORS and `/ws`, but not `myproject.pages.dev` itself. The part after `*.` needs at least three labels, so `*.pages.dev` is refused. Refused or malformed wildcards are dropped at startup with a log line. That floor is a sanity check, not a Public Suffix List lookup: `https://*.s3.amazonaws.com` passes and would admit every S3 website endpoint, so a wildcard belongs only over a host the operator controls. - **Case:** exact entries now match case-insensitively for CORS (was `==`), as they already did for `/ws`. - **Test only:** pins that the three anonymous POSTs (`/api/decode`, `/api/packets/observations`, `/api/paths/inspect`) accept a `text/plain` JSON body, which keeps them free of a preflight. Credentials stay off, so nothing a listed origin sends rides on cookies. Beyond GET, a listed origin, and so every deployment under a wildcard entry, can: - call those three anonymous POSTs as simple requests. That predates this PR; they compute an answer and write nothing. `Access-Control-Allow-Methods: GET, HEAD, OPTIONS` only governs preflighted requests. - with `userManagement.enabled`, use the existing bearer tier for companion linking (`/api/auth/device-token`, `/api/auth/me`, `/api/auth/logout`, `/api/account/settings`, `/api/account/companions*`) with POST, PUT and DELETE. Those routes need a device token in `Authorization`, not a cookie, but a preview deployment gets them too. On such a server, list a wildcard only if previews may use them. ## Configuring ```yaml - CORS_ALLOWED_ORIGINS=https://existing-embed.example,https://myproject.pages.dev,https://*.myproject.pages.dev ``` The env var replaces `corsAllowedOrigins`, so keep the origins already listed there. ## Testing - `go test ./...`, `go vet` and `gofmt` are clean in `cmd/server`. The new tests were written first and failed first. - In Chromium, the static build from the backup branch, served on `pr-1.proj.preview.localhost`, passed its split E2E (9/9, WebSocket included) against this server. - From the same setup, the project origin connected, a two-level subdomain was blocked, and `X-CoreScope-Load-Status` read `ready` where master gives `null`. ## Performance These run per request in the CORS middleware and the `/ws` handshake, and they allocate nothing. Same-origin requests skip them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- cmd/server/config.go | 6 ++ cmd/server/cors.go | 98 +++++++++++++++++++++-- cmd/server/cors_expose_test.go | 67 ++++++++++++++++ cmd/server/cors_wildcard_test.go | 131 +++++++++++++++++++++++++++++++ cmd/server/simple_post_test.go | 58 ++++++++++++++ cmd/server/websocket.go | 12 +-- config.example.json | 2 +- 7 files changed, 360 insertions(+), 14 deletions(-) create mode 100644 cmd/server/cors_expose_test.go create mode 100644 cmd/server/cors_wildcard_test.go create mode 100644 cmd/server/simple_post_test.go diff --git a/cmd/server/config.go b/cmd/server/config.go index 641fd11e..e3f35525 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -154,6 +154,10 @@ type Config struct { // CORSAllowedOrigins is the list of origins permitted to make cross-origin // requests. When empty (default), no Access-Control-* headers are sent, // so browsers enforce same-origin policy. Set to ["*"] to allow all origins. + // An entry may use one wildcard label, "https://*.myproject.pages.dev" + // (see originMatches); LoadConfig drops malformed wildcards and those over + // fewer than three labels, a sanity floor rather than a Public Suffix List + // check (see wildcardOrigin). CORSAllowedOrigins []string `json:"corsAllowedOrigins,omitempty"` // WebSocket carries the /ws transport limits from #1794. Omitted entirely @@ -530,12 +534,14 @@ func LoadConfig(baseDirs ...string) (*Config, error) { cfg.migrateDeprecatedConfig() cfg.applyListLimitsDefaults() applyCORSEnv(cfg) + cfg.CORSAllowedOrigins = sanitizeCORSOrigins(cfg.CORSAllowedOrigins) return cfg, nil } cfg.NormalizeTimestampConfig() cfg.migrateDeprecatedConfig() cfg.applyListLimitsDefaults() applyCORSEnv(cfg) + cfg.CORSAllowedOrigins = sanitizeCORSOrigins(cfg.CORSAllowedOrigins) return cfg, nil // defaults } diff --git a/cmd/server/cors.go b/cmd/server/cors.go index 0f3f7701..42b8ad83 100644 --- a/cmd/server/cors.go +++ b/cmd/server/cors.go @@ -1,6 +1,7 @@ package main import ( + "log" "net/http" "os" "strings" @@ -32,18 +33,98 @@ func applyCORSEnv(cfg *Config) { cfg.CORSAllowedOrigins = out } +// wildcardOrigin splits a wildcard allowlist entry, "scheme://*.host[:port]", +// into its scheme and the host[:port] the wildcard label sits on. One entry +// such as "https://*.myproject.pages.dev" then covers every deployment and +// branch preview of a static host project (85868e50.myproject.pages.dev). +// ok is false for anything else, including a wildcard over fewer than three +// labels: "https://*.pages.dev" would admit every Pages site on the internet. +// The label count is a sanity floor, not a Public Suffix List check. It keeps +// two-label shared suffixes (pages.dev, github.io, co.uk) out, but +// "https://*.s3.amazonaws.com" passes and would admit every S3 website +// endpoint, so a wildcard belongs only over a host the operator controls. +func wildcardOrigin(entry string) (scheme, rest string, ok bool) { + scheme, rest, ok = strings.Cut(entry, "://*.") + if !ok { + return "", "", false + } + if s := strings.ToLower(scheme); s != "http" && s != "https" { + return "", "", false + } + host, port, hasPort := strings.Cut(rest, ":") + if hasPort && (port == "" || strings.Trim(port, "0123456789") != "") { + return "", "", false + } + if strings.Count(host, ".") < 2 || strings.Contains(host, "..") || + strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") || + strings.ContainsAny(host, "*/?#@ ") { + return "", "", false + } + return scheme, rest, true +} + +// originMatches reports whether a browser Origin matches one allowlist entry: +// the same origin ignoring case, or a wildcard entry (see wildcardOrigin) +// with exactly one more label in front. The wildcard does not match the host +// it sits on, so "https://myproject.pages.dev" is listed on its own. "*" +// matches nothing here: the CORS middleware handles it and /ws refuses it. +func originMatches(entry, origin string) bool { + if strings.EqualFold(entry, origin) { + return true + } + scheme, rest, ok := wildcardOrigin(entry) + if !ok { + return false + } + oScheme, oRest, ok := strings.Cut(origin, "://") + if !ok || !strings.EqualFold(scheme, oScheme) { + return false + } + label, tail, ok := strings.Cut(oRest, ".") + return ok && label != "" && !strings.ContainsAny(label, ":/@") && strings.EqualFold(tail, rest) +} + +// sanitizeCORSOrigins drops each entry that has a wildcard but is neither "*" +// nor a valid wildcard label (see wildcardOrigin), and logs it, so a typo +// fails closed instead of matching more than the operator meant. +func sanitizeCORSOrigins(origins []string) []string { + var out []string + for _, o := range origins { + if o != "*" && strings.Contains(o, "*") { + if _, _, ok := wildcardOrigin(o); !ok { + log.Printf(`[config] corsAllowedOrigins: ignoring %q: a wildcard must be the whole leftmost label of "scheme://*.host", with at least three labels after "*.", e.g. "https://*.myproject.pages.dev"`, o) + continue + } + } + out = append(out, o) + } + return out +} + +// corsExposedHeaders are the response headers the UI reads besides the +// CORS-safelisted ones: Retry-After (api() on a warm-up 503, node-reach.js on +// a 429) and X-CoreScope-Load-Status (warmup-banner.js). A UI served from an +// allowed origin reads both as null unless they are listed here. +const corsExposedHeaders = "Retry-After, X-CoreScope-Load-Status" + // 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. +// An entry is an exact origin, a wildcard label (originMatches) or "*". // -// 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. +// Embed contract (issue #1369): no cross-origin writes. The middleware +// advertises only GET, HEAD, and OPTIONS in Access-Control-Allow-Methods so +// iframes cannot opt into POST/PUT/DELETE via CORS. That header only governs +// requests with a preflight: a CORS simple request (a POST with a text/plain +// body) has none, which is how a listed origin calls the three anonymous POSTs +// that compute an answer and write nothing (/api/decode, +// /api/packets/observations, /api/paths/inspect; see simple_post_test.go). +// Same-origin writes (admin UI, API-key holders on the canonical origin) are +// unaffected — they never go through the preflight path. // The exception is the bearer routes of companion linking (bearerCORSPath, // only with user management on), which also allow POST, PUT, DELETE and the -// Authorization header. +// Authorization header, for every listed origin: each deployment under a +// wildcard entry included. // 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) { @@ -68,7 +149,7 @@ func (s *Server) corsMiddleware(next http.Handler) http.Handler { wildcard = true break } - if o == reqOrigin { + if originMatches(o, reqOrigin) { allowed = true break } @@ -92,6 +173,7 @@ func (s *Server) corsMiddleware(next http.Handler) http.Handler { w.Header().Set("Access-Control-Allow-Origin", reqOrigin) w.Header().Set("Vary", "Origin") } + w.Header().Set("Access-Control-Expose-Headers", corsExposedHeaders) if s.auth != nil && bearerCORSPath(r.URL.Path) { // Companion linking: CoreDrive RX on an allowlisted origin logs in // and writes with a device token in Authorization. Still no @@ -99,7 +181,7 @@ func (s *Server) corsMiddleware(next http.Handler) http.Handler { w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") } else { - // Read-only embed contract — see comment above. + // 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") } diff --git a/cmd/server/cors_expose_test.go b/cmd/server/cors_expose_test.go new file mode 100644 index 00000000..9a9d115d --- /dev/null +++ b/cmd/server/cors_expose_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "net/http/httptest" + "strings" + "testing" +) + +// A frontend served from another origin (a static host listed in +// CORS_ALLOWED_ORIGINS) reads two response headers besides Content-Type: +// Retry-After (api() on a warm-up 503, node-reach.js on a 429) and +// X-CoreScope-Load-Status (warmup-banner.js). Cross-origin, fetch exposes only +// the CORS-safelisted headers unless the server lists the rest in +// Access-Control-Expose-Headers; without it both read as null. + +func exposedHeaders(t *testing.T, origins []string, origin string) map[string]bool { + t.Helper() + handler := newTestServerWithCORS(origins).corsMiddleware(dummyHandler) + req := httptest.NewRequest("GET", "/api/stats", nil) + if origin != "" { + req.Header.Set("Origin", origin) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + got := map[string]bool{} + for _, v := range rr.Header().Values("Access-Control-Expose-Headers") { + for _, name := range strings.Split(v, ",") { + if name = strings.TrimSpace(name); name != "" { + got[strings.ToLower(name)] = true + } + } + } + return got +} + +func TestCORS_ExposesRetryAfterAndLoadStatus(t *testing.T) { + for _, tc := range []struct { + name string + origins []string + }{ + {"listed origin", []string{"https://beta.example"}}, + {"wildcard", []string{"*"}}, + } { + got := exposedHeaders(t, tc.origins, "https://beta.example") + for _, want := range []string{"retry-after", "x-corescope-load-status"} { + if !got[want] { + t.Errorf("%s: Access-Control-Expose-Headers lacks %s, got %v", tc.name, want, got) + } + } + } +} + +func TestCORS_NoExposeHeadersWithoutAnAllowedOrigin(t *testing.T) { + for _, tc := range []struct { + name string + origins []string + origin string + }{ + {"no CORS list", nil, "https://beta.example"}, + {"unlisted origin", []string{"https://beta.example"}, "https://evil.example"}, + {"same-origin request (no Origin header)", []string{"https://beta.example"}, ""}, + } { + if got := exposedHeaders(t, tc.origins, tc.origin); len(got) != 0 { + t.Errorf("%s: expected no Access-Control-Expose-Headers, got %v", tc.name, got) + } + } +} diff --git a/cmd/server/cors_wildcard_test.go b/cmd/server/cors_wildcard_test.go new file mode 100644 index 00000000..3a22bbea --- /dev/null +++ b/cmd/server/cors_wildcard_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +// A static host such as Cloudflare Pages serves each deployment and branch +// preview on its own subdomain of the project (85868e50.myproject.pages.dev, +// pr-12.myproject.pages.dev). One allowlist entry, "https://*.myproject.pages.dev", +// covers them for both CORS and /ws. The wildcard is one leftmost label and +// matches exactly one label; the project's own origin is listed separately. + +func TestOriginMatches(t *testing.T) { + for _, tc := range []struct { + pattern, origin string + want bool + }{ + {"https://beta.example", "https://beta.example", true}, + {"https://Beta.Example", "https://beta.example", true}, + {"https://beta.example", "https://beta.example:8443", false}, + {"https://*.myproject.pages.dev", "https://85868e50.myproject.pages.dev", true}, + {"https://*.myproject.pages.dev", "https://pr-12.myproject.pages.dev", true}, + {"https://*.myproject.pages.dev", "https://PR-12.MyProject.pages.dev", true}, + {"https://*.myproject.pages.dev", "https://myproject.pages.dev", false}, + {"https://*.myproject.pages.dev", "https://a.b.myproject.pages.dev", false}, + {"https://*.myproject.pages.dev", "https://evilmyproject.pages.dev", false}, + {"https://*.myproject.pages.dev", "https://x.myproject.pages.dev.evil.example", false}, + {"https://*.myproject.pages.dev", "http://x.myproject.pages.dev", false}, + {"https://*.myproject.pages.dev", "https://x.myproject.pages.dev:8443", false}, + {"https://*.myproject.pages.dev", "https://.myproject.pages.dev", false}, + {"https://*.myproject.pages.dev", "null", false}, + {"http://*.pr.preview.localhost:8788", "http://pr-1.pr.preview.localhost:8788", true}, + {"http://*.pr.preview.localhost:8788", "http://pr-1.pr.preview.localhost", false}, + // Patterns refused at config load never match either. + {"https://*.pages.dev", "https://anyone.pages.dev", false}, + {"https://*.example.com", "https://www.example.com", false}, + {"*", "https://beta.example", false}, + } { + if got := originMatches(tc.pattern, tc.origin); got != tc.want { + t.Errorf("originMatches(%q, %q) = %v, want %v", tc.pattern, tc.origin, got, tc.want) + } + } +} + +func TestSanitizeCORSOriginsDropsUnsafeWildcards(t *testing.T) { + in := []string{ + "https://beta.example", + "*", + "https://myproject.pages.dev", + "https://*.myproject.pages.dev", + "http://*.pr.preview.localhost:8788", + "https://*.pages.dev", // every Pages site on the internet + "https://*.example.com", // fewer than three labels after "*." + "*.myproject.pages.dev", // no scheme + "https://a.*.pages.dev", // not the leftmost label + "https://*abc.myproject.pages.dev", // part of a label + "https://*.*.myproject.pages.dev", + "https://*.myproject.pages.dev/path", + "https://*.myproject.pages.dev:port", + "ftp://*.a.b.example", + "https://*.a..example", + } + want := []string{ + "https://beta.example", + "*", + "https://myproject.pages.dev", + "https://*.myproject.pages.dev", + "http://*.pr.preview.localhost:8788", + } + if got := sanitizeCORSOrigins(in); !reflect.DeepEqual(got, want) { + t.Fatalf("sanitizeCORSOrigins:\n got %q\nwant %q", got, want) + } +} + +func TestLoadConfigDropsUnsafeWildcardFromEnv(t *testing.T) { + t.Setenv("CORS_ALLOWED_ORIGINS", "https://myproject.pages.dev,https://*.myproject.pages.dev,https://*.pages.dev") + cfg, err := LoadConfig("/nonexistent") + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + want := []string{"https://myproject.pages.dev", "https://*.myproject.pages.dev"} + if !reflect.DeepEqual(cfg.CORSAllowedOrigins, want) { + t.Fatalf("CORSAllowedOrigins = %q, want %q", cfg.CORSAllowedOrigins, want) + } +} + +func TestCORS_WildcardEntryEchoesTheMatchingOrigin(t *testing.T) { + handler := newTestServerWithCORS([]string{"https://*.myproject.pages.dev"}).corsMiddleware(dummyHandler) + for _, tc := range []struct { + origin string + allow bool + }{ + {"https://85868e50.myproject.pages.dev", true}, + {"https://myproject.pages.dev", false}, + {"https://a.b.myproject.pages.dev", false}, + } { + req := httptest.NewRequest("GET", "/api/stats", nil) + req.Header.Set("Origin", tc.origin) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + acao := rr.Header().Get("Access-Control-Allow-Origin") + if tc.allow { + if acao != tc.origin || rr.Header().Get("Vary") != "Origin" { + t.Errorf("%s: ACAO %q, Vary %q; want the origin echoed with Vary: Origin", tc.origin, acao, rr.Header().Get("Vary")) + } + } else if acao != "" { + t.Errorf("%s: expected no ACAO, got %q", tc.origin, acao) + } + } +} + +func TestCheckOriginHonorsWildcardEntry(t *testing.T) { + srv, _ := newCheckOriginServer(t, []string{"https://myproject.pages.dev", "https://*.myproject.pages.dev"}) + defer srv.Close() + for _, tc := range []struct { + origin string + want int + }{ + {"https://pr-12.myproject.pages.dev", http.StatusSwitchingProtocols}, + {"https://myproject.pages.dev", http.StatusSwitchingProtocols}, + {"https://a.b.myproject.pages.dev", http.StatusForbidden}, + {"https://evil.example", http.StatusForbidden}, + } { + if status, _ := dialWS(t, srv, tc.origin); status != tc.want { + t.Errorf("%s: status %d, want %d", tc.origin, status, tc.want) + } + } +} diff --git a/cmd/server/simple_post_test.go b/cmd/server/simple_post_test.go new file mode 100644 index 00000000..16c4c525 --- /dev/null +++ b/cmd/server/simple_post_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" +) + +// A UI served from another origin (a static host listed in +// CORS_ALLOWED_ORIGINS) sends its three anonymous JSON POSTs with Content-Type +// text/plain. That keeps each one a CORS simple request: no preflight, which +// the embed CORS tier (Allow-Methods GET, HEAD, OPTIONS) would +// refuse. It works because these handlers decode the body as JSON whatever its +// Content-Type. If a handler starts insisting on application/json, this fails +// here instead of on the static host. +func TestAnonymousPostsAcceptTextPlainJSON(t *testing.T) { + _, router := setupTestServerWithAPIKey(t, "") + // The full test server is still warming the inspect graph (503), and the + // bare server path_inspect_test.go uses has no node data to search. A + // prefix the handler rejects after decoding still proves the point: a body + // it could not decode would answer "invalid JSON" instead. + inspect := newTestServerForInspect(t) + + cases := []struct { + path, body string + handler http.Handler + want int + wantBody string + }{ + {"/api/decode", `{"hex":"0200"}`, router, http.StatusOK, `"decoded"`}, + {"/api/packets/observations", `{"hashes":["abc123"]}`, router, http.StatusOK, `"results"`}, + {"/api/paths/inspect", `{"prefixes":["abc"]}`, http.HandlerFunc(inspect.handlePathInspect), http.StatusBadRequest, "even-length hex"}, + } + for _, c := range cases { + t.Run(c.path, func(t *testing.T) { + post := func(contentType string) *httptest.ResponseRecorder { + req := httptest.NewRequest("POST", c.path, bytes.NewBufferString(c.body)) + req.Header.Set("Content-Type", contentType) + req.Header.Set("Origin", "https://beta.corescope.example") + w := httptest.NewRecorder() + c.handler.ServeHTTP(w, req) + return w + } + asJSON := post("application/json") + asText := post("text/plain;charset=UTF-8") + if asJSON.Code != c.want || !bytes.Contains(asJSON.Body.Bytes(), []byte(c.wantBody)) { + t.Fatalf("application/json: expected %d with %s, got %d (body: %s)", c.want, c.wantBody, asJSON.Code, asJSON.Body.String()) + } + if asText.Code != asJSON.Code { + t.Fatalf("text/plain: got %d, application/json got %d (body: %s)", asText.Code, asJSON.Code, asText.Body.String()) + } + if asText.Body.String() != asJSON.Body.String() { + t.Fatalf("text/plain answered differently:\n text/plain: %s\n json: %s", asText.Body.String(), asJSON.Body.String()) + } + }) + } +} diff --git a/cmd/server/websocket.go b/cmd/server/websocket.go index 7f340af2..40395639 100644 --- a/cmd/server/websocket.go +++ b/cmd/server/websocket.go @@ -17,13 +17,14 @@ type Hub struct { mu sync.RWMutex clients map[*Client]bool upgrader websocket.Upgrader - allowedOrigins []string // exact-match allowlist for /ws CheckOrigin (see SetAllowedOrigins) + allowedOrigins []string // allowlist for /ws CheckOrigin (see SetAllowedOrigins) limits *wsLimiter // #1794: per-IP caps and deny list; nil allows everything pingInterval time.Duration } -// SetAllowedOrigins configures the exact-match origin allowlist consulted by -// the WebSocket upgrader's CheckOrigin. The "*" wildcard is deliberately NOT +// SetAllowedOrigins configures the origin allowlist consulted by the +// WebSocket upgrader's CheckOrigin: exact origins, or one wildcard label +// scoped to a project (originMatches). The "*" wildcard is deliberately NOT // honored here (it IS honored by the HTTP CORS middleware): OWASP's // WebSocket Security Cheat Sheet recommends an explicit allowlist for CSWSH // defense. If "*" appears in the slice, it is ignored and a startup WARN is @@ -46,7 +47,8 @@ func (h *Hub) SetAllowedOrigins(origins []string) { // - empty Origin header → allow (non-browser client; rate-limit / IP gate // is handled separately, see #1794). // - Origin host == request Host (same-origin) → allow. -// - Origin in allowedOrigins by exact case-insensitive match → allow. +// - Origin matches an allowedOrigins entry → allow (originMatches: exact +// and case-insensitive, or one wildcard label). // - "*" in allowedOrigins is ignored (see SetAllowedOrigins). // - anything else → reject (gorilla returns 403). func (h *Hub) checkOrigin(r *http.Request) bool { @@ -68,7 +70,7 @@ func (h *Hub) checkOrigin(r *http.Request) bool { if o == "*" { continue // deliberately not honored — see SetAllowedOrigins } - if strings.EqualFold(o, origin) { + if originMatches(o, origin) { return true } } diff --git a/config.example.json b/config.example.json index ce5ec25e..8b032dc6 100644 --- a/config.example.json +++ b/config.example.json @@ -40,7 +40,7 @@ }, "_comment_ingestorStats": "Ingestor publishes a 1-Hz stats snapshot consumed by the server's /api/perf/io and /api/perf/write-sources endpoints (#1120). Path is configured via the CORESCOPE_INGESTOR_STATS environment variable on the INGESTOR process. Default: /tmp/corescope-ingestor-stats.json. The writer uses O_NOFOLLOW + 0o600, so a pre-planted symlink in /tmp cannot be used to clobber an arbitrary file. SECURITY: in shared-tmp environments (multi-tenant hosts), point CORESCOPE_INGESTOR_STATS at a private directory like /var/lib/corescope/ingestor-stats.json that only the corescope user can write to.", "corsAllowedOrigins": [], - "_comment_corsAllowedOrigins": "Cross-origin allowlist for embed scenarios (#1369) AND for /ws WebSocket upgrades (#1793 — CSWSH defense per OWASP WebSocket Security Cheat Sheet). Exact-match origins, e.g. [\"https://blog.example.com\", \"https://embed.example.com\"]. Same-origin requests (Origin host == request Host) and non-browser clients (no Origin header) are always allowed for /ws. When empty (default), no Access-Control-* headers are sent and browsers enforce same-origin; cross-origin /ws upgrades are rejected. When non-empty, only the listed origins receive CORS headers, and Access-Control-Allow-Methods is limited to GET, HEAD, OPTIONS (the cross-domain surface is read-only — same-origin admin writes are unaffected). One exception, with userManagement.enabled: the CoreDrive RX device-token routes (/api/auth/device-token, /api/auth/me, /api/auth/logout, /api/account/settings, /api/account/companions*) also allow POST, PUT, DELETE and the Authorization and Content-Type headers for listed origins, so an RX build on another origin can log in and link companions with a bearer token. Use [\"*\"] to allow any origin for CORS XHR (NOT recommended for write-capable deployments); note that \"*\" is deliberately NOT honored for /ws upgrades — list explicit origins to permit cross-origin WebSocket clients (OWASP guidance: avoid wildcards in CSWSH defense). Operators can override per-deployment with the CORS_ALLOWED_ORIGINS environment variable (comma-separated). No credentialed CORS is enabled. To embed the map or channels pages cross-domain, add the embedding origin here and use the URL pattern '/#/map?embed=1' or '/#/channels?embed=1' — embed mode hides the top-nav, bottom-nav, and side drawer for full-bleed iframe rendering.", + "_comment_corsAllowedOrigins": "Cross-origin allowlist for embed scenarios (#1369) AND for /ws WebSocket upgrades (#1793 — CSWSH defense per OWASP WebSocket Security Cheat Sheet). Exact-match origins, e.g. [\"https://blog.example.com\", \"https://embed.example.com\"]. An entry may also use one wildcard label for a static host project, e.g. \"https://*.myproject.pages.dev\": it matches exactly one more label (every Cloudflare Pages deployment and branch preview, such as https://85868e50.myproject.pages.dev) for both CORS and /ws, but not https://myproject.pages.dev itself, so list that too. The part after \"*.\" needs at least three labels: \"https://*.pages.dev\" (every Pages site on the internet) and \"https://*.example.com\" are ignored with a startup log line, as is any other wildcard that is not the whole leftmost label. That floor is a sanity check, not a Public Suffix List lookup: \"https://*.s3.amazonaws.com\" passes and would admit every S3 website endpoint, so put a wildcard only over a host you control. Same-origin requests (Origin host == request Host) and non-browser clients (no Origin header) are always allowed for /ws. When empty (default), no Access-Control-* headers are sent and browsers enforce same-origin; cross-origin /ws upgrades are rejected. When non-empty, only the listed origins receive CORS headers, and Access-Control-Allow-Methods is limited to GET, HEAD, OPTIONS, so they cannot write cross-origin (same-origin admin writes are unaffected). That limit only applies to requests with a preflight: listed origins can also call the three anonymous POSTs /api/decode, /api/packets/observations and /api/paths/inspect with a text/plain body, which compute an answer and write nothing. One exception, with userManagement.enabled: the CoreDrive RX device-token routes (/api/auth/device-token, /api/auth/me, /api/auth/logout, /api/account/settings, /api/account/companions*) also allow POST, PUT, DELETE and the Authorization and Content-Type headers for every listed origin, each deployment under a wildcard entry included, so an RX build on another origin can log in and link companions with a bearer token. Use [\"*\"] to allow any origin for CORS XHR (NOT recommended for write-capable deployments); note that \"*\" is deliberately NOT honored for /ws upgrades — list explicit origins to permit cross-origin WebSocket clients (OWASP guidance: avoid wildcards in CSWSH defense). Operators can override per-deployment with the CORS_ALLOWED_ORIGINS environment variable (comma-separated). No credentialed CORS is enabled. Listed origins can also read the Retry-After and X-CoreScope-Load-Status response headers (Access-Control-Expose-Headers), so the UI served from a static host on a listed origin handles warm-up and busy answers as the embedded UI does. To embed the map or channels pages cross-domain, add the embedding origin here and use the URL pattern '/#/map?embed=1' or '/#/channels?embed=1' — embed mode hides the top-nav, bottom-nav, and side drawer for full-bleed iframe rendering.", "webSocket": { "maxConnsPerIP": 0, "_comment_maxConnsPerIP": "#1794 - concurrent /ws connections allowed from one client address. 0 = off, and that is the shipped default ON PURPOSE: carrier-grade NAT puts thousands of unrelated mobile subscribers behind one public IPv4, so a low cap refuses real visitors while a scraper simply rents more addresses. Set it only if you know your audience is not behind shared NAT.",