Skip to content

corsMiddleware emits invalid CORS header combo, blocks browser SPA load #384

@oren-openteams

Description

@oren-openteams

Summary

corsMiddleware() in internal/api/router.go unconditionally sets both:

c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")

These are mutually exclusive per the CORS spec. A credentialed response must specify an explicit origin, not the wildcard. Browsers reject any response that combines them.

Impact

The Nebi SPA's index.html (Vite default) ships:

<script type="module" crossorigin src="/assets/index-*.js"></script>

The crossorigin attribute forces a CORS check even for same-origin module loads. With the invalid header combo above, the browser silently refuses to load the JS bundle. The SPA never initializes, and users land in an infinite /login reload loop with no diagnostic surfaced unless they open DevTools.

Reproduces on Firefox and Chrome. Both same-origin and cross-origin module loads fail.

Symptom in Firefox console:

Loading failed for the module with source "https://<host>/assets/index-<hash>.js". login:16:71

The bundle itself is valid JS served with Content-Type: application/javascript and HTTP 200 — fetch() retrieves it successfully. Only <script type="module" crossorigin> loading fails, because that path enforces CORS.

Root cause

internal/api/router.go:489-505:

// corsMiddleware adds CORS headers
func corsMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
        c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
        c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
        c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        c.Writer.Header().Set("Cache-Control", "no-store")

        if c.Request.Method == "OPTIONS" {
            c.AbortWithStatus(http.StatusNoContent)
            return
        }
        c.Next()
    }
}

The middleware applies to every route, including static /assets/* responses served by the SPA file handler.

Proposed fix

Two viable options:

Option A — drop credentials (simpler)

c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
// no Access-Control-Allow-Credentials

The SPA's <script type="module" crossorigin> uses anonymous crossorigin mode (the default), so the browser never sends credentials with module loads anyway — the credentials header doesn't help that case. If Nebi exposes any cross-origin API surface that needs cookies/auth, option B is required instead.

Option B — echo the request origin (correct general pattern)

origin := c.Request.Header.Get("Origin")
if origin != "" {
    c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
    c.Writer.Header().Set("Vary", "Origin")
    c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
}

With an allowlist if stricter behavior is wanted:

allowedOrigins := map[string]struct{}{
    cfg.PublicURL: {},
    // ...
}
if _, ok := allowedOrigins[origin]; ok {
    c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
    c.Writer.Header().Set("Vary", "Origin")
    c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
}

Either option resolves the browser-blocking bug. Option B is the correct pattern for any API that genuinely needs credentialed CORS; option A is fine if Nebi is only ever accessed from a same-origin SPA.

Adjacent issue: Cache-Control: no-store applied to static assets

While debugging this, I noticed the same middleware unconditionally emits Cache-Control: no-store on every response, including immutable hashed-filename bundles (/assets/index-<hash>.js). The inline comment explains the WebView/desktop concern, but the consequence is browsers re-download ~500KB of JS on every navigation even though the filename hash already guarantees cache-busting.

Worth scoping the no-store header to API responses (e.g. /api/*) and letting /assets/* be cacheable. Happy to file a separate issue if preferred — flagging here because the fix touches the same middleware function.

Repro

curl -sI https://<nebi-host>/assets/index-<any-hash>.js

Both Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true appear in the response. Then load https://<nebi-host>/login in any standards-compliant browser and observe the module-load failure in DevTools' console.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions