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.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/autobrr/quiGo | < 1.15.0 | 1.15.0 |
Affected products
1Patches
1424f7a0de089fix(api): restrict CORS to explicit allowlist (#1551)
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- github.com/autobrr/qui/commit/424f7a0de089dce881e8bbecd220163a78e0295fnvdPatchWEB
- github.com/advisories/GHSA-h8vw-ph9r-xpchghsaADVISORY
- github.com/autobrr/qui/security/advisories/GHSA-h8vw-ph9r-xpchnvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-30924ghsaADVISORY
News mentions
0No linked articles in our index yet.