Files
upstream-mattermost/server/channels/app/plugin_requests.go
upstream-mirror 240db4e2a7
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
snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:29:55 +05:00

335 lines
12 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"bytes"
"errors"
"io"
"net/http"
"path"
"path/filepath"
"strings"
"github.com/gorilla/mux"
"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/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
func (ch *Channels) ServePluginRequest(w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
pluginID := params["plugin_id"]
pluginsEnvironment := ch.GetPluginsEnvironment()
if pluginsEnvironment == nil {
appErr := model.NewAppError("ServePluginRequest", "app.plugin.disabled.app_error", nil, "Enable plugins to serve plugin requests", http.StatusNotImplemented)
mlog.Error(appErr.Error())
w.WriteHeader(appErr.StatusCode)
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write([]byte(appErr.ToJSON())); err != nil {
mlog.Warn("Error while writing response", mlog.Err(err))
}
return
}
hooks, err := pluginsEnvironment.HooksForPlugin(pluginID)
if err != nil {
mlog.Debug("Access to route for non-existent plugin",
mlog.String("missing_plugin_id", pluginID),
mlog.String("url", r.URL.String()),
mlog.Err(err))
http.NotFound(w, r)
return
}
ch.servePluginRequest(w, r, hooks.ServeHTTP)
}
// ServeInternalPluginRequest handles internal requests to plugins from either core server or other plugins.
// This is used by the Plugin Bridge to route requests with proper authentication headers.
//
// Parameters:
// - userID: User ID to set in the authentication header (empty string if no user context)
// - w: HTTP response writer
// - r: HTTP request (should have URL path set to the endpoint, NOT including plugin ID)
// - sourcePluginID: ID of calling plugin (empty string if from core)
// - targetPluginID: ID of target plugin to call
func (a *App) ServeInternalPluginRequest(userID string, w http.ResponseWriter, r *http.Request, sourcePluginID, targetPluginID string) {
pluginsEnvironment := a.ch.GetPluginsEnvironment()
if pluginsEnvironment == nil {
appErr := model.NewAppError("ServeInternalPluginRequest", "app.plugin.disabled.app_error", nil, "Plugin environment not found.", http.StatusNotImplemented)
a.Log().Error(appErr.Error())
w.WriteHeader(appErr.StatusCode)
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write([]byte(appErr.ToJSON())); err != nil {
mlog.Warn("Error while writing response", mlog.Err(err))
}
return
}
hooks, err := pluginsEnvironment.HooksForPlugin(targetPluginID)
if err != nil {
a.Log().Error("Access to route for non-existent plugin in internal plugin request",
mlog.String("source_plugin_id", sourcePluginID),
mlog.String("target_plugin_id", targetPluginID),
mlog.String("url", r.URL.String()),
mlog.Err(err),
)
http.NotFound(w, r)
return
}
context := &plugin.Context{
RequestId: model.NewId(),
UserAgent: r.UserAgent(),
}
// Set authentication headers - these are trusted because this function is internal
// and not exposed to external HTTP routes
r.Header.Set("Mattermost-User-Id", userID)
// Set plugin ID header to identify the caller
// Use a special ID for core server calls to distinguish them from plugin-to-plugin calls
if sourcePluginID != "" {
r.Header.Set("Mattermost-Plugin-ID", sourcePluginID)
} else {
// Core server call - use special identifier
r.Header.Set("Mattermost-Plugin-ID", "com.mattermost.server")
}
hooks.ServeHTTP(context, w, r)
}
// ServeInterPluginRequest handles inter-plugin HTTP requests.
// This function does not set user authentication headers, unlike ServeInternalPluginRequest.
func (a *App) ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId, destinationPluginId string) {
// Call ServeInternalPluginRequest with empty userID since this function doesn't handle user authentication
a.ServeInternalPluginRequest("", w, r, sourcePluginId, destinationPluginId)
}
// ServePluginPublicRequest serves public plugin files
// at the URL http(s)://$SITE_URL/plugins/$PLUGIN_ID/public/{anything}
func (ch *Channels) ServePluginPublicRequest(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/") {
http.NotFound(w, r)
return
}
// Should be in the form of /(subpath/)?/plugins/{plugin_id}/public/* by the time we get here
vars := mux.Vars(r)
pluginID := vars["plugin_id"]
pluginsEnv := ch.GetPluginsEnvironment()
// Check if someone has nullified the pluginsEnv in the meantime
if pluginsEnv == nil {
http.NotFound(w, r)
return
}
publicFilesPath, err := pluginsEnv.PublicFilesPath(pluginID)
if err != nil {
http.NotFound(w, r)
return
}
subpath, err := utils.GetSubpathFromConfig(ch.cfgSvc.Config())
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
publicFilePath := path.Clean(r.URL.Path)
prefix := path.Join(subpath, "plugins", pluginID, "public")
if !strings.HasPrefix(publicFilePath, prefix) {
http.NotFound(w, r)
return
}
publicFile := filepath.Join(publicFilesPath, strings.TrimPrefix(publicFilePath, prefix))
http.ServeFile(w, r, publicFile)
}
func (ch *Channels) servePluginRequest(w http.ResponseWriter, r *http.Request, handler func(*plugin.Context, http.ResponseWriter, *http.Request)) {
handleInternalServerError := func(rctx request.CTX, logMsg string, err error) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
rctx.Logger().Error(logMsg, mlog.Err(err))
}
context := &plugin.Context{
RequestId: model.NewId(),
IPAddress: utils.GetIPAddress(r, ch.cfgSvc.Config().ServiceSettings.TrustedProxyIPHeader),
AcceptLanguage: r.Header.Get("Accept-Language"),
UserAgent: r.UserAgent(),
ConnectionId: r.Header.Get(model.ConnectionId),
}
pluginID := mux.Vars(r)["plugin_id"]
const headerBearerPrefix = model.HeaderBearer + " "
const headerTokenPrefix = model.HeaderToken + " "
var cookieAuth bool
var token string
authHeader := r.Header.Get(model.HeaderAuth)
if strings.HasPrefix(strings.ToUpper(authHeader), headerBearerPrefix) {
token = authHeader[len(headerBearerPrefix):]
} else if strings.HasPrefix(strings.ToLower(authHeader), headerTokenPrefix) {
token = authHeader[len(headerTokenPrefix):]
} else if cookie, _ := r.Cookie(model.SessionCookieToken); cookie != nil {
token = cookie.Value
cookieAuth = true
} else {
token = r.URL.Query().Get("access_token")
}
// Mattermost-Plugin-ID can only be set by inter-plugin requests
r.Header.Del("Mattermost-Plugin-ID")
// Clean Mattermost-User-Id header. The server sets this header for authenticated requests
r.Header.Del("Mattermost-User-Id")
cookies := r.Cookies()
r.Header.Del("Cookie")
for _, c := range cookies {
if c.Name != model.SessionCookieToken {
r.AddCookie(c)
}
}
r.Header.Del("Referer")
newQuery := r.URL.Query()
newQuery.Del("access_token")
r.URL.RawQuery = newQuery.Encode()
app := New(ServerConnector(ch))
rctx := request.EmptyContext(
ch.srv.Log().With(
mlog.String("plugin_id", pluginID),
mlog.String("path", r.URL.Path),
mlog.String("method", r.Method),
mlog.String("request_id", context.RequestId),
mlog.String("ip_addr", utils.GetIPAddress(r, app.Config().ServiceSettings.TrustedProxyIPHeader)),
),
).WithPath(r.URL.Path)
subpath, err := utils.GetSubpathFromConfig(ch.cfgSvc.Config())
if err != nil {
handleInternalServerError(rctx, "Failed to get subpath for plugin request", err)
return
}
r.URL.Path = strings.TrimPrefix(r.URL.Path, path.Join(subpath, "plugins", pluginID))
// Short path for un-authenticated requests
if token == "" {
handler(context, w, r)
return
}
session, appErr := app.GetSession(token)
if appErr != nil {
// GetSession embeds the raw token in its error message.
sessionErr := strings.ReplaceAll(appErr.Error(), token, "<redacted>")
if appErr.StatusCode == http.StatusInternalServerError {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
rctx.Logger().Error("Internal server error while loading session", mlog.String("error", sessionErr))
return
}
rctx.Logger().Debug("Token in plugin request is invalid. Treating request as unauthenticated",
mlog.String("error", sessionErr),
)
handler(context, w, r)
return
}
// If we get to this point, the token resolved to a valid session, and we don't need to remit
// the authorization header to the plugin at all. This also prevents the plugin from incorrectly
// using the token if MFA or CSRF fail below.
r.Header.Del(model.HeaderAuth)
rctx = rctx.
WithLogFields(mlog.String("user_id", session.UserId)).
WithSession(session)
// If MFA is required and user has not activated it, treat it as unauthenticated
if appErr := app.MFARequired(rctx, r.Method); appErr != nil {
if appErr.StatusCode == http.StatusInternalServerError {
handleInternalServerError(rctx, "Internal server error during MFA validation", appErr)
return
}
rctx.Logger().Warn("Treating session as unauthenticated since MFA required",
mlog.Err(appErr),
)
handler(context, w, r)
return
}
maxBodyBytes := *ch.cfgSvc.Config().ServiceSettings.MaximumPayloadSizeBytes + bytes.MinRead
csrfValid, err := validateCSRFForPluginRequest(rctx, w, r, session, cookieAuth, *ch.cfgSvc.Config().ServiceSettings.ExperimentalStrictCSRFEnforcement, maxBodyBytes)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
http.Error(w, http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge)
return
}
handleInternalServerError(rctx, "Failed to read request body for plugin CSRF validation", err)
return
}
if csrfValid {
r.Header.Set("Mattermost-User-Id", session.UserId)
context.SessionId = session.Id
} else {
rctx.Logger().Debug("CSRF request failed. Treating the request as unauthenticated.")
}
handler(context, w, r)
}
// validateCSRFForPluginRequest validates CSRF token for plugin requests
func validateCSRFForPluginRequest(rctx request.CTX, w http.ResponseWriter, r *http.Request, session *model.Session, cookieAuth bool, strictCSRFEnforcement bool, maxBodyBytes int64) (bool, error) {
// Skip CSRF check for non-cookie auth or GET requests
if !cookieAuth || r.Method == http.MethodGet {
return true, nil
}
csrfTokenFromClient := r.Header.Get(model.HeaderCsrfToken)
if csrfTokenFromClient == "" {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
return false, err
}
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
if err := r.ParseForm(); err != nil {
rctx.Logger().Warn("Failed to parse form data for plugin request", mlog.Err(err))
}
csrfTokenFromClient = r.FormValue("csrf")
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
expectedToken := session.GetCSRF()
if csrfTokenFromClient == expectedToken {
return true, nil
}
// ToDo(DSchalla) 2019/01/04: Remove after deprecation period and only allow CSRF Header (MM-13657)
if r.Header.Get(model.HeaderRequestedWith) == model.HeaderRequestedWithXML {
csrfErrorMessage := "CSRF Check failed for request - Please migrate your plugin to either send a CSRF Header or Form Field, XMLHttpRequest is deprecated"
if strictCSRFEnforcement {
rctx.Logger().Warn(csrfErrorMessage, mlog.String("session_id", session.Id))
return false, nil
}
// Allow XMLHttpRequest for backward compatibility when not strict
rctx.Logger().Debug(csrfErrorMessage, mlog.String("session_id", session.Id))
return true, nil
}
return false, nil
}