Compare commits

...

2 Commits

Author SHA1 Message Date
openclaw-bot f773b0f2ce fix(#1793): GREEN — enforce origin allowlist on /ws WebSocket upgrade
Replaces CheckOrigin: return true with an explicit allowlist:
- empty Origin (non-browser client) → allow
- Origin host == request Host (same-origin) → allow
- Origin matches cfg.CORSAllowedOrigins exactly (case-insensitive) → allow
- '*' in the allowlist is deliberately NOT honored for /ws (OWASP
  WebSocket Security Cheat Sheet: avoid wildcards in CSWSH defense).
  A startup WARN is logged if '*' is present so operators see the
  intentional divergence from the CORS XHR semantics.
- anything else → reject (gorilla returns 403)

Wires cfg.CORSAllowedOrigins into the Hub via SetAllowedOrigins from
main.go (single call site, no other NewHub callers need to change).
config.example.json comment updated to document the /ws gating and
the '*' divergence.

Out of scope (follow-up #1794): per-IP rate limiting / deny list /
connection cap for non-browser clients that still bypass Origin
because they don't send one.

OWASP: https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html

Fixes #1793
2026-06-27 21:58:58 +00:00
openclaw-bot 8509b1ba17 test(#1793): RED — assert /ws rejects foreign Origin and wildcard does not allow
Adds failing tests for the WebSocket CheckOrigin allowlist:
- foreign Origin must be rejected (403)
- empty Origin allowed (non-browser client)
- same-host Origin allowed
- allowlisted Origin allowed
- '*' in allowlist must NOT allow cross-origin upgrades (OWASP CSWSH)

Introduces a SetAllowedOrigins stub on Hub so the test compiles; the
stub does not yet enforce — the GREEN commit wires it through
CheckOrigin. Failing tests prove the gate works once implemented.

Refs #1793
2026-06-27 21:58:58 +00:00
4 changed files with 187 additions and 10 deletions
+1
View File
@@ -321,6 +321,7 @@ func main() {
// WebSocket hub
hub := NewHub()
hub.SetAllowedOrigins(cfg.CORSAllowedOrigins)
hub.upgrader.EnableCompression = cfg.WSCompressionEnabled()
// HTTP server
+65 -9
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -13,9 +14,63 @@ import (
// Hub manages WebSocket clients and broadcasts.
type Hub struct {
mu sync.RWMutex
clients map[*Client]bool
upgrader websocket.Upgrader
mu sync.RWMutex
clients map[*Client]bool
upgrader websocket.Upgrader
allowedOrigins []string // exact-match allowlist for /ws CheckOrigin (see SetAllowedOrigins)
}
// SetAllowedOrigins configures the exact-match origin allowlist consulted by
// the WebSocket upgrader's CheckOrigin. 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
// logged once per call.
//
// See: https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html
func (h *Hub) SetAllowedOrigins(origins []string) {
h.mu.Lock()
defer h.mu.Unlock()
h.allowedOrigins = append(h.allowedOrigins[:0], origins...)
for _, o := range origins {
if o == "*" {
log.Println(`[ws] WARNING: CORSAllowedOrigins contains "*" — CORS allows any origin for XHR, but /ws upgrade enforces explicit allowlist only (OWASP CSWSH guidance). Add specific origins to allow cross-origin WebSocket clients.`)
break
}
}
}
// checkOrigin is the gorilla/websocket Upgrader.CheckOrigin hook. Rules:
// - 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.
// - "*" in allowedOrigins is ignored (see SetAllowedOrigins).
// - anything else → reject (gorilla returns 403).
func (h *Hub) checkOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
u, err := url.Parse(origin)
if err != nil {
return false
}
if strings.EqualFold(u.Host, r.Host) {
return true
}
h.mu.RLock()
allowed := h.allowedOrigins
h.mu.RUnlock()
for _, o := range allowed {
if o == "*" {
continue // deliberately not honored — see SetAllowedOrigins
}
if strings.EqualFold(o, origin) {
return true
}
}
return false
}
// Client is a single WebSocket connection.
@@ -26,14 +81,15 @@ type Client struct {
}
func NewHub() *Hub {
return &Hub{
h := &Hub{
clients: make(map[*Client]bool),
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool { return true },
},
}
h.upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 4096,
CheckOrigin: h.checkOrigin,
}
return h
}
func (h *Hub) ClientCount() int {
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gorilla/websocket"
)
// dialWS attempts a WebSocket upgrade against srv with the given Origin
// header. It returns the HTTP status code received (101 on success, 403 on
// rejection) and any dial error. Connection is closed if it succeeds.
func dialWS(t *testing.T, srv *httptest.Server, origin string) (int, error) {
t.Helper()
wsURL := "ws" + srv.URL[4:]
headers := http.Header{}
if origin != "" {
headers.Set("Origin", origin)
}
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, headers)
if conn != nil {
defer conn.Close()
}
status := 0
if resp != nil {
status = resp.StatusCode
}
return status, err
}
func newCheckOriginServer(t *testing.T, allowed []string) (*httptest.Server, *Hub) {
t.Helper()
hub := NewHub()
hub.SetAllowedOrigins(allowed)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hub.ServeWS(w, r)
}))
return srv, hub
}
// TestCheckOriginRejectsForeignOrigin: the RED test that proves a
// cross-origin browser cannot upgrade /ws when not allowlisted.
// (#1793)
func TestCheckOriginRejectsForeignOrigin(t *testing.T) {
srv, _ := newCheckOriginServer(t, nil)
defer srv.Close()
status, err := dialWS(t, srv, "https://evil.example.com")
if err == nil {
t.Fatalf("expected upgrade rejection for foreign origin, got success (status=%d)", status)
}
if status != http.StatusForbidden {
t.Fatalf("expected 403 Forbidden for foreign origin, got status=%d err=%v", status, err)
}
}
func TestCheckOriginAllowsEmptyOrigin(t *testing.T) {
srv, _ := newCheckOriginServer(t, nil)
defer srv.Close()
status, err := dialWS(t, srv, "")
if err != nil {
t.Fatalf("expected upgrade success for empty Origin (non-browser client), got status=%d err=%v", status, err)
}
if status != http.StatusSwitchingProtocols {
t.Fatalf("expected 101 Switching Protocols, got %d", status)
}
}
func TestCheckOriginAllowsSameHost(t *testing.T) {
srv, _ := newCheckOriginServer(t, nil)
defer srv.Close()
// httptest.NewServer uses srv.URL host, e.g. 127.0.0.1:PORT.
hostURL := "http" + srv.URL[4:] // strip "ws" was N/A — srv.URL is http://
if !strings.HasPrefix(srv.URL, "http://") {
t.Fatalf("unexpected srv URL: %s", srv.URL)
}
sameOrigin := hostURL[:len(hostURL)] // same scheme+host as the dial target
status, err := dialWS(t, srv, sameOrigin)
if err != nil {
t.Fatalf("expected upgrade success for same-host origin %s, got status=%d err=%v", sameOrigin, status, err)
}
if status != http.StatusSwitchingProtocols {
t.Fatalf("expected 101 Switching Protocols, got %d", status)
}
}
func TestCheckOriginAllowsAllowlistedOrigin(t *testing.T) {
allow := []string{"https://embed.example.com"}
srv, _ := newCheckOriginServer(t, allow)
defer srv.Close()
status, err := dialWS(t, srv, "https://embed.example.com")
if err != nil {
t.Fatalf("expected upgrade success for allowlisted origin, got status=%d err=%v", status, err)
}
if status != http.StatusSwitchingProtocols {
t.Fatalf("expected 101 Switching Protocols, got %d", status)
}
}
// TestCheckOriginWildcardDoesNotAllowForeignOrigin asserts the deliberate
// divergence from CORS: "*" in the allowlist does NOT permit cross-origin
// WebSocket upgrades. OWASP WebSocket Security Cheat Sheet — avoid wildcards
// in CSWSH defense. See PR for citation.
func TestCheckOriginWildcardDoesNotAllowForeignOrigin(t *testing.T) {
srv, _ := newCheckOriginServer(t, []string{"*"})
defer srv.Close()
status, err := dialWS(t, srv, "https://evil.example.com")
if err == nil {
t.Fatalf("expected upgrade rejection for foreign origin even when allowlist=[\"*\"], got success (status=%d)", status)
}
if status != http.StatusForbidden {
t.Fatalf("expected 403 Forbidden, got status=%d err=%v", status, err)
}
}
+1 -1
View File
@@ -34,7 +34,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). Exact-match origins, e.g. [\"https://blog.example.com\", \"https://embed.example.com\"]. When empty (default), no Access-Control-* headers are sent and browsers enforce same-origin. 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). Use [\"*\"] to allow any origin (NOT recommended for write-capable deployments). 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\"]. 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). 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.",
"https": {
"cert": "/path/to/cert.pem",
"key": "/path/to/key.pem",