Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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;\"'") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are commas filtered here? Could that potentially break headers if someone was misconfigured?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Added , to the blocked character list in isValidConnectSrcURL. Commas are valid in URLs but could cause confusion if someone uses them as CSP list separators. No harm in rejecting them defensively.

Resolved in the latest commit.

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 {
valid, skippedKeys := cspConnectSrcFromSettings(settings)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I think the prefix you went with will collide with the generic plugin-settings namespace. I think this would inject or degub on every page load and there's no opt in. Not sure the best way to handle this tbh.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this was a real concern. Added an opt-in mechanism: plugins must now set csp-settings: true in their ui section to enable the csp_ setting prefix. Without it, csp_-prefixed settings are ignored for CSP purposes.

This prevents accidental namespace collisions. The flag is plumbed through UIConfig (yaml: csp-settings) and PluginUI (json: csp_settings), and the cspConnectSrcFromSettings call is gated on ui.CSPSettings.

Also updated Plugins.md to document the opt-in requirement.

connectSrcSlice = append(connectSrcSlice, valid...)
for _, key := range skippedKeys {
logger.Debugf("skipping invalid csp_ setting %q for plugin %q", key, plugin.ID)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this be better as a warn?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Changed Debugf to Warnf. A skipped CSP setting means a plugin author misconfigured something that silently breaks functionality — worth surfacing at warn level.

}
}

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

import (
"testing"

"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: "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)
})
}
}
9 changes: 8 additions & 1 deletion ui/v2.5/src/docs/en/Manual/Plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ 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.

Only values that are valid `http`/`https` URLs with a host are accepted. Wildcard hosts, URLs containing whitespace 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 +160,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, 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