Skip to content

CORS bug: /health bypasses CORS middleware because Gin Use() is registered after routes #155

Description

@vrogojin

Summary

internal/gateway/server.go:setupRoutes() registers the CORS middleware after the routes, so the middleware never applies to any of them. GET /health therefore returns no CORS headers and browser-based monitors / dashboards get blocked.

POST / happens to work because pkg/jsonrpc/handler.go:55-58 hardcodes its own CORS headers inside the JSON-RPC handler — that's a separate code path, not the Gin middleware.

Reproducer

Against the deployed testnet aggregator (https://goggregator-test.unicity.network):

$ curl -sI https://goggregator-test.unicity.network/health
HTTP/2 200 
date: Mon, 01 Jun 2026 22:49:13 GMT
content-type: application/json
content-length: 120
strict-transport-security: max-age=16000000; includeSubDomains; preload;
# ← no Access-Control-* headers

$ curl -sI -X POST https://goggregator-test.unicity.network/ \
       -H "Origin: https://sphere-telco-test.dyndns.org" \
       -H "Content-Type: application/json"
HTTP/2 200 
access-control-allow-origin: https://sphere-telco-test.dyndns.org
access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS
access-control-allow-headers: Content-Type, Authorization, X-API-Key, X-Requested-With, Accept, Origin

# ← CORS present (from handler.go, not the Gin middleware — note the different
#   Methods list vs what the middleware would emit)

In the browser, the asymmetry shows up as:

home:1 Access to fetch at 'https://goggregator-test.unicity.network/health'
       from origin 'https://sphere-telco-test.dyndns.org' has been blocked
       by CORS policy: No 'Access-Control-Allow-Origin' header is present
       on the requested resource.

Root cause

internal/gateway/server.go:

func (s *Server) setupRoutes() {
    // Routes registered FIRST...
    s.router.GET("/health", s.handleHealth)                    // line 107
    s.router.PUT("/api/v1/trustbases", s.handlePutTrustBase)   // line 108
    s.router.POST("/", gin.WrapH(s.rpcServer))                 // line 111
    if s.config.Server.EnableDocs {
        s.router.GET("/docs", s.handleDocs)                    // line 115
    }

    // ...CORS middleware registered LAST.
    if s.config.Server.EnableCORS {
        s.router.Use(func(c *gin.Context) {                    // line 120
            c.Header("Access-Control-Allow-Origin", "*")
            c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
            c.Header("Access-Control-Allow-Headers", s.config.Server.CORSAllowedHeaders)
            …
        })
    }
}

In Gin, RouterGroup.Use() appends middleware to the group's handler chain, but routes capture the chain at the moment of registration. Middleware added after a route is registered does NOT retroactively apply to it. Reference: gin.RouterGroup.Use impl.

So the current code is a no-op for the four routes registered before the Use call, regardless of EnableCORS.

Suggested fix

Move the CORS middleware registration before the route registrations:

func (s *Server) setupRoutes() {
    // CORS middleware MUST be registered BEFORE routes so it applies
    // to /health, /docs, /api/v1/trustbases, and POST /. The JSON-RPC
    // handler in pkg/jsonrpc/handler.go also sets CORS headers — that
    // is a belt-and-suspenders backstop for the JSON-RPC route only,
    // not a substitute for the gateway middleware.
    if s.config.Server.EnableCORS {
        s.router.Use(func(c *gin.Context) {
            c.Header("Access-Control-Allow-Origin", "*")
            c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
            c.Header("Access-Control-Allow-Headers", s.config.Server.CORSAllowedHeaders)
            if c.Request.Method == "OPTIONS" {
                c.AbortWithStatus(http.StatusOK)
                return
            }
            c.Next()
        })
    }

    // Then routes.
    s.router.GET("/health", s.handleHealth)
    s.router.PUT("/api/v1/trustbases", s.handlePutTrustBase)
    s.router.POST("/", gin.WrapH(s.rpcServer))
    if s.config.Server.EnableDocs {
        s.router.GET("/docs", s.handleDocs)
    }
}

Two follow-up considerations worth a separate discussion:

  1. /health is an operator endpoint typically read by uptime monitors / browser dashboards from arbitrary origins. It is reasonable to set CORS headers on it unconditionally (in handleHealth itself), independent of EnableCORS. The current gating model effectively turns off the operator visibility surface whenever EnableCORS=false.

  2. Default value of EnableCORS — judging by the symptoms on the live testnet deploy, it is currently false. If you flip the default to true (or set it true for /health specifically) operators stop hitting this issue with every fresh deploy. Worth confirming what behavior pre-existing deployments need to preserve.

Browser-visible impact

Sphere wallet's aggregator-status banner runs a GET /health probe every few seconds. With CORS broken, every probe surfaces a CORS error in DevTools and the wallet falls through to the JSON-RPC liveness probe. The wallet works because the fallback is healthy, but the console noise is steady and confusing for users / support engineers.

Tested against

Live https://goggregator-test.unicity.network on 2026-06-01.

🤖 Generated with Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions