From b3b2c5246f9989fa1fb8aed12beab6e74c6b8f95 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 11 Feb 2026 23:44:50 +1300 Subject: [PATCH 1/2] fix: WebSocket origin checker now handles empty ALLOWED_ORIGINS Without this patch, the WebSocket origin checker only allowed all origins when ALLOWED_ORIGINS was explicitly set to "*". If ALLOWED_ORIGINS was unset or empty, the checker would fall through to the restrictive logic that rejects origins not in the (empty) allowlist. This is a problem because it's inconsistent with the CORS middleware behavior, which allows all origins when ALLOWED_ORIGINS is not configured. Users expect WebSocket connections to work out of the box in development. This patch solves the problem by treating empty/nil ALLOWED_ORIGINS the same as "*" - allowing all origins. The logging now distinguishes between "not configured" vs explicitly set to "*" for better observability. Changes: - Handle empty/nil ALLOWED_ORIGINS as permissive (allow all) - Add distinct log messages for unconfigured vs wildcard cases - Add comprehensive test coverage with 9 test cases Co-authored-by: Claude Code --- internal/graphql/subscription/handler.go | 12 ++- internal/graphql/subscription/handler_test.go | 79 +++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 internal/graphql/subscription/handler_test.go diff --git a/internal/graphql/subscription/handler.go b/internal/graphql/subscription/handler.go index 23ec0b7..320ad1f 100644 --- a/internal/graphql/subscription/handler.go +++ b/internal/graphql/subscription/handler.go @@ -66,9 +66,15 @@ func NewHandler(schema *graphql.Schema, pubsub *PubSub, allowedOrigins []string) // makeOriginChecker returns a CheckOrigin function based on the allowed origins list. func makeOriginChecker(allowedOrigins []string) func(r *http.Request) bool { - // If explicitly set to "*", allow all origins (development mode) - if len(allowedOrigins) == 1 && allowedOrigins[0] == "*" { - slog.Warn("WebSocket CheckOrigin allows all origins (development mode)") + // No origins configured or explicitly set to "*": allow all origins. + // This matches the CORS middleware default behavior. To restrict origins, + // set ALLOWED_ORIGINS to a comma-separated list of specific origins. + if len(allowedOrigins) == 0 || (len(allowedOrigins) == 1 && allowedOrigins[0] == "*") { + if len(allowedOrigins) == 0 { + slog.Warn("WebSocket CheckOrigin allows all origins (ALLOWED_ORIGINS not configured)") + } else { + slog.Warn("WebSocket CheckOrigin allows all origins (ALLOWED_ORIGINS=\"*\")") + } return func(r *http.Request) bool { return true } diff --git a/internal/graphql/subscription/handler_test.go b/internal/graphql/subscription/handler_test.go new file mode 100644 index 0000000..ca39305 --- /dev/null +++ b/internal/graphql/subscription/handler_test.go @@ -0,0 +1,79 @@ +package subscription + +import ( + "net/http" + "testing" +) + +func TestMakeOriginChecker(t *testing.T) { + tests := []struct { + name string + allowedOrigins []string + requestOrigin string + want bool + }{ + { + name: "nil origins allows all", + allowedOrigins: nil, + requestOrigin: "https://example.com", + want: true, + }, + { + name: "empty origins allows all", + allowedOrigins: []string{}, + requestOrigin: "https://example.com", + want: true, + }, + { + name: "wildcard allows all", + allowedOrigins: []string{"*"}, + requestOrigin: "https://example.com", + want: true, + }, + { + name: "no origin header always allowed", + allowedOrigins: []string{"https://allowed.com"}, + requestOrigin: "", + want: true, + }, + { + name: "matching origin allowed", + allowedOrigins: []string{"https://allowed.com"}, + requestOrigin: "https://allowed.com", + want: true, + }, + { + name: "non-matching origin rejected", + allowedOrigins: []string{"https://allowed.com"}, + requestOrigin: "https://evil.com", + want: false, + }, + { + name: "multiple origins one matches", + allowedOrigins: []string{"https://a.com", "https://b.com"}, + requestOrigin: "https://b.com", + want: true, + }, + { + name: "multiple origins none match", + allowedOrigins: []string{"https://a.com", "https://b.com"}, + requestOrigin: "https://c.com", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checker := makeOriginChecker(tt.allowedOrigins) + req, _ := http.NewRequest("GET", "/graphql/ws", nil) + if tt.requestOrigin != "" { + req.Header.Set("Origin", tt.requestOrigin) + } + got := checker(req) + if got != tt.want { + t.Errorf("makeOriginChecker(%v) with origin %q = %v, want %v", + tt.allowedOrigins, tt.requestOrigin, got, tt.want) + } + }) + } +} From 5f1207e5662a2336a88c88b54c72060c0029eb42 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 11 Feb 2026 23:47:20 +1300 Subject: [PATCH 2/2] docs: add all missing env vars to .env.example 11 config vars from config.go were undocumented: ALLOWED_ORIGINS, TRUST_PROXY_HEADERS, DOMAIN_DID, LEXICON_DIR, JETSTREAM_URL, JETSTREAM_COLLECTIONS, BACKFILL_ON_START, BACKFILL_COLLECTIONS, BACKFILL_PDS_CONCURRENCY, BACKFILL_REPO_TIMEOUT, PLC_DIRECTORY_URL --- .env.example | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/.env.example b/.env.example index 5d31780..865de2a 100644 --- a/.env.example +++ b/.env.example @@ -29,10 +29,22 @@ DATABASE_URL=sqlite:data/hypergoat.db # IMPORTANT: This MUST be persistent across restarts or sessions will be invalidated SECRET_KEY_BASE=CHANGE_ME_TO_A_RANDOM_64_CHARACTER_STRING_USE_OPENSSL_RAND +# Trust X-User-DID header from reverse proxy for authentication +# DANGEROUS: only enable when running behind a trusted reverse proxy +# TRUST_PROXY_HEADERS=false + +# Allowed origins for CORS and WebSocket connections (comma-separated) +# Empty or unset = allow all origins. Set explicit origins in production. +# Examples: https://myapp.com,https://admin.myapp.com +# ALLOWED_ORIGINS= + # Admin DIDs (comma-separated) - users with admin access to the dashboard # Example: did:plc:qc42fmqqlsmdq7jiypiiigww is daviddao.org ADMIN_DIDS=did:plc:qc42fmqqlsmdq7jiypiiigww +# Domain DID for server identity (defaults to did:web:{HOST}) +# DOMAIN_DID= + # ============================================================================= # OAuth Configuration # ============================================================================= @@ -52,10 +64,24 @@ ADMIN_DIDS=did:plc:qc42fmqqlsmdq7jiypiiigww # Set to "true" for local development, leave unset for production # OAUTH_LOOPBACK_MODE=true +# ============================================================================= +# Lexicon Configuration +# ============================================================================= + +# Directory to load lexicon JSON files from (default: testdata/lexicons) +# LEXICON_DIR= + # ============================================================================= # Jetstream Configuration # ============================================================================= +# Jetstream WebSocket URL (default: wss://jetstream2.us-west.bsky.network/subscribe) +# JETSTREAM_URL= + +# Collections to subscribe to via Jetstream (comma-separated NSIDs) +# If not set, uses collections from registered lexicons +# JETSTREAM_COLLECTIONS= + # Disable Jetstream cursor tracking (useful in development to avoid # backfilling events from previous sessions) # Set to "true", "1", or "yes" to disable @@ -65,12 +91,21 @@ ADMIN_DIDS=did:plc:qc42fmqqlsmdq7jiypiiigww # Backfill Configuration # ============================================================================= +# Run backfill on server start +# BACKFILL_ON_START=false + +# Collections to backfill (comma-separated, defaults to JETSTREAM_COLLECTIONS) +# BACKFILL_COLLECTIONS= + # Relay URL for discovering repos (com.atproto.sync.listReposByCollection) # BACKFILL_RELAY_URL=https://relay1.us-west.bsky.network # PLC directory URL for resolving DIDs # BACKFILL_PLC_URL=https://plc.directory +# Concurrent requests per PDS during backfill +# BACKFILL_PDS_CONCURRENCY=4 + # Global maximum concurrent HTTP requests (prevents overwhelming network) # Higher = faster but more resource intensive # BACKFILL_MAX_HTTP=50 @@ -86,6 +121,16 @@ ADMIN_DIDS=did:plc:qc42fmqqlsmdq7jiypiiigww # Maximum concurrent DID resolutions during discovery phase # BACKFILL_MAX_REPOS=50 +# Timeout per repo in milliseconds +# BACKFILL_REPO_TIMEOUT=60000 + +# ============================================================================= +# PLC Directory +# ============================================================================= + +# PLC directory URL for DID resolution (default: https://plc.directory) +# PLC_DIRECTORY_URL= + # ============================================================================= # External Services (Defaults configured via Admin UI) # =============================================================================