diff --git a/utils/subpath.go b/utils/subpath.go index 457627af30..9bb75db3de 100644 --- a/utils/subpath.go +++ b/utils/subpath.go @@ -22,6 +22,29 @@ import ( "github.com/mattermost/mattermost-server/utils/fileutils" ) +// getSubpathScript renders the inline script that defines window.publicPath to change how webpack loads assets. +func getSubpathScript(subpath string) string { + if subpath == "" { + subpath = "/" + } + + newPath := path.Join(subpath, "static") + "/" + + return fmt.Sprintf("window.publicPath='%s'", newPath) +} + +// GetSubpathScriptHash computes the script-src addition required for the subpath script to bypass CSP protections. +func GetSubpathScriptHash(subpath string) string { + // No hash is required for the default subpath. + if subpath == "" || subpath == "/" { + return "" + } + + scriptHash := sha256.Sum256([]byte(getSubpathScript(subpath))) + + return fmt.Sprintf(" 'sha256-%s'", base64.StdEncoding.EncodeToString(scriptHash[:])) +} + // UpdateAssetsSubpath rewrites assets in the /client directory to assume the application is hosted // at the given subpath instead of at the root. No changes are written unless necessary. func UpdateAssetsSubpath(subpath string) error { @@ -45,47 +68,47 @@ func UpdateAssetsSubpath(subpath string) error { return errors.Wrap(err, "failed to open root.html") } - pathToReplace := "/static/" - newPath := path.Join(subpath, "static") + "/" + oldSubpath := "/" // Determine if a previous subpath had already been rewritten into the assets. - reWebpackPublicPathScript := regexp.MustCompile("window.publicPath='([^']+)'") + reWebpackPublicPathScript := regexp.MustCompile("window.publicPath='([^']+/)static/'") alreadyRewritten := false if matches := reWebpackPublicPathScript.FindStringSubmatch(string(oldRootHtml)); matches != nil { - pathToReplace = matches[1] + oldSubpath = matches[1] alreadyRewritten = true } - if pathToReplace == newPath { - mlog.Debug("No rewrite required for static assets", mlog.String("path", pathToReplace)) - return nil - } + pathToReplace := path.Join(oldSubpath, "static") + "/" + newPath := path.Join(subpath, "static") + "/" - mlog.Debug("Rewriting static assets", mlog.String("from_path", pathToReplace), mlog.String("to_path", newPath)) + mlog.Debug("Rewriting static assets", mlog.String("from_subpath", oldSubpath), mlog.String("to_subpath", subpath)) newRootHtml := string(oldRootHtml) - // Compute the sha256 hash for the inline script and reference same in the CSP meta tag. - // This allows the inline script defining `window.publicPath` to bypass CSP protections. - script := fmt.Sprintf("window.publicPath='%s'", newPath) - scriptHash := sha256.Sum256([]byte(script)) - reCSP := regexp.MustCompile(``) if results := reCSP.FindAllString(newRootHtml, -1); len(results) == 0 { return fmt.Errorf("failed to find 'Content-Security-Policy' meta tag to rewrite") } newRootHtml = reCSP.ReplaceAllLiteralString(newRootHtml, fmt.Sprintf( - ``, - base64.StdEncoding.EncodeToString(scriptHash[:]), + ``, + GetSubpathScriptHash(subpath), )) - // Rewrite the root.html references to `/static/*` to include the given subpath. This - // potentially includes a previously injected inline script. + // Rewrite the root.html references to `/static/*` to include the given subpath. + // This potentially includes a previously injected inline script that needs to + // be updated (and isn't covered by the cases above). newRootHtml = strings.Replace(newRootHtml, pathToReplace, newPath, -1) - // Inject the script, if needed, to define `window.publicPath`. - if !alreadyRewritten { + if alreadyRewritten && subpath == "/" { + // Remove the injected script since no longer required. Note that the rewrite above + // will have affected the script, so look for the new subpath, not the old one. + oldScript := getSubpathScript(subpath) + newRootHtml = strings.Replace(newRootHtml, fmt.Sprintf("", oldScript), "", 1) + + } else if !alreadyRewritten && subpath != "/" { + // Otherwise, inject the script to define `window.publicPath`. + script := getSubpathScript(subpath) newRootHtml = strings.Replace(newRootHtml, "", fmt.Sprintf("", script), 1) } diff --git a/utils/subpath_test.go b/utils/subpath_test.go index 66641e00ee..aa6fbe7b98 100644 --- a/utils/subpath_test.go +++ b/utils/subpath_test.go @@ -5,6 +5,7 @@ import ( "io/ioutil" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" @@ -117,7 +118,7 @@ func TestUpdateAssetsSubpath(t *testing.T) { baseManifestJson, "/", nil, - resetRootHtml, + baseRootHtml, baseCss, baseManifestJson, }, @@ -137,7 +138,11 @@ func TestUpdateAssetsSubpath(t *testing.T) { contents, err := ioutil.ReadFile(filepath.Join(tempDir, model.CLIENT_DIR, "root.html")) require.NoError(t, err) - require.Equal(t, testCase.ExpectedRootHTML, string(contents)) + + // Rewrite the expected and contents for simpler diffs when failed. + expectedRootHTML := strings.Replace(testCase.ExpectedRootHTML, ">", ">\n", -1) + contentsStr := strings.Replace(string(contents), ">", ">\n", -1) + require.Equal(t, expectedRootHTML, contentsStr) contents, err = ioutil.ReadFile(filepath.Join(tempDir, model.CLIENT_DIR, "main.css")) require.NoError(t, err) diff --git a/web/handlers.go b/web/handlers.go index dbff4ceaf9..9303119871 100644 --- a/web/handlers.go +++ b/web/handlers.go @@ -77,7 +77,10 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Instruct the browser not to display us in an iframe unless is the same origin for anti-clickjacking w.Header().Set("X-Frame-Options", "SAMEORIGIN") // Set content security policy. This is also specified in the root.html of the webapp in a meta tag. - w.Header().Set("Content-Security-Policy", "frame-ancestors 'self'; script-src 'self' cdn.segment.com/analytics.js/") + w.Header().Set("Content-Security-Policy", fmt.Sprintf( + "frame-ancestors 'self'; script-src 'self' cdn.segment.com/analytics.js/%s", + utils.GetSubpathScriptHash(subpath), + )) } else { // All api response bodies will be JSON formatted by default w.Header().Set("Content-Type", "application/json") diff --git a/web/handlers_test.go b/web/handlers_test.go index 0eadb1c840..a6f618b2e2 100644 --- a/web/handlers_test.go +++ b/web/handlers_test.go @@ -197,3 +197,78 @@ func TestHandlerServeCSRFToken(t *testing.T) { t.Errorf("Expected status 200, got %d", response.Code) } } + +func handlerForCSPHeader(c *Context, w http.ResponseWriter, r *http.Request) { +} + +func TestHandlerServeCSPHeader(t *testing.T) { + t.Run("non-static", func(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + web := New(th.Server, th.Server.AppOptions, th.Server.Router) + + handler := Handler{ + GetGlobalAppOptions: web.GetGlobalAppOptions, + HandleFunc: handlerForCSPHeader, + RequireSession: false, + TrustRequester: false, + RequireMfa: false, + IsStatic: false, + } + + request := httptest.NewRequest("POST", "/api/v4/test", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + assert.Equal(t, 200, response.Code) + assert.Empty(t, response.Header()["Content-Security-Policy"]) + }) + + t.Run("static, without subpath", func(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + web := New(th.Server, th.Server.AppOptions, th.Server.Router) + + handler := Handler{ + GetGlobalAppOptions: web.GetGlobalAppOptions, + HandleFunc: handlerForCSPHeader, + RequireSession: false, + TrustRequester: false, + RequireMfa: false, + IsStatic: true, + } + + request := httptest.NewRequest("POST", "/", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + assert.Equal(t, 200, response.Code) + assert.Contains(t, response.Header()["Content-Security-Policy"], "frame-ancestors 'self'; script-src 'self' cdn.segment.com/analytics.js/") + }) + + t.Run("static, with subpath", func(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.SiteURL = *cfg.ServiceSettings.SiteURL + "/subpath" + }) + + web := New(th.Server, th.Server.AppOptions, th.Server.Router) + + handler := Handler{ + GetGlobalAppOptions: web.GetGlobalAppOptions, + HandleFunc: handlerForCSPHeader, + RequireSession: false, + TrustRequester: false, + RequireMfa: false, + IsStatic: true, + } + + request := httptest.NewRequest("POST", "/", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + assert.Equal(t, 200, response.Code) + assert.Contains(t, response.Header()["Content-Security-Policy"], "frame-ancestors 'self'; script-src 'self' cdn.segment.com/analytics.js/ 'sha256-tPOjw+tkVs9axL78ZwGtYl975dtyPHB6LYKAO2R3gR4='") + }) +}