mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 11:57:57 +00:00
feat(cors): let a UI on another origin connect, static host previews included (#2362)
## 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
899a3ad0b1
commit
d66a219bcb
7 files changed
+360
-14
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+90
-8
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
Reference in new issue
Block a user