Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/unreleased/split-graphql-admin-cors.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: fixed
body: Split CORS configuration so public GraphQL allows browser clients by default while admin GraphQL requires explicit trusted origins.
time: 2026-06-03T11:42:23.428959884+06:00
custom:
Affects: operator
16 changes: 12 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,18 @@ SECRET_KEY_BASE=CHANGE_ME_TO_A_RANDOM_64_CHARACTER_STRING_USE_OPENSSL_RAND
# Replace this placeholder with a real random secret before production use.
ADMIN_API_KEY=replace-with-a-random-secret

# 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=
# Browser origins for public GraphQL HTTP and subscription APIs.
# Empty or "*" allows every origin so independent frontends can query public indexed data.
# Set a comma-separated list only if you need to restrict public browser access.
PUBLIC_ALLOWED_ORIGINS=*

# Browser origins for admin GraphQL. Empty keeps admin GraphQL same-origin only.
# Set explicit trusted admin frontend origins when the admin UI is hosted separately.
# Wildcard "*" is rejected for admin routes.
# ADMIN_ALLOWED_ORIGINS=https://admin.example.com

# Deprecated: ALLOWED_ORIGINS is kept only as a compatibility fallback for explicit admin origins.
# Use PUBLIC_ALLOWED_ORIGINS and ADMIN_ALLOWED_ORIGINS instead.

# Admin DIDs (comma-separated) - users with admin access to the dashboard
# This is the backend source of truth and is read-only in the admin UI.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ Run verification based on what changed.
- `TAP_ENABLED=true` switches record ingestion to Tap mode.
- `LABELER_SUBSCRIBE_ENABLED=true` with `LABELER_SUBSCRIBE_URLS` starts optional external `com.atproto.label.subscribeLabels` ingestion.
- Migrations run automatically on startup.
- Be careful with `ALLOWED_ORIGINS`: current code allows all origins when unset, even if older prose suggests stricter defaults.
- CORS origin config is split by route group: `PUBLIC_ALLOWED_ORIGINS` controls public GraphQL/OAuth browser access and defaults to `*`; `ADMIN_ALLOWED_ORIGINS` must list trusted admin frontend origins explicitly and rejects wildcard `*`. Deprecated `ALLOWED_ORIGINS` is only a compatibility fallback for explicit admin origins.

## Changie fragments

Expand Down
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,18 @@ SECRET_KEY_BASE=your-secret-key-at-least-64-characters-long-generate-with-openss
# Example: openssl rand -base64 32
ADMIN_API_KEY=replace-with-a-random-secret

# WebSocket origins — comma-separated allowed origins for subscriptions.
# Unset or empty allows all origins. Set a comma-separated list to restrict origins; "*" also allows all origins.
# ALLOWED_ORIGINS=https://your-frontend.vercel.app
# Browser origins for public GraphQL HTTP and subscription APIs.
# Empty or "*" allows every origin so independent browser frontends can query public indexed data.
# Set a comma-separated list only if you need to restrict public browser access.
PUBLIC_ALLOWED_ORIGINS=*

# Browser origins for admin GraphQL. Empty keeps admin GraphQL same-origin only.
# Set explicit trusted admin frontend origins when the admin UI is hosted separately.
# Wildcard "*" is rejected for admin routes.
# ADMIN_ALLOWED_ORIGINS=https://admin.hypercerts.dev

# Deprecated: ALLOWED_ORIGINS is kept only as a compatibility fallback for explicit admin origins.
# Use PUBLIC_ALLOWED_ORIGINS and ADMIN_ALLOWED_ORIGINS instead.

# Tap record ingestion (recommended)
# TAP_ENABLED=true
Expand Down
120 changes: 67 additions & 53 deletions cmd/hyperindex/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,19 +262,6 @@ func setupRouter(cfg *config.Config, svc *services, bg *backgroundServices) *chi
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))

// CORS — uses AllowedOrigins from config; defaults to "*" if not set
var allowedOrigins []string
if cfg.AllowedOrigins != "" {
for _, o := range strings.Split(cfg.AllowedOrigins, ",") {
allowedOrigins = append(allowedOrigins, strings.TrimSpace(o))
}
}
allowAdminAPIKeyAuth := cfg.AdminAPIKey != ""
r.Use(server.CORSMiddleware(server.CORSConfig{
AllowedOrigins: allowedOrigins,
AllowAdminAPIKeyAuth: allowAdminAPIKeyAuth,
}))

