VYPR
Critical severity9.6NVD Advisory· Published Mar 19, 2026· Updated Apr 23, 2026

CVE-2026-30924

CVE-2026-30924

Description

qui is a web interface for managing qBittorrent instances. Versions 1.14.1 and below use a permissive CORS policy that reflects arbitrary origins while also returning Access-Control-Allow-Credentials: true, effectively allowing any external webpage to make authenticated requests on behalf of a logged-in user. An attacker can exploit this by tricking a victim into loading a malicious webpage, which silently interacts with the application using the victim's session and potentially exfiltrating sensitive data such as API keys and account credentials, or even achieving full system compromise through the built-in External Programs manager. Exploitation requires that the victim access the application via a non-localhost hostname and load an attacker-controlled webpage, making highly targeted social-engineering attacks the most likely real-world scenario. This issue was not fixed at the time of publication.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/autobrr/quiGo
< 1.15.01.15.0

Affected products

1
  • cpe:2.3:a:getqui:qui:*:*:*:*:*:docker:*:*
    Range: <1.15.0

Patches

1
424f7a0de089

fix(api): restrict CORS to explicit allowlist (#1551)

https://github.com/autobrr/quisoupMar 6, 2026via ghsa
10 files changed · +439 22
  • cmd/qui/main.go+4 0 modified
    @@ -460,6 +460,10 @@ func (app *Application) runServer() {
     		log.Warn().Msg("Only one of QUI__AUTH_DISABLED and QUI__I_ACKNOWLEDGE_THIS_IS_A_BAD_IDEA is set. Authentication remains enabled. Set both to disable authentication.")
     	}
     
    +	if err := cfg.Config.NormalizeCORSAllowedOrigins(); err != nil {
    +		log.Fatal().Err(err).Msg("Invalid corsAllowedOrigins configuration")
    +	}
    +
     	trackerIconService, err := trackericons.NewService(cfg.GetDataDir(), buildinfo.UserAgent)
     	if err != nil {
     		log.Fatal().Err(err).Msg("Failed to prepare tracker icon cache")
    
  • documentation/docs/advanced/sso-proxy-cors.md+11 1 modified
    @@ -5,7 +5,7 @@ title: SSO Proxies and CORS
     
     # SSO Proxies and CORS
     
    -When qui is behind an SSO proxy (Cloudflare Access, Pangolin, etc.), expired sessions can redirect API `fetch()` calls to the proxy's auth origin. Browsers block cross-origin redirects unless the **proxy** sends CORS headers, so you may see errors like "CORS request did not succeed" or "NetworkError". In normal same-origin setups, qui does not need any CORS configuration.
    +When qui is behind an SSO proxy (Cloudflare Access, Pangolin, etc.), expired sessions can redirect API `fetch()` calls to the proxy's auth origin. Browsers block cross-origin redirects unless the **proxy** sends CORS headers, so you may see errors like "CORS request did not succeed" or "NetworkError". In normal same-origin setups, qui does not need any CORS configuration and keeps CORS disabled.
     
     ## What qui does
     
    @@ -18,4 +18,14 @@ When qui is behind an SSO proxy (Cloudflare Access, Pangolin, etc.), expired ses
     - Configure CORS **on the SSO proxy** (not in qui) for the auth endpoints.
     - Allow credentials and handle `OPTIONS` preflight when required.
     
    +## Optional qui allowlist
    +
    +If another trusted website running in the user's browser must call qui from a different origin on the user's behalf, set an explicit allowlist:
    +
    +```bash
    +QUI__CORS_ALLOWED_ORIGINS=https://panel.example.com
    +```
    +
    +Only explicit origins are accepted (`http(s)://host[:port]`). Wildcards and path/query/fragment values are rejected.
    +
     If you still hit CORS errors after proxy configuration, capture the browser console error and open an issue.
    
  • documentation/docs/configuration/environment.md+8 0 modified
    @@ -17,6 +17,14 @@ QUI__PORT=7476           # Port number
     QUI__BASE_URL=/qui/      # Optional: serve from subdirectory
     ```
     
    +## CORS
    +
    +```bash
    +QUI__CORS_ALLOWED_ORIGINS=https://sso.example.com,https://panel.example.com  # Optional: explicit CORS allowlist (empty disables CORS)
    +```
    +
    +`QUI__CORS_ALLOWED_ORIGINS` accepts comma/space-separated origins. Entries must be explicit `http(s)://host[:port]` values, without wildcards, paths, query strings, fragments, or userinfo.
    +
     ## Security
     
     ```bash
    
  • documentation/docs/configuration/reference.md+18 0 modified
    @@ -44,6 +44,7 @@ qui watches `config.toml` for changes. Some settings are applied immediately (fo
     | `host` | `QUI__HOST` | string | `localhost` (or `0.0.0.0` in containers) | Bind address for the main HTTP server. |
     | `port` | `QUI__PORT` | int | `7476` | Port for the main HTTP server. |
     | `baseUrl` | `QUI__BASE_URL` | string | `/` | Serve qui from a subdirectory (example: `/qui/`). |
    +| `corsAllowedOrigins` | `QUI__CORS_ALLOWED_ORIGINS` | string[] | empty list | Explicit CORS allowlist. Empty disables CORS. Origins must be `http(s)://host[:port]`; wildcards are rejected; default ports are normalized. Restart required. |
     | `sessionSecret` | `QUI__SESSION_SECRET` / `QUI__SESSION_SECRET_FILE` | string | auto-generated | WARNING: changing breaks decryption of stored instance passwords; you must re-enter them in the UI. |
     | `logLevel` | `QUI__LOG_LEVEL` | string | `INFO` | `ERROR`, `DEBUG`, `INFO`, `WARN`, `TRACE`. Applied immediately. |
     | `logPath` | `QUI__LOG_PATH` | string | empty | If empty: logs to stdout. Relative paths resolve relative to the config directory. Applied immediately. |
    @@ -119,6 +120,23 @@ If you use private trackers, running qui without authentication is especially da
     
     If `QUI__AUTH_DISABLED` is set without `QUI__I_ACKNOWLEDGE_THIS_IS_A_BAD_IDEA`, qui will log a warning and keep authentication enabled.
     
    +## CORS
    +
    +By default, qui does not send CORS allow headers. To allow browser requests from another trusted origin, set `corsAllowedOrigins` (or `QUI__CORS_ALLOWED_ORIGINS`) to an explicit allowlist:
    +
    +```bash
    +QUI__CORS_ALLOWED_ORIGINS=https://sso.example.com,https://panel.example.com
    +```
    +
    +Rules:
    +
    +- only explicit origins are allowed (`http://` or `https://` + host + optional non-default port)
    +- wildcards are rejected (`*`, `https://*.example.com`, etc.)
    +- path/query/fragment/userinfo are rejected
    +- invalid values refuse startup; invalid live reloads are rejected and keep the last valid allowlist
    +
    +For SSO proxy setups, prefer configuring CORS on the proxy auth endpoints first. See [SSO Proxies and CORS](../advanced/sso-proxy-cors).
    +
     ## Example `config.toml`
     
     ```toml
    
  • internal/api/server_cors_test.go+104 7 modified
    @@ -12,7 +12,7 @@ import (
     	"github.com/stretchr/testify/require"
     )
     
    -func TestCORSPreflightBypassesAuth(t *testing.T) {
    +func TestCORSDisabledByDefault(t *testing.T) {
     	deps := newTestDependencies(t)
     
     	server := NewServer(deps)
    @@ -27,20 +27,59 @@ func TestCORSPreflightBypassesAuth(t *testing.T) {
     
     	router.ServeHTTP(rec, req)
     
    +	require.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"))
    +	require.Empty(t, rec.Header().Get("Access-Control-Allow-Credentials"))
    +}
    +
    +func TestCORSPreflightAllowsConfiguredOrigin(t *testing.T) {
    +	deps := newTestDependencies(t)
    +	deps.Config.Config.CORSAllowedOrigins = []string{"https://example.com"}
    +
    +	server := NewServer(deps)
    +	router, err := server.Handler()
    +	require.NoError(t, err)
    +
    +	req := httptest.NewRequest(http.MethodOptions, "/api/auth/me", nil)
    +	req.Header.Set("Origin", "https://example.com")
    +	req.Header.Set("Access-Control-Request-Method", http.MethodGet)
    +
    +	rec := httptest.NewRecorder()
    +
    +	router.ServeHTTP(rec, req)
    +
     	require.Equal(t, http.StatusNoContent, rec.Code)
     	require.Equal(t, "https://example.com", rec.Header().Get("Access-Control-Allow-Origin"))
     	require.Equal(t, "true", rec.Header().Get("Access-Control-Allow-Credentials"))
     }
     
    -func TestCORSAllowsXRequestedWithHeader(t *testing.T) {
    +func TestCORSPreflightDeniesUnconfiguredOrigin(t *testing.T) {
    +	deps := newTestDependencies(t)
    +	deps.Config.Config.CORSAllowedOrigins = []string{"https://allowed.example"}
    +
    +	server := NewServer(deps)
    +	router, err := server.Handler()
    +	require.NoError(t, err)
    +
    +	req := httptest.NewRequest(http.MethodOptions, "/api/auth/me", nil)
    +	req.Header.Set("Origin", "https://blocked.example")
    +	req.Header.Set("Access-Control-Request-Method", http.MethodGet)
    +
    +	rec := httptest.NewRecorder()
    +
    +	router.ServeHTTP(rec, req)
    +
    +	require.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"))
    +	require.Empty(t, rec.Header().Get("Access-Control-Allow-Credentials"))
    +}
    +
    +func TestCORSAllowsXRequestedWithHeaderForConfiguredOrigin(t *testing.T) {
     	deps := newTestDependencies(t)
    +	deps.Config.Config.CORSAllowedOrigins = []string{"https://example.com"}
     
     	server := NewServer(deps)
     	router, err := server.Handler()
     	require.NoError(t, err)
     
    -	// Preflight request asking if X-Requested-With is allowed
    -	// (browsers send this header in lowercase)
     	req := httptest.NewRequest(http.MethodOptions, "/api/auth/me", nil)
     	req.Header.Set("Origin", "https://example.com")
     	req.Header.Set("Access-Control-Request-Method", http.MethodGet)
    @@ -50,20 +89,40 @@ func TestCORSAllowsXRequestedWithHeader(t *testing.T) {
     
     	router.ServeHTTP(rec, req)
     
    -	// Basic CORS preflight should work
     	require.Equal(t, http.StatusNoContent, rec.Code)
     	require.Equal(t, "https://example.com", rec.Header().Get("Access-Control-Allow-Origin"))
     	require.Equal(t, "true", rec.Header().Get("Access-Control-Allow-Credentials"))
     
    -	// rs/cors echoes back allowed headers (normalized to lowercase)
     	allowedHeaders := strings.ToLower(rec.Header().Get("Access-Control-Allow-Headers"))
     	require.Contains(t, allowedHeaders, "x-requested-with",
     		"CORS should allow X-Requested-With header for SSO proxy compatibility")
     }
     
    -func TestCORSPreflightWithCustomBaseURL(t *testing.T) {
    +func TestCORSPreflightDeniesUnconfiguredRequestHeader(t *testing.T) {
    +	deps := newTestDependencies(t)
    +	deps.Config.Config.CORSAllowedOrigins = []string{"https://example.com"}
    +
    +	server := NewServer(deps)
    +	router, err := server.Handler()
    +	require.NoError(t, err)
    +
    +	req := httptest.NewRequest(http.MethodOptions, "/api/auth/me", nil)
    +	req.Header.Set("Origin", "https://example.com")
    +	req.Header.Set("Access-Control-Request-Method", http.MethodGet)
    +	req.Header.Set("Access-Control-Request-Headers", "x-not-allowed")
    +
    +	rec := httptest.NewRecorder()
    +
    +	router.ServeHTTP(rec, req)
    +
    +	require.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"))
    +	require.Empty(t, rec.Header().Get("Access-Control-Allow-Credentials"))
    +}
    +
    +func TestCORSPreflightWithCustomBaseURLAndConfiguredOrigin(t *testing.T) {
     	deps := newTestDependencies(t)
     	deps.Config.Config.BaseURL = "/qui"
    +	deps.Config.Config.CORSAllowedOrigins = []string{"https://example.com"}
     
     	server := NewServer(deps)
     	router, err := server.Handler()
    @@ -81,3 +140,41 @@ func TestCORSPreflightWithCustomBaseURL(t *testing.T) {
     	require.Equal(t, "https://example.com", rec.Header().Get("Access-Control-Allow-Origin"))
     	require.Equal(t, "true", rec.Header().Get("Access-Control-Allow-Credentials"))
     }
    +
    +func TestCORSGetIncludesHeadersForConfiguredOrigin(t *testing.T) {
    +	deps := newTestDependencies(t)
    +	deps.Config.Config.CORSAllowedOrigins = []string{"https://example.com"}
    +
    +	server := NewServer(deps)
    +	router, err := server.Handler()
    +	require.NoError(t, err)
    +
    +	req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
    +	req.Header.Set("Origin", "https://example.com")
    +
    +	rec := httptest.NewRecorder()
    +
    +	router.ServeHTTP(rec, req)
    +
    +	require.Equal(t, "https://example.com", rec.Header().Get("Access-Control-Allow-Origin"))
    +	require.Equal(t, "true", rec.Header().Get("Access-Control-Allow-Credentials"))
    +}
    +
    +func TestCORSGetOmitsHeadersForUnconfiguredOrigin(t *testing.T) {
    +	deps := newTestDependencies(t)
    +	deps.Config.Config.CORSAllowedOrigins = []string{"https://allowed.example"}
    +
    +	server := NewServer(deps)
    +	router, err := server.Handler()
    +	require.NoError(t, err)
    +
    +	req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
    +	req.Header.Set("Origin", "https://blocked.example")
    +
    +	rec := httptest.NewRecorder()
    +
    +	router.ServeHTTP(rec, req)
    +
    +	require.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"))
    +	require.Empty(t, rec.Header().Get("Access-Control-Allow-Credentials"))
    +}
    
  • internal/api/server.go+12 10 modified
    @@ -277,16 +277,18 @@ func (s *Server) Handler() (*chi.Mux, error) {
     		r.Use(compressor)
     	}
     
    -	// CORS - mirror autobrr's permissive credentials setup
    -	corsMiddleware := cors.New(cors.Options{
    -		AllowCredentials: true,
    -		AllowedMethods:   []string{"HEAD", "OPTIONS", "GET", "POST", "PUT", "PATCH", "DELETE"},
    -		AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-API-Key", "X-Requested-With"},
    -		AllowOriginFunc:  func(origin string) bool { return true },
    -		MaxAge:           300,
    -		Debug:            false,
    -	})
    -	r.Use(corsMiddleware.Handler)
    +	// CORS is disabled by default. Enable only for explicit trusted origins.
    +	if len(s.config.Config.CORSAllowedOrigins) > 0 {
    +		corsMiddleware := cors.New(cors.Options{
    +			AllowCredentials: true,
    +			AllowedOrigins:   s.config.Config.CORSAllowedOrigins,
    +			AllowedMethods:   []string{"HEAD", "OPTIONS", "GET", "POST", "PUT", "PATCH", "DELETE"},
    +			AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-API-Key", "X-Requested-With"},
    +			MaxAge:           300,
    +			Debug:            false,
    +		})
    +		r.Use(corsMiddleware.Handler)
    +	}
     
     	// Session middleware - must be added before any session-dependent middleware
     	r.Use(s.sessionManager.LoadAndSave)
    
  • internal/config/config.go+18 0 modified
    @@ -101,6 +101,7 @@ func (c *AppConfig) defaults() {
     	c.viper.SetDefault("host", host)
     	c.viper.SetDefault("port", 7476)
     	c.viper.SetDefault("baseUrl", "/")
    +	c.viper.SetDefault("corsAllowedOrigins", []string{})
     	c.viper.SetDefault("sessionSecret", sessionSecret)
     	c.viper.SetDefault("logLevel", "INFO")
     	c.viper.SetDefault("logPath", "")
    @@ -203,6 +204,7 @@ func (c *AppConfig) loadFromEnv() {
     	c.viper.BindEnv("host", envPrefix+"HOST")
     	c.viper.BindEnv("port", envPrefix+"PORT")
     	c.viper.BindEnv("baseUrl", envPrefix+"BASE_URL")
    +	c.viper.BindEnv("corsAllowedOrigins", envPrefix+"CORS_ALLOWED_ORIGINS")
     	c.bindOrReadFromFile("sessionSecret", envPrefix+"SESSION_SECRET")
     	c.viper.BindEnv("logLevel", envPrefix+"LOG_LEVEL")
     	c.viper.BindEnv("logPath", envPrefix+"LOG_PATH")
    @@ -256,6 +258,7 @@ func (c *AppConfig) watchConfig() {
     			iAcknowledgeThisIsABadIdea: c.Config.IAcknowledgeThisIsABadIdea,
     			authDisabledAllowedCIDRs:   append([]string(nil), c.Config.AuthDisabledAllowedCIDRs...),
     			oidcEnabled:                c.Config.OIDCEnabled,
    +			corsAllowedOrigins:         append([]string(nil), c.Config.CORSAllowedOrigins...),
     		}
     
     		// Reload configuration
    @@ -275,6 +278,7 @@ type authReloadSettings struct {
     	iAcknowledgeThisIsABadIdea bool
     	authDisabledAllowedCIDRs   []string
     	oidcEnabled                bool
    +	corsAllowedOrigins         []string
     }
     
     func (c *AppConfig) applyDynamicChanges(previousAuthSettings authReloadSettings) {
    @@ -289,10 +293,16 @@ func (c *AppConfig) applyDynamicChanges(previousAuthSettings authReloadSettings)
     		c.Config.IAcknowledgeThisIsABadIdea = previousAuthSettings.iAcknowledgeThisIsABadIdea
     		c.Config.AuthDisabledAllowedCIDRs = append([]string(nil), previousAuthSettings.authDisabledAllowedCIDRs...)
     		c.Config.OIDCEnabled = previousAuthSettings.oidcEnabled
    +		c.Config.CORSAllowedOrigins = append([]string(nil), previousAuthSettings.corsAllowedOrigins...)
     
     		return
     	}
     
    +	if err := c.Config.NormalizeCORSAllowedOrigins(); err != nil {
    +		log.Error().Err(err).Msg("CORS config is invalid after reload; keeping previous valid corsAllowedOrigins")
    +		c.Config.CORSAllowedOrigins = append([]string(nil), previousAuthSettings.corsAllowedOrigins...)
    +	}
    +
     	switch {
     	case c.Config.IsAuthDisabled():
     		log.Warn().Strs("authDisabledAllowedCIDRs", c.Config.AuthDisabledAllowedCIDRs).Msg("Authentication is disabled via QUI__AUTH_DISABLED. Access is restricted to authDisabledAllowedCIDRs. Make sure qui is behind a reverse proxy with its own authentication.")
    @@ -307,6 +317,7 @@ func (c *AppConfig) hydrateConfigFromViper() {
     	c.Config.Host = c.viper.GetString("host")
     	c.Config.Port = c.viper.GetInt("port")
     	c.Config.BaseURL = c.viper.GetString("baseUrl")
    +	c.Config.CORSAllowedOrigins = c.getNormalizedStringSlice("corsAllowedOrigins")
     	c.Config.SessionSecret = c.viper.GetString("sessionSecret")
     
     	c.Config.LogLevel = c.viper.GetString("logLevel")
    @@ -454,6 +465,13 @@ port = {{ .port }}
     # Optional
     #baseUrl = "/qui/"
     
    +# CORS allowlist
    +# Empty (default) disables CORS.
    +# Entries must be explicit origins (scheme + host + optional non-default port).
    +# Wildcards are not allowed.
    +# Example:
    +#corsAllowedOrigins = ["https://sso.example.com", "https://panel.example.com"]
    +
     # Session secret
     # Auto-generated if not provided
     # WARNING: Changing this value will break decryption of existing instance passwords!
    
  • internal/config/config_test.go+86 0 modified
    @@ -387,40 +387,124 @@ func TestApplyDynamicChangesNotifiesOnValidAuthDisabledReload(t *testing.T) {
     	assert.Equal(t, zerolog.ErrorLevel, zerolog.GlobalLevel())
     }
     
    +func TestApplyDynamicChangesRejectsInvalidCORSReload(t *testing.T) {
    +	previousLevel := zerolog.GlobalLevel()
    +	t.Cleanup(func() {
    +		zerolog.SetGlobalLevel(previousLevel)
    +	})
    +
    +	cfg := &AppConfig{
    +		Config: &domain.Config{
    +			LogLevel:                 "info",
    +			CORSAllowedOrigins:       []string{"https://good.example"},
    +			AuthDisabledAllowedCIDRs: []string{},
    +		},
    +		version:    "test",
    +		logManager: NewLogManager("test"),
    +	}
    +
    +	var listenerCalls int32
    +	cfg.RegisterReloadListener(func(conf *domain.Config) {
    +		atomic.AddInt32(&listenerCalls, 1)
    +		assert.Equal(t, []string{"https://good.example"}, conf.CORSAllowedOrigins)
    +	})
    +
    +	previous := authReloadSettings{
    +		corsAllowedOrigins: []string{"https://good.example"},
    +	}
    +
    +	cfg.Config.CORSAllowedOrigins = []string{"https://*.example.com"}
    +	cfg.applyDynamicChanges(previous)
    +
    +	assert.Equal(t, []string{"https://good.example"}, cfg.Config.CORSAllowedOrigins)
    +	assert.Equal(t, int32(1), atomic.LoadInt32(&listenerCalls))
    +}
    +
    +func TestApplyDynamicChangesRejectsInvalidAuthDisabledReloadAlsoRestoresCORS(t *testing.T) {
    +	previousLevel := zerolog.GlobalLevel()
    +	t.Cleanup(func() {
    +		zerolog.SetGlobalLevel(previousLevel)
    +	})
    +
    +	cfg := &AppConfig{
    +		Config: &domain.Config{
    +			LogLevel:                   "warn",
    +			AuthDisabled:               true,
    +			IAcknowledgeThisIsABadIdea: true,
    +			AuthDisabledAllowedCIDRs:   nil, // invalid when auth is disabled
    +			CORSAllowedOrigins:         []string{"https://*.example.com"},
    +		},
    +		version:    "test",
    +		logManager: NewLogManager("test"),
    +	}
    +
    +	var listenerCalls int32
    +	cfg.RegisterReloadListener(func(_ *domain.Config) {
    +		atomic.AddInt32(&listenerCalls, 1)
    +	})
    +
    +	previous := authReloadSettings{
    +		authDisabled:               false,
    +		iAcknowledgeThisIsABadIdea: false,
    +		authDisabledAllowedCIDRs:   nil,
    +		oidcEnabled:                false,
    +		corsAllowedOrigins:         []string{"https://good.example"},
    +	}
    +
    +	cfg.applyDynamicChanges(previous)
    +
    +	assert.False(t, cfg.Config.AuthDisabled)
    +	assert.False(t, cfg.Config.IAcknowledgeThisIsABadIdea)
    +	assert.Nil(t, cfg.Config.AuthDisabledAllowedCIDRs)
    +	assert.False(t, cfg.Config.OIDCEnabled)
    +	assert.Equal(t, []string{"https://good.example"}, cfg.Config.CORSAllowedOrigins)
    +	assert.Equal(t, int32(0), atomic.LoadInt32(&listenerCalls))
    +}
    +
     func TestHydrateConfigFromViperSplitsStringSlices(t *testing.T) {
     	tests := []struct {
     		name                    string
     		authDisabledCIDRsValue  any
    +		corsAllowedOriginsValue any
     		externalAllowListValue  any
     		wantAuthDisabledCIDRs   []string
    +		wantCORSAllowedOrigins  []string
     		wantExternalProgramList []string
     	}{
     		{
     			name:                    "splits comma separated values",
     			authDisabledCIDRsValue:  "127.0.0.1/32, 192.168.1.0/24",
    +			corsAllowedOriginsValue: "https://a.example, https://b.example",
     			externalAllowListValue:  "/usr/local/bin/a, /usr/local/bin/b",
     			wantAuthDisabledCIDRs:   []string{"127.0.0.1/32", "192.168.1.0/24"},
    +			wantCORSAllowedOrigins:  []string{"https://a.example", "https://b.example"},
     			wantExternalProgramList: []string{"/usr/local/bin/a", "/usr/local/bin/b"},
     		},
     		{
     			name:                    "splits whitespace separated values",
     			authDisabledCIDRsValue:  "127.0.0.1/32 192.168.1.0/24",
    +			corsAllowedOriginsValue: "https://a.example https://b.example",
     			externalAllowListValue:  "/usr/local/bin/a /usr/local/bin/b",
     			wantAuthDisabledCIDRs:   []string{"127.0.0.1/32", "192.168.1.0/24"},
    +			wantCORSAllowedOrigins:  []string{"https://a.example", "https://b.example"},
     			wantExternalProgramList: []string{"/usr/local/bin/a", "/usr/local/bin/b"},
     		},
     		{
     			name:                    "trims and drops empty values",
     			authDisabledCIDRsValue:  " , 127.0.0.1/32,,   ",
    +			corsAllowedOriginsValue: " , https://a.example,,   ",
     			externalAllowListValue:  "   ",
     			wantAuthDisabledCIDRs:   []string{"127.0.0.1/32"},
    +			wantCORSAllowedOrigins:  []string{"https://a.example"},
     			wantExternalProgramList: nil,
     		},
     		{
     			name:                    "preserves list values from config",
     			authDisabledCIDRsValue:  []string{" 127.0.0.1/32 ", "", "192.168.1.0/24"},
    +			corsAllowedOriginsValue: []any{" https://a.example ", "", "https://b.example"},
     			externalAllowListValue:  []any{" /usr/local/bin/a ", "", "/usr/local/bin/b"},
     			wantAuthDisabledCIDRs:   []string{"127.0.0.1/32", "192.168.1.0/24"},
    +			wantCORSAllowedOrigins:  []string{"https://a.example", "https://b.example"},
     			wantExternalProgramList: []string{"/usr/local/bin/a", "/usr/local/bin/b"},
     		},
     	}
    @@ -429,6 +513,7 @@ func TestHydrateConfigFromViperSplitsStringSlices(t *testing.T) {
     		t.Run(tt.name, func(t *testing.T) {
     			v := viper.New()
     			v.Set("authDisabledAllowedCIDRs", tt.authDisabledCIDRsValue)
    +			v.Set("corsAllowedOrigins", tt.corsAllowedOriginsValue)
     			v.Set("externalProgramAllowList", tt.externalAllowListValue)
     
     			cfg := &AppConfig{
    @@ -439,6 +524,7 @@ func TestHydrateConfigFromViperSplitsStringSlices(t *testing.T) {
     			cfg.hydrateConfigFromViper()
     
     			assert.Equal(t, tt.wantAuthDisabledCIDRs, cfg.Config.AuthDisabledAllowedCIDRs)
    +			assert.Equal(t, tt.wantCORSAllowedOrigins, cfg.Config.CORSAllowedOrigins)
     			assert.Equal(t, tt.wantExternalProgramList, cfg.Config.ExternalProgramAllowList)
     		})
     	}
    
  • internal/domain/config.go+125 4 modified
    @@ -7,15 +7,21 @@ import (
     	"errors"
     	"fmt"
     	"net/netip"
    +	"net/url"
    +	"strconv"
     	"strings"
    +
    +	"golang.org/x/net/idna"
     )
     
     // Config represents the application configuration
     type Config struct {
    -	Version                  string
    -	Host                     string `toml:"host" mapstructure:"host"`
    -	Port                     int    `toml:"port" mapstructure:"port"`
    -	BaseURL                  string `toml:"baseUrl" mapstructure:"baseUrl"`
    +	Version            string
    +	Host               string   `toml:"host" mapstructure:"host"`
    +	Port               int      `toml:"port" mapstructure:"port"`
    +	BaseURL            string   `toml:"baseUrl" mapstructure:"baseUrl"`
    +	CORSAllowedOrigins []string `toml:"corsAllowedOrigins" mapstructure:"corsAllowedOrigins"`
    +	//nolint:gosec // Config schema requires this field name; value is provided by runtime configuration.
     	SessionSecret            string `toml:"sessionSecret" mapstructure:"sessionSecret"`
     	LogLevel                 string `toml:"logLevel" mapstructure:"logLevel"`
     	LogPath                  string `toml:"logPath" mapstructure:"logPath"`
    @@ -126,3 +132,118 @@ func (c *Config) ValidateAuthDisabledConfig() error {
     
     	return nil
     }
    +
    +// NormalizeCORSAllowedOrigins validates and canonicalizes CORS allowlist entries.
    +// Empty values are ignored, wildcard origins are rejected, and valid entries are
    +// normalized to browser-style origins (scheme://host[:port]).
    +func (c *Config) NormalizeCORSAllowedOrigins() error {
    +	if len(c.CORSAllowedOrigins) == 0 {
    +		c.CORSAllowedOrigins = nil
    +		return nil
    +	}
    +
    +	normalized := make([]string, 0, len(c.CORSAllowedOrigins))
    +	seen := make(map[string]struct{}, len(c.CORSAllowedOrigins))
    +
    +	for _, raw := range c.CORSAllowedOrigins {
    +		entry := strings.TrimSpace(raw)
    +		if entry == "" {
    +			continue
    +		}
    +
    +		origin, err := normalizeCORSOrigin(entry)
    +		if err != nil {
    +			return fmt.Errorf("invalid corsAllowedOrigins entry %q: %w", entry, err)
    +		}
    +
    +		if _, exists := seen[origin]; exists {
    +			continue
    +		}
    +		seen[origin] = struct{}{}
    +		normalized = append(normalized, origin)
    +	}
    +
    +	c.CORSAllowedOrigins = normalized
    +	return nil
    +}
    +
    +func normalizeCORSOrigin(origin string) (string, error) {
    +	if strings.Contains(origin, "*") {
    +		return "", errors.New("wildcards are not allowed")
    +	}
    +
    +	parsed, err := url.Parse(origin)
    +	if err != nil {
    +		return "", err
    +	}
    +
    +	scheme := strings.ToLower(parsed.Scheme)
    +	if scheme != "http" && scheme != "https" {
    +		return "", errors.New("scheme must be http or https")
    +	}
    +
    +	if parsed.User != nil {
    +		return "", errors.New("userinfo is not allowed")
    +	}
    +
    +	if parsed.RawQuery != "" || parsed.Fragment != "" {
    +		return "", errors.New("query and fragment are not allowed")
    +	}
    +
    +	if parsed.Path != "" && parsed.Path != "/" {
    +		return "", errors.New("path is not allowed")
    +	}
    +
    +	if parsed.Path == "/" {
    +		return "", errors.New("trailing slash is not allowed")
    +	}
    +
    +	host := strings.ToLower(parsed.Hostname())
    +	if host == "" {
    +		return "", errors.New("host is required")
    +	}
    +
    +	if strings.Contains(host, "*") {
    +		return "", errors.New("wildcards are not allowed")
    +	}
    +
    +	if parsed.Opaque != "" {
    +		return "", errors.New("opaque origins are not allowed")
    +	}
    +
    +	if _, err := netip.ParseAddr(host); err != nil {
    +		asciiHost, asciiErr := idna.Lookup.ToASCII(host)
    +		if asciiErr != nil {
    +			return "", fmt.Errorf("invalid hostname: %w", asciiErr)
    +		}
    +		host = strings.ToLower(asciiHost)
    +	}
    +
    +	port := parsed.Port()
    +	if port != "" {
    +		portNum, convErr := strconv.Atoi(port)
    +		if convErr != nil {
    +			return "", errors.New("port must be numeric")
    +		}
    +		if portNum < 1 || portNum > 65535 {
    +			return "", errors.New("port must be between 1 and 65535")
    +		}
    +
    +		if (scheme == "http" && portNum == 80) || (scheme == "https" && portNum == 443) {
    +			port = ""
    +		} else {
    +			port = strconv.Itoa(portNum)
    +		}
    +	}
    +
    +	canonicalHost := host
    +	if strings.Contains(canonicalHost, ":") {
    +		canonicalHost = "[" + canonicalHost + "]"
    +	}
    +
    +	if port != "" {
    +		canonicalHost += ":" + port
    +	}
    +
    +	return scheme + "://" + canonicalHost, nil
    +}
    
  • internal/domain/config_test.go+53 0 modified
    @@ -109,3 +109,56 @@ func TestValidateAuthDisabledConfig(t *testing.T) {
     		})
     	}
     }
    +
    +func TestNormalizeCORSAllowedOrigins(t *testing.T) {
    +	t.Run("normalizes and deduplicates valid origins", func(t *testing.T) {
    +		cfg := &Config{
    +			CORSAllowedOrigins: []string{
    +				" HTTPS://Example.COM:443 ",
    +				"https://example.com",
    +				"http://example.com:80",
    +				"http://example.com:8080",
    +				"https://bücher.example",
    +				"https://[2001:db8::1]:443",
    +				"https://[2001:db8::1]:8443",
    +			},
    +		}
    +
    +		err := cfg.NormalizeCORSAllowedOrigins()
    +		require.NoError(t, err)
    +		assert.Equal(t, []string{
    +			"https://example.com",
    +			"http://example.com",
    +			"http://example.com:8080",
    +			"https://xn--bcher-kva.example",
    +			"https://[2001:db8::1]",
    +			"https://[2001:db8::1]:8443",
    +		}, cfg.CORSAllowedOrigins)
    +	})
    +
    +	tests := []struct {
    +		name       string
    +		origins    []string
    +		wantErrSub string
    +	}{
    +		{name: "reject wildcard literal", origins: []string{"*"}, wantErrSub: "wildcards are not allowed"},
    +		{name: "reject wildcard host", origins: []string{"https://*.example.com"}, wantErrSub: "wildcards are not allowed"},
    +		{name: "reject path", origins: []string{"https://example.com/api"}, wantErrSub: "path is not allowed"},
    +		{name: "reject trailing slash", origins: []string{"https://example.com/"}, wantErrSub: "trailing slash is not allowed"},
    +		{name: "reject query", origins: []string{"https://example.com?q=1"}, wantErrSub: "query and fragment are not allowed"},
    +		{name: "reject fragment", origins: []string{"https://example.com#frag"}, wantErrSub: "query and fragment are not allowed"},
    +		{name: "reject userinfo", origins: []string{"https://user:pass@example.com"}, wantErrSub: "userinfo is not allowed"},
    +		{name: "reject non-http scheme", origins: []string{"ftp://example.com"}, wantErrSub: "scheme must be http or https"},
    +		{name: "reject non-numeric port", origins: []string{"https://example.com:abc"}, wantErrSub: "invalid port"},
    +		{name: "reject out of range port", origins: []string{"https://example.com:70000"}, wantErrSub: "port must be between 1 and 65535"},
    +	}
    +
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			cfg := &Config{CORSAllowedOrigins: tc.origins}
    +			err := cfg.NormalizeCORSAllowedOrigins()
    +			require.Error(t, err)
    +			assert.Contains(t, err.Error(), tc.wantErrSub)
    +		})
    +	}
    +}
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.