From 8b3d8d3e86c6f5d426aa60509c24f21aa9a44edb Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 1 Dec 2025 09:33:40 +0000 Subject: [PATCH] Add unit tests for websocket Hub.checkOrigin function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test coverage for CORS origin validation including: - No origin header (non-browser clients) - Same-origin requests with/without proxy headers - Wildcard and explicit allowed origins - Private network fallback (192.168.x, 10.x, localhost, .local, .lan) - Public IP/domain rejection - X-Forwarded-Proto/Host/Scheme header handling - WebSocket protocol normalization (ws->http, wss->https) Coverage: 37.3% → 48.6% --- internal/websocket/hub_test.go | 193 +++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/internal/websocket/hub_test.go b/internal/websocket/hub_test.go index bf28bef..9996a76 100644 --- a/internal/websocket/hub_test.go +++ b/internal/websocket/hub_test.go @@ -2,6 +2,7 @@ package websocket import ( "math" + "net/http" "testing" "time" @@ -673,3 +674,195 @@ func TestMessage_Fields(t *testing.T) { t.Errorf("Timestamp = %v, want 2024-01-01T00:00:00Z", msg.Timestamp) } } + +func TestHub_CheckOrigin(t *testing.T) { + tests := []struct { + name string + origin string + host string + allowedOrigins []string + forwardedProto string + forwardedHost string + expected bool + }{ + // No origin header - always allowed for non-browser clients + { + name: "no origin header", + origin: "", + host: "localhost:8080", + expected: true, + }, + + // Same-origin requests + { + name: "same origin http", + origin: "http://localhost:8080", + host: "localhost:8080", + expected: true, + }, + { + name: "same origin with forwarded proto https", + origin: "https://example.com", + host: "example.com", + forwardedProto: "https", + expected: true, + }, + { + name: "same origin with forwarded host", + origin: "http://proxy.example.com", + host: "backend:8080", + forwardedHost: "proxy.example.com", + expected: true, + }, + + // Wildcard allowed origins + { + name: "wildcard allows any origin", + origin: "https://evil.com", + host: "localhost:8080", + allowedOrigins: []string{"*"}, + expected: true, + }, + + // Explicit allowed origins + { + name: "explicit allowed origin matches", + origin: "https://app.example.com", + host: "localhost:8080", + allowedOrigins: []string{"https://app.example.com"}, + expected: true, + }, + { + name: "explicit allowed origin no match", + origin: "https://other.example.com", + host: "localhost:8080", + allowedOrigins: []string{"https://app.example.com"}, + expected: false, + }, + { + name: "multiple allowed origins - match second", + origin: "https://second.example.com", + host: "localhost:8080", + allowedOrigins: []string{"https://first.example.com", "https://second.example.com"}, + expected: true, + }, + + // Private network fallback (no allowed origins configured) + { + name: "private IP 192.168.x.x allowed when no origins configured", + origin: "http://192.168.1.100:3000", + host: "localhost:8080", + expected: true, + }, + { + name: "private IP 10.x.x.x allowed when no origins configured", + origin: "http://10.0.0.50:3000", + host: "localhost:8080", + expected: true, + }, + { + name: "localhost allowed when no origins configured", + origin: "http://localhost:3000", + host: "localhost:8080", + expected: true, + }, + { + name: "127.0.0.1 allowed when no origins configured", + origin: "http://127.0.0.1:3000", + host: "localhost:8080", + expected: true, + }, + { + name: ".local domain allowed when no origins configured", + origin: "http://myserver.local:3000", + host: "localhost:8080", + expected: true, + }, + { + name: ".lan domain allowed when no origins configured", + origin: "http://myserver.lan:3000", + host: "localhost:8080", + expected: true, + }, + { + name: "public IP rejected when no origins configured", + origin: "http://8.8.8.8:3000", + host: "localhost:8080", + expected: false, + }, + { + name: "public domain rejected when no origins configured", + origin: "https://evil.example.com", + host: "localhost:8080", + expected: false, + }, + + // HTTPS origin stripping + { + name: "https origin with private IP", + origin: "https://192.168.1.50:443", + host: "localhost:8080", + expected: true, + }, + + // Forwarded proto normalization + { + name: "wss forwarded proto normalized to https", + origin: "https://example.com", + host: "example.com", + forwardedProto: "wss", + expected: true, + }, + { + name: "ws forwarded proto normalized to http", + origin: "http://example.com", + host: "example.com", + forwardedProto: "ws", + expected: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + hub := NewHub(nil) + if len(tc.allowedOrigins) > 0 { + hub.SetAllowedOrigins(tc.allowedOrigins) + } + + req := &http.Request{ + Host: tc.host, + Header: make(http.Header), + } + if tc.origin != "" { + req.Header.Set("Origin", tc.origin) + } + if tc.forwardedProto != "" { + req.Header.Set("X-Forwarded-Proto", tc.forwardedProto) + } + if tc.forwardedHost != "" { + req.Header.Set("X-Forwarded-Host", tc.forwardedHost) + } + + result := hub.checkOrigin(req) + if result != tc.expected { + t.Errorf("checkOrigin() = %v, want %v", result, tc.expected) + } + }) + } +} + +func TestHub_CheckOrigin_XForwardedScheme(t *testing.T) { + hub := NewHub(nil) + + req := &http.Request{ + Host: "example.com", + Header: make(http.Header), + } + req.Header.Set("Origin", "https://example.com") + req.Header.Set("X-Forwarded-Scheme", "https") + + result := hub.checkOrigin(req) + if !result { + t.Error("checkOrigin should allow same-origin with X-Forwarded-Scheme") + } +}