// Health check reports process liveness only. Dependency readiness and
// deterministic labeler cursor failures are exposed via GET /ready.
r.Get("/health", func(w http.ResponseWriter, req *http.Request) {
Expand Down Expand Up @@ -535,34 +522,59 @@ func setupOAuth(r *chi.Mux, cfg *config.Config, svc *services, bg *backgroundSer
AuthorizationCodeExpiration: 600, // 10 minutes
}, svc.db)

// Discovery endpoints
r.Get("/.well-known/oauth-authorization-server", oauthHandlers.HandleAuthorizationServerMetadata)
r.Get("/.well-known/oauth-protected-resource", oauthHandlers.HandleProtectedResourceMetadata)

// Client metadata (this server as an OAuth client)
r.Get("/oauth-client-metadata.json", server.HandleClientMetadata(server.ClientMetadataConfig{
ExternalBaseURL: cfg.ExternalBaseURL,
ClientName: "Hyperindex",
Scope: "atproto transition:generic",
}))

// OAuth flow endpoints
r.Get("/oauth/authorize", oauthHandlers.HandleAuthorize)
r.Post("/oauth/authorize", oauthHandlers.HandleAuthorize)
r.Get("/oauth/callback", oauthHandlers.HandleCallback)
r.Post("/oauth/token", oauthHandlers.HandleToken)
r.Get("/oauth/jwks", oauthHandlers.HandleJWKS)
r.Post("/oauth/revoke", oauthHandlers.HandleRevoke)

// Additional OAuth endpoints
registerHandler := server.NewOAuthRegisterHandler(svc.db)
r.Post("/oauth/register", registerHandler.HandleRegister)

parHandler := server.NewOAuthPARHandler(svc.db)
r.Post("/oauth/par", parHandler.HandlePAR)
publicCORS := server.CORSMiddleware(server.CORSConfig{
AllowedOrigins: cfg.PublicAllowedOriginList(),
})

r.Get("/oauth/dpop/nonce", server.HandleDPoPNonce)
r.Post("/oauth/dpop/nonce", server.HandleDPoPNonce)
r.Group(func(r chi.Router) {
r.Use(publicCORS)

// Discovery endpoints
r.Get("/.well-known/oauth-authorization-server", oauthHandlers.HandleAuthorizationServerMetadata)
r.Get("/.well-known/oauth-protected-resource", oauthHandlers.HandleProtectedResourceMetadata)

// Client metadata (this server as an OAuth client)
r.Get("/oauth-client-metadata.json", server.HandleClientMetadata(server.ClientMetadataConfig{
ExternalBaseURL: cfg.ExternalBaseURL,
ClientName: "Hyperindex",
Scope: "atproto transition:generic",
}))

// OAuth flow endpoints
r.Get("/oauth/authorize", oauthHandlers.HandleAuthorize)
r.Post("/oauth/authorize", oauthHandlers.HandleAuthorize)
r.Get("/oauth/callback", oauthHandlers.HandleCallback)
r.Post("/oauth/token", oauthHandlers.HandleToken)
r.Get("/oauth/jwks", oauthHandlers.HandleJWKS)
r.Post("/oauth/revoke", oauthHandlers.HandleRevoke)

// Additional OAuth endpoints
registerHandler := server.NewOAuthRegisterHandler(svc.db)
r.Post("/oauth/register", registerHandler.HandleRegister)

parHandler := server.NewOAuthPARHandler(svc.db)
r.Post("/oauth/par", parHandler.HandlePAR)

r.Get("/oauth/dpop/nonce", server.HandleDPoPNonce)
r.Post("/oauth/dpop/nonce", server.HandleDPoPNonce)

corsPreflight := func(w http.ResponseWriter, r *http.Request) {}
for _, path := range []string{
"/.well-known/oauth-authorization-server",
"/.well-known/oauth-protected-resource",
"/oauth-client-metadata.json",
"/oauth/authorize",
"/oauth/callback",
"/oauth/token",
"/oauth/jwks",
"/oauth/revoke",
"/oauth/register",
"/oauth/par",
"/oauth/dpop/nonce",
} {
r.Options(path, corsPreflight)
}
})

// Start cleanup worker
oauthCleanupCtx, oauthCleanupCancel := context.WithCancel(context.Background())
Expand Down Expand Up @@ -614,9 +626,14 @@ func setupAdmin(r *chi.Mux, cfg *config.Config, svc *services) *admin.Handler {
// Wire up backfill callbacks for the admin UI
configureBackfillCallbacks(adminHandler, cfg, svc)

// Admin endpoint with optional auth (allows introspection without auth)
r.Handle("/admin/graphql", adminHandler.OptionalAuth())
r.Handle("/admin/graphql/", adminHandler.OptionalAuth())
// Admin endpoint with optional auth (allows introspection without auth).
// Cross-origin browser access is restricted to explicit admin origins.
adminGraphQLHandler := server.CORSMiddleware(server.CORSConfig{
AllowedOrigins: cfg.AdminAllowedOriginList(),
AllowAdminAPIKeyAuth: cfg.AdminAPIKey != "",
})(adminHandler.OptionalAuth())
r.Handle("/admin/graphql", adminGraphQLHandler)
r.Handle("/admin/graphql/", adminGraphQLHandler)
slog.Info("Admin GraphQL endpoint enabled", "path", "/admin/graphql")

// GraphiQL playgrounds
Expand Down Expand Up @@ -775,19 +792,16 @@ func setupGraphQL(r *chi.Mux, cfg *config.Config, svc *services, pubsub *subscri
if err != nil {
slog.Error("Failed to create GraphQL handler", "error", err)
} else {
r.Handle("/graphql", graphqlHandler)
r.Handle("/graphql/", graphqlHandler)
publicOrigins := cfg.PublicAllowedOriginList()
publicGraphQLHandler := server.CORSMiddleware(server.CORSConfig{
AllowedOrigins: publicOrigins,
})(graphqlHandler)
Comment thread
Kzoeps marked this conversation as resolved.
r.Handle("/graphql", publicGraphQLHandler)
r.Handle("/graphql/", publicGraphQLHandler)
slog.Info("GraphQL endpoint enabled", "path", "/graphql")

// WebSocket subscription endpoint
var allowedOrigins []string
if cfg.AllowedOrigins != "" {
allowedOrigins = strings.Split(cfg.AllowedOrigins, ",")
for i := range allowedOrigins {
allowedOrigins[i] = strings.TrimSpace(allowedOrigins[i])
}
}
subscriptionHandler := subscription.NewHandler(graphqlHandler.Schema(), pubsub, allowedOrigins)
subscriptionHandler := subscription.NewHandler(graphqlHandler.Schema(), pubsub, publicOrigins)
r.Handle("/graphql/ws", subscriptionHandler)
slog.Info("GraphQL subscriptions enabled", "path", "/graphql/ws")
}
Expand Down
128 changes: 122 additions & 6 deletions cmd/hyperindex/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/GainForest/hyperindex/internal/buildinfo"
"github.com/GainForest/hyperindex/internal/config"
"github.com/GainForest/hyperindex/internal/database/repositories"
"github.com/GainForest/hyperindex/internal/graphql/subscription"
"github.com/GainForest/hyperindex/internal/testutil"
)

Expand Down Expand Up @@ -42,6 +43,115 @@ func TestRootEndpointReturnsBuildInfoVersion(t *testing.T) {
}
}

func TestAdminGraphQLCORSUsesRestrictedOrigins(t *testing.T) {
db := testutil.SetupTestDB(t)
cfg := &config.Config{
ExternalBaseURL: "https://api.example",
AdminAPIKey: "admin-secret-123",
AdminAllowedOrigins: "https://admin.example",
}
r := setupRouter(cfg, labelerTestServices(db), &backgroundServices{})
setupAdmin(r, cfg, labelerTestServices(db))

t.Run("allowed admin origin", func(t *testing.T) {
req := httptest.NewRequest(http.MethodOptions, "/admin/graphql", nil)
req.Header.Set("Origin", "https://admin.example")
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
rec := httptest.NewRecorder()

r.ServeHTTP(rec, req)

if rec.Code != http.StatusNoContent {
t.Fatalf("OPTIONS /admin/graphql status = %d, want %d", rec.Code, http.StatusNoContent)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://admin.example" {
t.Fatalf("Access-Control-Allow-Origin = %q, want admin origin", got)
}
if got := rec.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, "X-Admin-API-Key") {
t.Fatalf("Access-Control-Allow-Headers = %q, want X-Admin-API-Key", got)
}
})

t.Run("unknown admin origin", func(t *testing.T) {
req := httptest.NewRequest(http.MethodOptions, "/admin/graphql", nil)
req.Header.Set("Origin", "https://evil.example")
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
rec := httptest.NewRecorder()

r.ServeHTTP(rec, req)

if rec.Code != http.StatusNoContent {
t.Fatalf("OPTIONS /admin/graphql status = %d, want %d", rec.Code, http.StatusNoContent)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("Access-Control-Allow-Origin = %q, want empty", got)
}
})
}

