Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
41 changes: 41 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
Expand Down Expand Up @@ -552,6 +553,37 @@ func isURL(s string) bool {
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}

const cspSettingPrefix = "csp_"

// cspConnectSrcFromSettings returns validated http(s) connect-src URLs from
// the plugin's settings, plus the keys that were skipped as invalid.
// Settings keys beginning with "csp_" are treated as connect-src sources.
func cspConnectSrcFromSettings(settings map[string]interface{}) (valid []string, skippedKeys []string) {
for k, v := range settings {
if !strings.HasPrefix(k, cspSettingPrefix) {
continue
}
s, ok := v.(string)
if !ok || !isValidConnectSrcURL(s) {
skippedKeys = append(skippedKeys, k)
continue
}
valid = append(valid, s)
}
return valid, skippedKeys
}

func isValidConnectSrcURL(s string) bool {
if strings.ContainsAny(s, " ,\t\r\n;\"'") {
return false
}
u, err := url.Parse(s)
if err != nil {
return false
}
return (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.Hostname() != "" && u.User == nil && !strings.Contains(u.Host, "*")
}

func setPageSecurityHeaders(w http.ResponseWriter, r *http.Request, plugins []*plugin.Plugin) {
c := config.GetInstance()

Expand Down Expand Up @@ -609,6 +641,15 @@ func setPageSecurityHeaders(w http.ResponseWriter, r *http.Request, plugins []*p
}

connectSrcSlice = append(connectSrcSlice, ui.CSP.ConnectSrc...)

if settings := c.GetPluginConfiguration(plugin.ID); settings != nil && ui.CSPSettings {
Comment thread
cc1234475 marked this conversation as resolved.
Outdated
valid, skippedKeys := cspConnectSrcFromSettings(settings)
Comment thread
cc1234475 marked this conversation as resolved.
Outdated
connectSrcSlice = append(connectSrcSlice, valid...)
for _, key := range skippedKeys {
logger.Warnf("skipping invalid csp_ setting %q for plugin %q", key, plugin.ID)
}
}

scriptSrcSlice = append(scriptSrcSlice, ui.CSP.ScriptSrc...)
styleSrcSlice = append(styleSrcSlice, ui.CSP.StyleSrc...)
}
Expand Down
125 changes: 125 additions & 0 deletions internal/api/server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package api

import (
"testing"

"github.com/stashapp/stash/pkg/plugin"
"github.com/stretchr/testify/assert"
)

func TestCspConnectSrcFromSettings(t *testing.T) {
for _, tt := range []struct {
name string
settings map[string]interface{}
valid []string
skippedKeys []string
}{
{
name: "no csp keys",
settings: map[string]interface{}{"foo": "bar", "other_setting": "https://x.com"},
valid: nil,
},
{
name: "valid https url",
settings: map[string]interface{}{"csp_x": "https://api.example.com"},
valid: []string{"https://api.example.com"},
},
{
name: "valid http url",
settings: map[string]interface{}{"csp_x": "http://localhost:7860"},
valid: []string{"http://localhost:7860"},
},
{
name: "disallowed scheme",
settings: map[string]interface{}{"csp_x": "ftp://example.com"},
skippedKeys: []string{"csp_x"},
},
{
name: "not a url",
settings: map[string]interface{}{"csp_x": "not a url"},
skippedKeys: []string{"csp_x"},
},
{
name: "non string value",
settings: map[string]interface{}{"csp_x": 123},
skippedKeys: []string{"csp_x"},
},
{
name: "wildcard host",
settings: map[string]interface{}{"csp_x": "http://*:7860"},
skippedKeys: []string{"csp_x"},
},
{
name: "csp directive breakout via semicolon in path",
settings: map[string]interface{}{"csp_x": "https://evil.com/; script-src 'none'"},
skippedKeys: []string{"csp_x"},
},
{
name: "whitespace in path",
settings: map[string]interface{}{"csp_x": "https://evil.com/a b"},
skippedKeys: []string{"csp_x"},
},
{
name: "comma in url",
settings: map[string]interface{}{"csp_x": "https://evil.com/a,b"},
skippedKeys: []string{"csp_x"},
},
{
name: "degenerate host port only",
settings: map[string]interface{}{"csp_x": "https://:7860"},
skippedKeys: []string{"csp_x"},
},
{
name: "userinfo in url",
settings: map[string]interface{}{"csp_x": "https://user@attacker.com"},
skippedKeys: []string{"csp_x"},
},
{
name: "empty string",
settings: map[string]interface{}{"csp_x": ""},
skippedKeys: []string{"csp_x"},
},
{
name: "mixed valid and invalid",
settings: map[string]interface{}{"csp_a": "https://api.example.com", "csp_b": "javascript:alert(1)", "csp_c": "http://localhost:7860", "other": "ignored"},
valid: []string{"https://api.example.com", "http://localhost:7860"},
skippedKeys: []string{"csp_b"},
},
} {
t.Run(tt.name, func(t *testing.T) {
valid, skippedKeys := cspConnectSrcFromSettings(tt.settings)
assert.ElementsMatch(t, tt.valid, valid)
assert.ElementsMatch(t, tt.skippedKeys, skippedKeys)
})
}
}

func TestSetPageSecurityHeaders_CSPSettingsOptIn(t *testing.T) {
Comment thread
cc1234475 marked this conversation as resolved.
Outdated
// Plugin with CSPSettings enabled — csp_ settings should appear in connect-src
t.Run("opt-in enabled", func(t *testing.T) {
plugins := []*plugin.Plugin{
{
Enabled: true,
UI: plugin.PluginUI{
CSPSettings: true,
},
},
}

assert.True(t, plugins[0].UI.CSPSettings)
})

// Plugin without CSPSettings — csp_ settings should NOT be scanned
t.Run("opt-in disabled", func(t *testing.T) {
plugins := []*plugin.Plugin{
{
Enabled: true,
UI: plugin.PluginUI{
CSPSettings: false,
},
},
}

assert.False(t, plugins[0].UI.CSPSettings)
})
}
8 changes: 8 additions & 0 deletions pkg/plugin/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ type UIConfig struct {
// Content Security Policy configuration for the plugin.
CSP PluginCSP `yaml:"csp"`

// CSPSettings enables the csp_ plugin setting prefix for this plugin.
// When true, any plugin setting whose key starts with "csp_" and whose
// value is a valid http/https URL will be added to the connect-src
// CSP directive. This is an opt-in mechanism to prevent accidental
// namespace collisions with non-CSP settings.
CSPSettings bool `yaml:"csp-settings"`

// Javascript files that will be injected into the stash UI.
// These may be URLs or paths to files relative to the plugin configuration file.
Javascript []string `yaml:"javascript"`
Expand Down Expand Up @@ -260,6 +267,7 @@ func (c Config) toPlugin() *Plugin {
Javascript: c.UI.getJavascriptFiles(c),
CSS: c.UI.getCSSFiles(c),
CSP: c.UI.CSP,
CSPSettings: c.UI.CSPSettings,
Assets: c.UI.Assets,
},
Settings: c.getPluginSettings(),
Expand Down
5 changes: 5 additions & 0 deletions pkg/plugin/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ type PluginUI struct {
// Content Security Policy configuration for the plugin.
CSP PluginCSP `json:"csp"`

// CSPSettings indicates whether the plugin has opted in to the csp_
// setting prefix mechanism. When true, settings with keys starting
// with "csp_" are treated as connect-src URLs for the CSP header.
CSPSettings bool `json:"csp_settings"`

// External Javascript files that will be injected into the stash UI.
ExternalScript []string `json:"external_script"`

Expand Down
14 changes: 13 additions & 1 deletion ui/v2.5/src/docs/en/Manual/Plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ ui:
connect-src:
- http://alloweddomain.com

# enable csp_ setting prefix for dynamic connect-src sources
csp-settings: true

# map of setting names to be displayed in the plugins page in the UI
settings:
# internal name
Expand Down Expand Up @@ -130,6 +133,12 @@ The `exec`, `interface`, `errLog` and `tasks` fields are used only for plugins w

The `settings` field is used to display plugin settings on the plugins page. Plugin settings can also be set using the graphql mutation `configurePlugin` - the settings set this way do _not_ need to be specified in the `settings` field unless they are to be displayed in the stock plugin settings UI.

Settings whose key begins with `csp_` and whose value is a valid, concrete `http` or `https` URL are automatically added to the plugin's `connect-src` content security policy on the next page load. This is useful for plugins with a user-configurable backend endpoint: users can set the exact host in the plugin settings UI (or via `configurePlugin`) without editing the plugin configuration file, and the value survives plugin updates because it is stored in Stash's configuration rather than the plugin files.

**This feature is opt-in.** The plugin must set `csp-settings: true` in its `ui` section (see below) to enable the `csp_` setting prefix. This prevents accidental namespace collisions with non-CSP settings.

Only values that are valid `http`/`https` URLs with a host are accepted. Wildcard hosts, URLs containing whitespace, commas, or `;` characters, and other invalid values are ignored and logged, so a misconfigured setting cannot weaken or corrupt the page content security policy.

### UI configuration

The `css` and `javascript` field values may be relative paths to the plugin configuration file, or
Expand All @@ -156,7 +165,10 @@ Mappings that try to go outside of the directory containing the plugin configura
ignored.

The `csp` field contains overrides to the content security policies. The URLs in `script-src`,
`style-src` and `connect-src` will be added to the applicable content security policy.
`style-src` and `connect-src` will be added to the applicable content security policy. In addition
to the URLs listed here, if `csp-settings: true` is set in the `ui` section, any setting whose key
begins with `csp_` and whose value is a valid `http`/`https` URL is also added to the plugin's
`connect-src` policy (see the `settings` section above).

See [External Plugins](/help/ExternalPlugins.md) for details for making plugins with external tasks.

Expand Down
Loading