Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
747 lines
27 KiB
Go
747 lines
27 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/plugin"
|
|
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
|
"github.com/mattermost/mattermost/server/v8/channels/testlib"
|
|
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
|
|
)
|
|
|
|
func TestServePluginPublicRequest(t *testing.T) {
|
|
installPlugin := func(t *testing.T, th *TestHelper, pluginID string) {
|
|
t.Helper()
|
|
|
|
path, _ := fileutils.FindDir("tests")
|
|
fileReader, err := os.Open(filepath.Join(path, fmt.Sprintf("%s.tar.gz", pluginID)))
|
|
require.NoError(t, err)
|
|
defer fileReader.Close()
|
|
|
|
_, appErr := th.App.WriteFile(fileReader, getBundleStorePath(pluginID))
|
|
checkNoError(t, appErr)
|
|
|
|
appErr = th.App.SyncPlugins()
|
|
checkNoError(t, appErr)
|
|
|
|
env := th.App.GetPluginsEnvironment()
|
|
require.NotNil(t, env)
|
|
|
|
// Check if installed
|
|
pluginStatus, err := env.Statuses()
|
|
require.NoError(t, err)
|
|
found := false
|
|
for _, pluginStatus := range pluginStatus {
|
|
if pluginStatus.PluginId == pluginID {
|
|
found = true
|
|
}
|
|
}
|
|
require.True(t, found, "failed to find plugin %s in plugin statuses", pluginID)
|
|
|
|
appErr = th.App.EnablePlugin(pluginID)
|
|
checkNoError(t, appErr)
|
|
|
|
t.Cleanup(func() {
|
|
appErr = th.App.ch.RemovePlugin(pluginID)
|
|
checkNoError(t, appErr)
|
|
})
|
|
}
|
|
|
|
t.Run("returns not found when plugins environment is nil", func(t *testing.T) {
|
|
th := Setup(t)
|
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = true })
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/plugins/plugin_id/public/file.txt", nil)
|
|
require.NoError(t, err)
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler := http.HandlerFunc(th.App.ch.ServePluginPublicRequest)
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusNotFound, rr.Code)
|
|
})
|
|
|
|
t.Run("resolves path for valid plugin", func(t *testing.T) {
|
|
th := Setup(t)
|
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = true })
|
|
|
|
path, _ := fileutils.FindDir("tests")
|
|
fileReader, err := os.Open(filepath.Join(path, "testplugin.tar.gz"))
|
|
require.NoError(t, err)
|
|
defer fileReader.Close()
|
|
|
|
installPlugin(t, th, "testplugin")
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/plugins/testplugin/public/file.txt", nil)
|
|
require.NoError(t, err)
|
|
|
|
rr := httptest.NewRecorder()
|
|
th.App.ch.srv.Router.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
body, err := io.ReadAll(rr.Body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "Hello World!", string(body))
|
|
})
|
|
|
|
t.Run("resolves path for valid plugin when subpath configured", func(t *testing.T) {
|
|
// SiteURL must be set before server init so the Router is created with the correct subpath prefix.
|
|
// Using UpdateConfig after Setup would not rebuild the Router.
|
|
th := SetupConfig(t, func(cfg *model.Config) {
|
|
cfg.ServiceSettings.SiteURL = new("http://localhost:8065/subpath")
|
|
})
|
|
|
|
installPlugin(t, th, "testplugin")
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/subpath/plugins/testplugin/public/file.txt", nil)
|
|
require.NoError(t, err)
|
|
|
|
rr := httptest.NewRecorder()
|
|
th.App.ch.srv.RootRouter.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
body, err := io.ReadAll(rr.Body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Hello World!", string(body))
|
|
})
|
|
|
|
t.Run("fails for invalid plugin", func(t *testing.T) {
|
|
th := Setup(t)
|
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = true })
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/plugins/invalidplugin/public/file.txt", nil)
|
|
require.NoError(t, err)
|
|
|
|
rr := httptest.NewRecorder()
|
|
th.App.ch.srv.Router.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusNotFound, rr.Code)
|
|
})
|
|
|
|
t.Run("fails attempting to break out of path", func(t *testing.T) {
|
|
// SiteURL must be set before server init so the Router is created with the correct subpath prefix.
|
|
// Using UpdateConfig after Setup would not rebuild the Router.
|
|
th := SetupConfig(t, func(cfg *model.Config) {
|
|
cfg.ServiceSettings.SiteURL = new("http://localhost:8065/subpath")
|
|
})
|
|
|
|
installPlugin(t, th, "testplugin")
|
|
installPlugin(t, th, "testplugin2")
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/subpath/plugins/testplugin/public/../../testplugin2/file.txt", nil)
|
|
require.NoError(t, err)
|
|
|
|
rr := httptest.NewRecorder()
|
|
th.App.ch.srv.RootRouter.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusMovedPermanently, rr.Code)
|
|
assert.Equal(t, "/subpath/plugins/testplugin2/file.txt", rr.Header()["Location"][0])
|
|
})
|
|
}
|
|
|
|
// TestUnauthRequestsMFAWarningFix tests the fix for https://mattermost.atlassian.net/browse/MM-63805.
|
|
func TestUnauthRequestsMFAWarningFix(t *testing.T) {
|
|
th := Setup(t)
|
|
|
|
// Enable MFA and require it
|
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
|
*cfg.ServiceSettings.EnableMultifactorAuthentication = true
|
|
*cfg.ServiceSettings.EnforceMultifactorAuthentication = true
|
|
})
|
|
th.App.Srv().SetLicense(model.NewTestLicense())
|
|
|
|
// Setup a buffer to capture logs
|
|
buffer := &mlog.Buffer{}
|
|
err := mlog.AddWriterTarget(th.TestLogger, buffer, true, mlog.StdAll...)
|
|
require.NoError(t, err)
|
|
|
|
// Test the fix by simulating an unauthenticated request (no token at all)
|
|
unauthReq := httptest.NewRequest(http.MethodGet, "/plugins/foo/bar", nil)
|
|
unauthReq = mux.SetURLVars(unauthReq, map[string]string{"plugin_id": "foo"})
|
|
|
|
// Handler function for the plugin request
|
|
handlerCalled := false
|
|
handler := func(_ *plugin.Context, _ http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
// Verify URL path was properly stripped
|
|
require.Equal(t, "/bar", r.URL.Path)
|
|
// Verify no user ID header (indicating the request is unauthenticated)
|
|
require.Empty(t, r.Header.Get("Mattermost-User-Id"))
|
|
}
|
|
|
|
// Call servePluginRequest directly
|
|
th.App.ch.servePluginRequest(nil, unauthReq, handler)
|
|
|
|
// Verify the handler was actually called
|
|
require.True(t, handlerCalled, "Plugin request handler should be called")
|
|
|
|
// Check the logs for the MFA warning
|
|
err = th.TestLogger.Flush()
|
|
require.NoError(t, err)
|
|
entries := testlib.ParseLogEntries(t, buffer)
|
|
for _, e := range entries {
|
|
if e.Msg == "Treating session as unauthenticated since MFA required" {
|
|
assert.Fail(t, "MFA warning should not be logged for unauthenticated requests")
|
|
}
|
|
if e.Msg == "Token in plugin request is invalid. Treating request as unauthenticated" {
|
|
assert.Fail(t, "MFA warning should not be logged for unauthenticated requests")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServePluginRequest(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := Setup(t).InitBasic(t)
|
|
|
|
session, err := th.App.CreateSession(th.Context, &model.Session{
|
|
UserId: th.BasicUser.Id,
|
|
})
|
|
require.Nil(t, err)
|
|
|
|
t.Run("Plugins are disabled", func(t *testing.T) {
|
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = false })
|
|
t.Cleanup(func() {
|
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = true })
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/plugins/foo/bar", nil)
|
|
th.App.ch.ServePluginRequest(w, r)
|
|
assert.Equal(t, http.StatusNotImplemented, w.Result().StatusCode)
|
|
})
|
|
|
|
t.Run("unauthenticated request", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
|
|
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
assert.Empty(t, r.Header.Get("Mattermost-User-Id"))
|
|
assert.Empty(t, ctx.SessionId)
|
|
assert.NotEmpty(t, ctx.RequestId)
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
})
|
|
|
|
t.Run("bearer token authentication", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
|
|
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
|
|
req.Header.Set(model.HeaderAuth, model.HeaderBearer+" "+session.Token)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
assert.Equal(t, th.BasicUser.Id, r.Header.Get("Mattermost-User-Id"))
|
|
assert.Equal(t, session.Id, ctx.SessionId)
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
|
|
// Test again with lower case header prefix
|
|
handlerCalled = false
|
|
req.Header.Set(model.HeaderAuth, "bearer "+session.Token)
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
})
|
|
|
|
t.Run("token header authentication", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
|
|
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
|
|
req.Header.Set(model.HeaderAuth, model.HeaderToken+" "+session.Token)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
assert.Equal(t, th.BasicUser.Id, r.Header.Get("Mattermost-User-Id"))
|
|
assert.Equal(t, session.Id, ctx.SessionId)
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
|
|
// Test again with upper case header prefix
|
|
handlerCalled = false
|
|
req.Header.Set(model.HeaderAuth, "TOKEN "+session.Token)
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
})
|
|
|
|
t.Run("cookie authentication", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
|
|
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
|
|
req.AddCookie(&http.Cookie{
|
|
Name: model.SessionCookieToken,
|
|
Value: session.Token,
|
|
})
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
assert.Equal(t, th.BasicUser.Id, r.Header.Get("Mattermost-User-Id"))
|
|
assert.Equal(t, session.Id, ctx.SessionId)
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
})
|
|
|
|
t.Run("query parameter authentication", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint?access_token="+session.Token, nil)
|
|
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
assert.Equal(t, th.BasicUser.Id, r.Header.Get("Mattermost-User-Id"))
|
|
assert.Equal(t, session.Id, ctx.SessionId)
|
|
// Verify access_token is removed from query parameters
|
|
assert.Empty(t, r.URL.Query().Get("access_token"))
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
})
|
|
|
|
t.Run("invalid token - treats as unauthenticated", func(t *testing.T) {
|
|
const invalidToken = "notarealtoken-abc123"
|
|
|
|
buffer := &mlog.Buffer{}
|
|
err := mlog.AddWriterTarget(th.TestLogger, buffer, true, mlog.StdAll...)
|
|
require.NoError(t, err)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
|
|
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
|
|
req.Header.Set(model.HeaderAuth, model.HeaderBearer+" "+invalidToken)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
assert.Empty(t, r.Header.Get("Mattermost-User-Id"))
|
|
assert.Empty(t, ctx.SessionId)
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
|
|
err = th.TestLogger.Flush()
|
|
require.NoError(t, err)
|
|
|
|
logOutput := buffer.String()
|
|
assert.NotContains(t, logOutput, invalidToken)
|
|
|
|
foundInvalidTokenLog := false
|
|
for _, e := range testlib.ParseLogEntries(t, strings.NewReader(logOutput)) {
|
|
if e.Msg == "Token in plugin request is invalid. Treating request as unauthenticated" {
|
|
foundInvalidTokenLog = true
|
|
break
|
|
}
|
|
}
|
|
require.True(t, foundInvalidTokenLog, "expected invalid-token debug log")
|
|
assert.Contains(t, logOutput, "<redacted>")
|
|
})
|
|
|
|
t.Run("MFA required - treats as unauthenticated", func(t *testing.T) {
|
|
// Enable MFA requirement
|
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
|
*cfg.ServiceSettings.EnableMultifactorAuthentication = true
|
|
*cfg.ServiceSettings.EnforceMultifactorAuthentication = true
|
|
})
|
|
th.App.Srv().SetLicense(model.NewTestLicense())
|
|
t.Cleanup(func() {
|
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
|
*cfg.ServiceSettings.EnableMultifactorAuthentication = false
|
|
*cfg.ServiceSettings.EnforceMultifactorAuthentication = false
|
|
})
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
|
|
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
|
|
req.Header.Set(model.HeaderAuth, model.HeaderBearer+" "+session.Token)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
assert.Empty(t, r.Header.Get("Mattermost-User-Id"))
|
|
assert.Empty(t, ctx.SessionId)
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
})
|
|
|
|
t.Run("header and cookie cleanup", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
|
|
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
|
|
req.Header.Set(model.HeaderAuth, model.HeaderBearer+" "+session.Token)
|
|
req.Header.Set("Mattermost-Plugin-ID", "evil-plugin")
|
|
req.Header.Set("Mattermost-User-Id", "evil-user")
|
|
req.AddCookie(&http.Cookie{Name: "other_cookie", Value: "keep_me"})
|
|
req.AddCookie(&http.Cookie{Name: "another_cookie", Value: "keep_me_too"})
|
|
req.Header.Set("Referer", "https://evil.com")
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
|
|
assert.Equal(t, th.BasicUser.Id, r.Header.Get("Mattermost-User-Id"))
|
|
assert.Equal(t, session.Id, ctx.SessionId)
|
|
|
|
assert.Empty(t, r.Header.Get("Mattermost-Plugin-ID"))
|
|
assert.Empty(t, r.Header.Get(model.HeaderAuth))
|
|
assert.Empty(t, r.Header.Get("Referer"))
|
|
|
|
// Verify that legitimate cookies are preserved (but not session cookies)
|
|
cookies := r.Cookies()
|
|
cookieNames := make([]string, len(cookies))
|
|
for i, cookie := range cookies {
|
|
cookieNames[i] = cookie.Name
|
|
}
|
|
// Session token cookie should be filtered out
|
|
assert.NotContains(t, cookieNames, model.SessionCookieToken)
|
|
// Other cookies should remain
|
|
assert.Contains(t, cookieNames, "other_cookie")
|
|
assert.Contains(t, cookieNames, "another_cookie")
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
})
|
|
|
|
t.Run("nested URL path", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/some/deep/path", nil)
|
|
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
// Path should be stripped of the plugin prefix
|
|
assert.Equal(t, "/some/deep/path", r.URL.Path)
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
})
|
|
|
|
t.Run("context creation with correct fields", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
|
|
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
|
|
req.Header.Set(model.HeaderAuth, model.HeaderBearer+" "+session.Token)
|
|
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
|
req.Header.Set("User-Agent", "TestAgent/1.0")
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
assert.NotEmpty(t, ctx.RequestId)
|
|
assert.NotEmpty(t, ctx.IPAddress)
|
|
assert.Equal(t, "en-US,en;q=0.9", ctx.AcceptLanguage)
|
|
assert.Equal(t, "TestAgent/1.0", ctx.UserAgent)
|
|
assert.Equal(t, th.BasicUser.Id, r.Header.Get("Mattermost-User-Id"))
|
|
assert.Equal(t, session.Id, ctx.SessionId)
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
})
|
|
|
|
t.Run("connection id passed to plugin context", func(t *testing.T) {
|
|
connectionId := "test-connection-id-abc123"
|
|
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
|
|
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
|
|
req.Header.Set(model.ConnectionId, connectionId)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
assert.Equal(t, connectionId, ctx.ConnectionId)
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
})
|
|
|
|
t.Run("empty connection id when header not present", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
|
|
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
assert.Empty(t, ctx.ConnectionId)
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
})
|
|
|
|
t.Run("subpath handling", func(t *testing.T) {
|
|
// Set up with subpath
|
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065/subpath" })
|
|
t.Cleanup(func() {
|
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065" })
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/subpath/plugins/testplugin/endpoint", nil)
|
|
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
// Path should be stripped of both subpath and plugin prefix
|
|
assert.Equal(t, "/endpoint", r.URL.Path)
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
})
|
|
|
|
t.Run("CSRF validation for cookie auth POST request", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/plugins/testplugin/endpoint", nil)
|
|
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
|
|
req.AddCookie(&http.Cookie{
|
|
Name: model.SessionCookieToken,
|
|
Value: session.Token,
|
|
})
|
|
req.Header.Set(model.HeaderCsrfToken, session.GetCSRF())
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
assert.Equal(t, th.BasicUser.Id, r.Header.Get("Mattermost-User-Id"))
|
|
assert.Equal(t, session.Id, ctx.SessionId)
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
})
|
|
|
|
t.Run("CSRF validation fails for cookie auth POST request", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/plugins/testplugin/endpoint", nil)
|
|
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
|
|
req.AddCookie(&http.Cookie{
|
|
Name: model.SessionCookieToken,
|
|
Value: session.Token,
|
|
})
|
|
req.Header.Set(model.HeaderCsrfToken, "invalid-csrf-token")
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
// Should not have user ID header due to CSRF failure
|
|
assert.Empty(t, r.Header.Get("Mattermost-User-Id"))
|
|
assert.Empty(t, ctx.SessionId)
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
})
|
|
|
|
t.Run("oversized cookie auth POST without CSRF header returns 413 and is not forwarded", func(t *testing.T) {
|
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.MaximumPayloadSizeBytes = 1 })
|
|
t.Cleanup(func() {
|
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.MaximumPayloadSizeBytes = 300000 })
|
|
})
|
|
|
|
body := strings.NewReader(strings.Repeat("a", 2048))
|
|
req := httptest.NewRequest(http.MethodPost, "/plugins/testplugin/endpoint", body)
|
|
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
|
|
req.AddCookie(&http.Cookie{
|
|
Name: model.SessionCookieToken,
|
|
Value: session.Token,
|
|
})
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
assert.Equal(t, http.StatusRequestEntityTooLarge, rr.Code)
|
|
assert.False(t, handlerCalled)
|
|
})
|
|
|
|
t.Run("third-party use of Authorization header preserved", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/plugins/testplugin/endpoint", nil)
|
|
req.Header.Set(model.HeaderAuth, "Bearer 3rd-party-token")
|
|
rr := httptest.NewRecorder()
|
|
|
|
handlerCalled := false
|
|
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
// Should still have the authorization header
|
|
assert.Equal(t, "Bearer 3rd-party-token", r.Header.Get(model.HeaderAuth))
|
|
}
|
|
|
|
th.App.ch.servePluginRequest(rr, req, mockHandler)
|
|
require.True(t, handlerCalled)
|
|
})
|
|
}
|
|
|
|
func TestValidateCSRFForPluginRequest(t *testing.T) {
|
|
th := Setup(t)
|
|
|
|
const testMaxBodyBytes = 1024 * 1024
|
|
|
|
t.Run("skip CSRF for non-cookie auth", func(t *testing.T) {
|
|
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
|
|
session.GenerateCSRF()
|
|
req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
|
|
|
result, _ := validateCSRFForPluginRequest(th.Context, httptest.NewRecorder(), req, session, false, false, testMaxBodyBytes)
|
|
assert.True(t, result)
|
|
})
|
|
|
|
t.Run("skip CSRF for GET requests", func(t *testing.T) {
|
|
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
|
|
session.GenerateCSRF()
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
|
|
result, _ := validateCSRFForPluginRequest(th.Context, httptest.NewRecorder(), req, session, true, false, testMaxBodyBytes)
|
|
assert.True(t, result)
|
|
})
|
|
|
|
t.Run("valid CSRF token in header", func(t *testing.T) {
|
|
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
|
|
expectedToken := session.GenerateCSRF()
|
|
req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
|
req.Header.Set(model.HeaderCsrfToken, expectedToken)
|
|
|
|
result, _ := validateCSRFForPluginRequest(th.Context, httptest.NewRecorder(), req, session, true, false, testMaxBodyBytes)
|
|
assert.True(t, result)
|
|
})
|
|
|
|
t.Run("invalid CSRF token in header", func(t *testing.T) {
|
|
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
|
|
session.GenerateCSRF()
|
|
req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
|
req.Header.Set(model.HeaderCsrfToken, "invalid-token")
|
|
|
|
result, _ := validateCSRFForPluginRequest(th.Context, httptest.NewRecorder(), req, session, true, false, testMaxBodyBytes)
|
|
assert.False(t, result)
|
|
})
|
|
|
|
t.Run("valid CSRF token in form data", func(t *testing.T) {
|
|
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
|
|
expectedToken := session.GetCSRF()
|
|
formData := "csrf=" + expectedToken + "&other=value"
|
|
req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(formData))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
result, _ := validateCSRFForPluginRequest(th.Context, httptest.NewRecorder(), req, session, true, false, testMaxBodyBytes)
|
|
assert.True(t, result)
|
|
})
|
|
|
|
t.Run("oversized form body is rejected as too large", func(t *testing.T) {
|
|
const smallMaxBodyBytes = 128
|
|
|
|
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
|
|
expectedToken := session.GenerateCSRF()
|
|
formData := "filler=" + strings.Repeat("a", smallMaxBodyBytes) + "&csrf=" + expectedToken
|
|
req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(formData))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
valid, err := validateCSRFForPluginRequest(th.Context, httptest.NewRecorder(), req, session, true, false, smallMaxBodyBytes)
|
|
assert.False(t, valid)
|
|
var maxBytesErr *http.MaxBytesError
|
|
assert.ErrorAs(t, err, &maxBytesErr)
|
|
})
|
|
|
|
t.Run("oversized form body with early csrf token is rejected, not accepted with truncated data", func(t *testing.T) {
|
|
const smallMaxBodyBytes = 64
|
|
|
|
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
|
|
expectedToken := session.GenerateCSRF()
|
|
// A valid token up front must not let an over-limit body through with truncated data.
|
|
formData := "csrf=" + expectedToken + "&filler=" + strings.Repeat("a", smallMaxBodyBytes)
|
|
req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(formData))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
valid, err := validateCSRFForPluginRequest(th.Context, httptest.NewRecorder(), req, session, true, false, smallMaxBodyBytes)
|
|
assert.False(t, valid)
|
|
var maxBytesErr *http.MaxBytesError
|
|
assert.ErrorAs(t, err, &maxBytesErr)
|
|
})
|
|
|
|
t.Run("oversized body with valid csrf header is not read or capped", func(t *testing.T) {
|
|
const smallMaxBodyBytes = 64
|
|
|
|
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
|
|
expectedToken := session.GenerateCSRF()
|
|
body := strings.Repeat("a", smallMaxBodyBytes*4)
|
|
req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(body))
|
|
req.Header.Set(model.HeaderCsrfToken, expectedToken)
|
|
|
|
valid, err := validateCSRFForPluginRequest(th.Context, httptest.NewRecorder(), req, session, true, false, smallMaxBodyBytes)
|
|
assert.True(t, valid)
|
|
require.NoError(t, err)
|
|
|
|
// The header path must leave the body untouched for the plugin to stream.
|
|
read, err := io.ReadAll(req.Body)
|
|
require.NoError(t, err)
|
|
assert.Len(t, read, smallMaxBodyBytes*4)
|
|
})
|
|
|
|
t.Run("XMLHttpRequest with strict enforcement disabled", func(t *testing.T) {
|
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
|
*cfg.ServiceSettings.ExperimentalStrictCSRFEnforcement = false
|
|
})
|
|
|
|
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
|
|
session.GenerateCSRF()
|
|
req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
|
req.Header.Set(model.HeaderRequestedWith, model.HeaderRequestedWithXML)
|
|
|
|
result, _ := validateCSRFForPluginRequest(th.Context, httptest.NewRecorder(), req, session, true, false, testMaxBodyBytes)
|
|
assert.True(t, result)
|
|
})
|
|
|
|
t.Run("XMLHttpRequest with strict enforcement enabled", func(t *testing.T) {
|
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
|
*cfg.ServiceSettings.ExperimentalStrictCSRFEnforcement = true
|
|
})
|
|
|
|
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
|
|
session.GenerateCSRF()
|
|
req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
|
req.Header.Set(model.HeaderRequestedWith, model.HeaderRequestedWithXML)
|
|
|
|
result, _ := validateCSRFForPluginRequest(th.Context, httptest.NewRecorder(), req, session, true, true, testMaxBodyBytes)
|
|
assert.False(t, result)
|
|
})
|
|
}
|