func TestPublicGraphQLCORSAllowsAllOriginsByDefault(t *testing.T) {
db := testutil.SetupTestDB(t)
cfg := &config.Config{ExternalBaseURL: "https://api.example"}
r := setupRouter(cfg, labelerTestServices(db), &backgroundServices{})
setupGraphQL(r, cfg, labelerTestServices(db), subscription.NewPubSub())

req := httptest.NewRequest(http.MethodOptions, "/graphql", nil)
req.Header.Set("Origin", "https://any-frontend.example")
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
rec := httptest.NewRecorder()

r.ServeHTTP(rec, req)

if rec.Code != http.StatusNoContent {
t.Fatalf("OPTIONS /graphql status = %d, want %d", rec.Code, http.StatusNoContent)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("Access-Control-Allow-Origin = %q, want *", got)
}
}

func TestOAuthEndpointsUsePublicCORS(t *testing.T) {
db := testutil.SetupTestDB(t)
cfg := &config.Config{ExternalBaseURL: "https://api.example"}
r := setupRouter(cfg, labelerTestServices(db), &backgroundServices{})
setupOAuth(r, cfg, labelerTestServices(db), &backgroundServices{})

t.Run("preflight for token endpoint", func(t *testing.T) {
req := httptest.NewRequest(http.MethodOptions, "/oauth/token", nil)
req.Header.Set("Origin", "https://frontend.example")
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
req.Header.Set("Access-Control-Request-Headers", "content-type,dpop")
rec := httptest.NewRecorder()

r.ServeHTTP(rec, req)

if rec.Code != http.StatusNoContent {
t.Fatalf("OPTIONS /oauth/token status = %d, want %d", rec.Code, http.StatusNoContent)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("Access-Control-Allow-Origin = %q, want *", got)
}
if got := rec.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, "DPoP") {
t.Fatalf("Access-Control-Allow-Headers = %q, want DPoP", got)
}
})

t.Run("actual discovery response", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil)
req.Header.Set("Origin", "https://frontend.example")
rec := httptest.NewRecorder()

r.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("GET discovery status = %d, want %d", rec.Code, http.StatusOK)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("Access-Control-Allow-Origin = %q, want *", got)
}
})
}

func TestHealthIgnoresLabelerReadiness(t *testing.T) {
db := testutil.SetupTestDB(t)
url := "wss://labeler.example/xrpc/com.atproto.label.subscribeLabels"
Expand Down Expand Up @@ -309,12 +419,18 @@ func labelerTestConfig(url string) *config.Config {

func labelerTestServices(db *testutil.TestDB) *services {
return &services{
db: db.Executor,
records: db.Records,
actors: db.Actors,
lexicons: db.Lexicons,
config: db.Config,
externalLabels: db.ExternalLabels,
db: db.Executor,
records: db.Records,
actors: db.Actors,
lexicons: db.Lexicons,
config: db.Config,
activity: db.Activity,
oauthClients: db.OAuthClients,
labels: db.Labels,
externalLabels: db.ExternalLabels,
labelDefinitions: db.LabelDefinitions,
labelPreferences: db.LabelPreferences,
reports: db.Reports,
}
}

Expand Down
Loading
Loading