Skip to content

Commit

Permalink
Plugin assets, external scripts and CSP overrides (#4260)
Browse files Browse the repository at this point in the history
* Add assets for plugins
* Move plugin javascript and css into separate endpoints
* Allow loading external scripts
* Add csp overrides
* Only include enabled plugins
* Move URLMap to utils
* Use URLMap for assets
* Add documentation
  • Loading branch information
WithoutPants authored Nov 18, 2023
1 parent 4dd4c3c commit 222475d
Show file tree
Hide file tree
Showing 20 changed files with 621 additions and 105 deletions.
5 changes: 5 additions & 0 deletions graphql/documents/queries/plugins.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ query Plugins {
description
type
}

paths {
css
javascript
}
}
}

Expand Down
9 changes: 9 additions & 0 deletions graphql/schema/types/plugin.graphql
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
type PluginPaths {
# path to javascript files
javascript: [String!]
# path to css files
css: [String!]
}

type Plugin {
id: ID!
name: String!
Expand All @@ -10,6 +17,8 @@ type Plugin {
tasks: [PluginTask!]
hooks: [PluginHook!]
settings: [PluginSetting!]

paths: PluginPaths!
}

type PluginTask {
Expand Down
1 change: 1 addition & 0 deletions internal/api/context_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ const (
tagKey
downloadKey
imageKey
pluginKey
)
4 changes: 4 additions & 0 deletions internal/api/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ func (r *Resolver) Tag() TagResolver {
func (r *Resolver) SavedFilter() SavedFilterResolver {
return &savedFilterResolver{r}
}
func (r *Resolver) Plugin() PluginResolver {
return &pluginResolver{r}
}
func (r *Resolver) ConfigResult() ConfigResultResolver {
return &configResultResolver{r}
}
Expand All @@ -102,6 +105,7 @@ type studioResolver struct{ *Resolver }
type movieResolver struct{ *Resolver }
type tagResolver struct{ *Resolver }
type savedFilterResolver struct{ *Resolver }
type pluginResolver struct{ *Resolver }
type configResultResolver struct{ *Resolver }

func (r *Resolver) withTxn(ctx context.Context, fn func(ctx context.Context) error) error {
Expand Down
57 changes: 57 additions & 0 deletions internal/api/resolver_model_plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package api

import (
"context"

"github.com/stashapp/stash/pkg/plugin"
)

type pluginURLBuilder struct {
BaseURL string
Plugin *plugin.Plugin
}

func (b pluginURLBuilder) javascript() []string {
ui := b.Plugin.UI
if len(ui.Javascript) == 0 && len(ui.ExternalScript) == 0 {
return nil
}

var ret []string

ret = append(ret, ui.ExternalScript...)
ret = append(ret, b.BaseURL+"/plugin/"+b.Plugin.ID+"/javascript")

return ret
}

func (b pluginURLBuilder) css() []string {
ui := b.Plugin.UI
if len(ui.CSS) == 0 && len(ui.ExternalCSS) == 0 {
return nil
}

var ret []string

ret = append(ret, b.Plugin.UI.ExternalCSS...)
ret = append(ret, b.BaseURL+"/plugin/"+b.Plugin.ID+"/css")
return ret
}

func (b *pluginURLBuilder) paths() *PluginPaths {
return &PluginPaths{
Javascript: b.javascript(),
CSS: b.css(),
}
}

func (r *pluginResolver) Paths(ctx context.Context, obj *plugin.Plugin) (*PluginPaths, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)

b := pluginURLBuilder{
BaseURL: baseURL,
Plugin: obj,
}

return b.paths(), nil
}
7 changes: 3 additions & 4 deletions internal/api/routes_custom.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@ import (
"strings"

"github.com/go-chi/chi/v5"

"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/utils"
)

type customRoutes struct {
servedFolders config.URLMap
servedFolders utils.URLMap
}

func getCustomRoutes(servedFolders config.URLMap) chi.Router {
func getCustomRoutes(servedFolders utils.URLMap) chi.Router {
return customRoutes{servedFolders: servedFolders}.Routes()
}

Expand Down
107 changes: 107 additions & 0 deletions internal/api/routes_plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package api

import (
"context"
"net/http"
"path/filepath"
"strings"

"github.com/go-chi/chi/v5"

"github.com/stashapp/stash/pkg/plugin"
)

type pluginRoutes struct {
pluginCache *plugin.Cache
}

func getPluginRoutes(pluginCache *plugin.Cache) chi.Router {
return pluginRoutes{
pluginCache: pluginCache,
}.Routes()
}

func (rs pluginRoutes) Routes() chi.Router {
r := chi.NewRouter()

r.Route("/{pluginId}", func(r chi.Router) {
r.Use(rs.PluginCtx)
r.Get("/assets/*", rs.Assets)
r.Get("/javascript", rs.Javascript)
r.Get("/css", rs.CSS)
})

return r
}

func (rs pluginRoutes) Assets(w http.ResponseWriter, r *http.Request) {
p := r.Context().Value(pluginKey).(*plugin.Plugin)

if !p.Enabled {
http.Error(w, "plugin disabled", http.StatusBadRequest)
return
}

prefix := "/plugin/" + chi.URLParam(r, "pluginId") + "/assets"

r.URL.Path = strings.Replace(r.URL.Path, prefix, "", 1)

// http.FileServer redirects to / if the path ends with index.html
r.URL.Path = strings.TrimSuffix(r.URL.Path, "/index.html")

pluginDir := filepath.Dir(p.ConfigPath)

// map the path to the applicable filesystem location
var dir string
r.URL.Path, dir = p.UI.Assets.GetFilesystemLocation(r.URL.Path)
if dir == "" {
http.NotFound(w, r)
}

dir = filepath.Join(pluginDir, filepath.FromSlash(dir))

// ensure directory is still within the plugin directory
if !strings.HasPrefix(dir, pluginDir) {
http.NotFound(w, r)
return
}

http.FileServer(http.Dir(dir)).ServeHTTP(w, r)
}

func (rs pluginRoutes) Javascript(w http.ResponseWriter, r *http.Request) {
p := r.Context().Value(pluginKey).(*plugin.Plugin)

if !p.Enabled {
http.Error(w, "plugin disabled", http.StatusBadRequest)
return
}

w.Header().Set("Content-Type", "text/javascript")
serveFiles(w, r, p.UI.Javascript)
}

func (rs pluginRoutes) CSS(w http.ResponseWriter, r *http.Request) {
p := r.Context().Value(pluginKey).(*plugin.Plugin)

if !p.Enabled {
http.Error(w, "plugin disabled", http.StatusBadRequest)
return
}

w.Header().Set("Content-Type", "text/css")
serveFiles(w, r, p.UI.CSS)
}

func (rs pluginRoutes) PluginCtx(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := rs.pluginCache.GetPlugin(chi.URLParam(r, "pluginId"))
if p == nil {
http.Error(w, http.StatusText(404), 404)
return
}

ctx := context.WithValue(r.Context(), pluginKey, p)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
93 changes: 60 additions & 33 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ func Start() error {

r.HandleFunc(gqlEndpoint, gqlHandlerFunc)
r.HandleFunc(playgroundEndpoint, func(w http.ResponseWriter, r *http.Request) {
setPageSecurityHeaders(w, r)
setPageSecurityHeaders(w, r, pluginCache.ListPlugins())
endpoint := getProxyPrefix(r) + gqlEndpoint
gqlPlayground.Handler("GraphQL playground", endpoint)(w, r)
})
Expand All @@ -150,9 +150,10 @@ func Start() error {
r.Mount("/movie", getMovieRoutes(repo))
r.Mount("/tag", getTagRoutes(repo))
r.Mount("/downloads", getDownloadsRoutes())
r.Mount("/plugin", getPluginRoutes(pluginCache))

r.HandleFunc("/css", cssHandler(c, pluginCache))
r.HandleFunc("/javascript", javascriptHandler(c, pluginCache))
r.HandleFunc("/css", cssHandler(c))
r.HandleFunc("/javascript", javascriptHandler(c))
r.HandleFunc("/customlocales", customLocalesHandler(c))

staticLoginUI := statigz.FileServer(loginUIBox.(fs.ReadDirFS))
Expand Down Expand Up @@ -201,7 +202,7 @@ func Start() error {
indexHtml = strings.Replace(indexHtml, `<base href="/"`, fmt.Sprintf(`<base href="%s/"`, prefix), 1)

w.Header().Set("Content-Type", "text/html")
setPageSecurityHeaders(w, r)
setPageSecurityHeaders(w, r, pluginCache.ListPlugins())

utils.ServeStaticContent(w, r, []byte(indexHtml))
} else {
Expand Down Expand Up @@ -289,19 +290,10 @@ func serveFiles(w http.ResponseWriter, r *http.Request, paths []string) {
utils.ServeStaticContent(w, r, buffer.Bytes())
}

func cssHandler(c *config.Instance, pluginCache *plugin.Cache) func(w http.ResponseWriter, r *http.Request) {
func cssHandler(c *config.Instance) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// add plugin css files first
var paths []string

for _, p := range pluginCache.ListPlugins() {
if !p.Enabled {
continue
}

paths = append(paths, p.UI.CSS...)
}

if c.GetCSSEnabled() {
// search for custom.css in current directory, then $HOME/.stash
fn := c.GetCSSPath()
Expand All @@ -316,19 +308,10 @@ func cssHandler(c *config.Instance, pluginCache *plugin.Cache) func(w http.Respo
}
}

func javascriptHandler(c *config.Instance, pluginCache *plugin.Cache) func(w http.ResponseWriter, r *http.Request) {
func javascriptHandler(c *config.Instance) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// add plugin javascript files first
var paths []string

for _, p := range pluginCache.ListPlugins() {
if !p.Enabled {
continue
}

paths = append(paths, p.UI.Javascript...)
}

if c.GetJavascriptEnabled() {
// search for custom.js in current directory, then $HOME/.stash
fn := c.GetJavascriptPath()
Expand Down Expand Up @@ -408,31 +391,75 @@ func makeTLSConfig(c *config.Instance) (*tls.Config, error) {
return tlsConfig, nil
}

func setPageSecurityHeaders(w http.ResponseWriter, r *http.Request) {
func isURL(s string) bool {
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}

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

defaultSrc := "data: 'self' 'unsafe-inline'"
connectSrc := "data: 'self'"
connectSrcSlice := []string{
"data:",
"'self'",
}
imageSrc := "data: *"
scriptSrc := "'self' http://www.gstatic.com https://www.gstatic.com 'unsafe-inline' 'unsafe-eval'"
styleSrc := "'self' 'unsafe-inline'"
scriptSrcSlice := []string{
"'self'",
"http://www.gstatic.com",
"https://www.gstatic.com",
"'unsafe-inline'",
"'unsafe-eval'",
}
styleSrcSlice := []string{
"'self'",
"'unsafe-inline'",
}
mediaSrc := "blob: 'self'"

// Workaround Safari bug https://bugs.webkit.org/show_bug.cgi?id=201591
// Allows websocket requests to any origin
connectSrc += " ws: wss:"
connectSrcSlice = append(connectSrcSlice, "ws:", "wss:")

// The graphql playground pulls its frontend from a cdn
if r.URL.Path == playgroundEndpoint {
connectSrc += " https://cdn.jsdelivr.net"
scriptSrc += " https://cdn.jsdelivr.net"
styleSrc += " https://cdn.jsdelivr.net"
connectSrcSlice = append(connectSrcSlice, "https://cdn.jsdelivr.net")
scriptSrcSlice = append(scriptSrcSlice, "https://cdn.jsdelivr.net")
styleSrcSlice = append(styleSrcSlice, "https://cdn.jsdelivr.net")
}

if !c.IsNewSystem() && c.GetHandyKey() != "" {
connectSrc += " https://www.handyfeeling.com"
connectSrcSlice = append(connectSrcSlice, "https://www.handyfeeling.com")
}

for _, plugin := range plugins {
if !plugin.Enabled {
continue
}

ui := plugin.UI

for _, url := range ui.ExternalScript {
if isURL(url) {
scriptSrcSlice = append(scriptSrcSlice, url)
}
}

for _, url := range ui.ExternalCSS {
if isURL(url) {
styleSrcSlice = append(styleSrcSlice, url)
}
}

connectSrcSlice = append(connectSrcSlice, ui.CSP.ConnectSrc...)
scriptSrcSlice = append(scriptSrcSlice, ui.CSP.ScriptSrc...)
styleSrcSlice = append(styleSrcSlice, ui.CSP.StyleSrc...)
}

connectSrc := strings.Join(connectSrcSlice, " ")
scriptSrc := strings.Join(scriptSrcSlice, " ")
styleSrc := strings.Join(styleSrcSlice, " ")

cspDirectives := fmt.Sprintf("default-src %s; connect-src %s; img-src %s; script-src %s; style-src %s; media-src %s;", defaultSrc, connectSrc, imageSrc, scriptSrc, styleSrc, mediaSrc)
cspDirectives += " worker-src blob:; child-src 'none'; object-src 'none'; form-action 'self';"

Expand Down
Loading

0 comments on commit 222475d

Please sign in to comment.