mattermost-community-enterp.../channels/web/handlers.go
Claude ec1f89217a Merge: Complete Mattermost Server with Community Enterprise
Full Mattermost server source with integrated Community Enterprise features.
Includes vendor directory for offline/air-gapped builds.

Structure:
- enterprise-impl/: Enterprise feature implementations
- enterprise-community/: Init files that register implementations
- enterprise/: Bridge imports (community_imports.go)
- vendor/: All dependencies for offline builds

Build (online):
  go build ./cmd/mattermost

Build (offline/air-gapped):
  go build -mod=vendor ./cmd/mattermost

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 23:59:07 +09:00

579 lines
19 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"reflect"
"runtime"
"strconv"
"strings"
"time"
"github.com/klauspost/compress/gzhttp"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
func GetHandlerName(h func(*Context, http.ResponseWriter, *http.Request)) string {
handlerName := runtime.FuncForPC(reflect.ValueOf(h).Pointer()).Name()
pos := strings.LastIndex(handlerName, ".")
if pos != -1 && len(handlerName) > pos {
handlerName = handlerName[pos+1:]
}
return handlerName
}
func (w *Web) NewHandler(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
return &Handler{
Srv: w.srv,
HandleFunc: h,
HandlerName: GetHandlerName(h),
RequireSession: false,
TrustRequester: false,
RequireMfa: false,
IsStatic: false,
IsLocal: false,
}
}
func (w *Web) NewStaticHandler(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
// Determine the CSP SHA directive needed for subpath support, if any. This value is fixed
// on server start and intentionally requires a restart to take effect.
subpath, _ := utils.GetSubpathFromConfig(w.srv.Config())
return &Handler{
Srv: w.srv,
HandleFunc: h,
HandlerName: GetHandlerName(h),
RequireSession: false,
TrustRequester: false,
RequireMfa: false,
IsStatic: true,
cspShaDirective: utils.GetSubpathScriptHash(subpath),
}
}
type Handler struct {
Srv *app.Server
HandleFunc func(*Context, http.ResponseWriter, *http.Request)
HandlerName string
RequireSession bool
RequireCloudKey bool
RequireRemoteClusterToken bool
TrustRequester bool
RequireMfa bool
IsStatic bool
IsLocal bool
DisableWhenBusy bool
FileAPI bool
cspShaDirective string
}
func generateDevCSP(c Context) string {
var devCSP []string
// Add unsafe-eval to the content security policy for faster source maps in development mode
if model.BuildNumber == "dev" {
devCSP = append(devCSP, "'unsafe-eval'")
}
// Add unsafe-inline to unlock extensions like React & Redux DevTools in Firefox
// see https://github.com/reduxjs/redux-devtools/issues/380
if model.BuildNumber == "dev" {
devCSP = append(devCSP, "'unsafe-inline'")
}
// Add supported flags for debugging during development, even if not on a dev build.
if *c.App.Config().ServiceSettings.DeveloperFlags != "" {
for devFlagKVStr := range strings.SplitSeq(*c.App.Config().ServiceSettings.DeveloperFlags, ",") {
devFlagKVSplit := strings.SplitN(devFlagKVStr, "=", 2)
if len(devFlagKVSplit) != 2 {
c.Logger.Warn("Unable to parse developer flag", mlog.String("developer_flag", devFlagKVStr))
continue
}
devFlagKey := devFlagKVSplit[0]
devFlagValue := devFlagKVSplit[1]
// Ignore disabled keys
if devFlagValue != "true" {
continue
}
// Honour only supported keys
switch devFlagKey {
case "unsafe-eval", "unsafe-inline":
if model.BuildNumber == "dev" {
// These flags are added automatically for dev builds
continue
}
devCSP = append(devCSP, "'"+devFlagKey+"'")
default:
c.Logger.Warn("Unrecognized developer flag", mlog.String("developer_flag", devFlagKVStr))
}
}
}
if len(devCSP) == 0 {
return ""
}
return " " + strings.Join(devCSP, " ")
}
func (h Handler) basicSecurityChecks(c *Context, w http.ResponseWriter, r *http.Request) {
maxURLCharacters := *c.App.Config().ServiceSettings.MaximumURLLength
if len(r.RequestURI) > maxURLCharacters {
c.Err = model.NewAppError("basicSecurityChecks", "basic_security_check.url.too_long_error", nil, "", http.StatusRequestURITooLong)
return
}
}
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w = newWrappedWriter(w)
now := time.Now()
appInstance := app.New(app.ServerConnector(h.Srv.Channels()))
c := &Context{
AppContext: &request.Context{},
App: appInstance,
}
requestID := model.NewId()
var rateLimitExceeded bool
defer func() {
responseLogFields := []mlog.Field{
mlog.String("method", r.Method),
mlog.String("url", r.URL.Path),
mlog.String("request_id", requestID),
}
// if there is a valid session and userID then include the user_id
if c.AppContext.Session() != nil && c.AppContext.Session().UserId != "" {
responseLogFields = append(responseLogFields, mlog.String("user_id", c.AppContext.Session().UserId))
}
statusCode := strconv.Itoa(w.(*responseWriterWrapper).StatusCode())
// Websockets are returning status code 0 to requests after closing the socket
if statusCode != "0" {
responseLogFields = append(responseLogFields, mlog.String("status_code", statusCode))
}
mlog.Debug("Received HTTP request", responseLogFields...)
if !rateLimitExceeded {
h.recordMetrics(c, r, now, statusCode)
}
}()
t, _ := i18n.GetTranslationsAndLocaleFromRequest(r)
c.AppContext = request.NewContext(
context.Background(),
requestID,
utils.GetIPAddress(r, c.App.Config().ServiceSettings.TrustedProxyIPHeader),
r.Header.Get("X-Forwarded-For"),
r.URL.Path,
r.UserAgent(),
r.Header.Get("Accept-Language"),
t,
)
c.Params = ParamsFromRequest(r)
c.Logger = c.App.Log()
h.basicSecurityChecks(c, w, r)
if c.Err != nil {
h.handleContextError(c, w, r)
return
}
var maxBytes int64
if h.FileAPI {
// We add a buffer of bytes.MinRead so that file sizes close to max file size
// do not get cut off.
maxBytes = *c.App.Config().FileSettings.MaxFileSize + bytes.MinRead
} else {
maxBytes = *c.App.Config().ServiceSettings.MaximumPayloadSizeBytes + bytes.MinRead
}
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
subpath, _ := utils.GetSubpathFromConfig(c.App.Config())
siteURLHeader := app.GetProtocol(r) + "://" + r.Host + subpath
if c.App.Channels().License().IsCloud() {
siteURLHeader = *c.App.Config().ServiceSettings.SiteURL + subpath
}
c.SetSiteURLHeader(siteURLHeader)
w.Header().Set(model.HeaderRequestId, c.AppContext.RequestId())
w.Header().Set(model.HeaderVersionId, fmt.Sprintf("%v.%v.%v.%v", model.CurrentVersion, model.BuildNumber, c.App.ClientConfigHash(), c.App.Channels().License() != nil))
if *c.App.Config().ServiceSettings.TLSStrictTransport {
w.Header().Set("Strict-Transport-Security", fmt.Sprintf("max-age=%d", *c.App.Config().ServiceSettings.TLSStrictTransportMaxAge))
}
// Hardcoded sensible default values for these security headers. Feel free to override in proxy or ingress
w.Header().Set("Permissions-Policy", "")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
if h.IsStatic {
// 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")
devCSP := generateDevCSP(*c)
// 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", fmt.Sprintf(
"frame-ancestors 'self' %s; script-src 'self'%s%s",
*c.App.Config().ServiceSettings.FrameAncestors,
h.cspShaDirective,
devCSP,
))
} else {
// All api response bodies will be JSON formatted by default
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
w.Header().Set("Expires", "0")
}
}
token, tokenLocation := app.ParseAuthTokenFromRequest(r)
if token != "" && tokenLocation != app.TokenLocationCloudHeader && tokenLocation != app.TokenLocationRemoteClusterHeader {
session, err := c.App.GetSession(token)
if err != nil {
c.Logger.Info("Invalid session", mlog.Err(err))
if err.StatusCode == http.StatusInternalServerError {
c.Err = err
} else if h.RequireSession {
c.RemoveSessionCookie(w, r)
c.Err = model.NewAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token="+token, http.StatusUnauthorized)
}
} else if !session.IsOAuth && tokenLocation == app.TokenLocationQueryString {
c.Err = model.NewAppError("ServeHTTP", "api.context.token_provided.app_error", nil, "token="+token, http.StatusUnauthorized)
} else {
c.AppContext = c.AppContext.WithSession(session)
}
// Rate limit by UserID
if c.App.Srv().RateLimiter != nil {
rateLimitExceeded = c.App.Srv().RateLimiter.UserIdRateLimit(c.AppContext.Session().UserId, w)
if rateLimitExceeded {
return
}
}
csrfChecked, csrfPassed := h.checkCSRFToken(c, r, tokenLocation, session)
if csrfChecked && !csrfPassed {
c.AppContext = c.AppContext.WithSession(&model.Session{})
c.RemoveSessionCookie(w, r)
c.Err = model.NewAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token="+token+" Appears to be a CSRF attempt", http.StatusUnauthorized)
}
} else if token != "" && c.App.Channels().License().IsCloud() && tokenLocation == app.TokenLocationCloudHeader {
// Check to see if this provided token matches our CWS Token
session, err := c.App.GetCloudSession(token)
if err != nil {
c.Logger.Warn("Invalid CWS token", mlog.Err(err))
c.Err = err
} else {
c.AppContext = c.AppContext.WithSession(session)
}
} else if token != "" && c.App.Channels().License() != nil && c.App.Channels().License().HasRemoteClusterService() && tokenLocation == app.TokenLocationRemoteClusterHeader {
// Get the remote cluster
if remoteId := c.GetRemoteID(r); remoteId == "" {
c.Logger.Warn("Missing remote cluster id") //
c.Err = model.NewAppError("ServeHTTP", "api.context.remote_id_missing.app_error", nil, "", http.StatusUnauthorized)
} else {
// Check the token is correct for the remote cluster id.
session, err := c.App.GetRemoteClusterSession(token, remoteId)
if err != nil {
c.Logger.Warn("Invalid remote cluster token", mlog.Err(err))
c.Err = err
} else {
c.AppContext = c.AppContext.WithSession(session)
}
}
}
c.Logger = c.App.Log().With(
mlog.String("path", c.AppContext.Path()),
mlog.String("request_id", c.AppContext.RequestId()),
mlog.String("ip_addr", c.AppContext.IPAddress()),
mlog.String("user_id", c.AppContext.Session().UserId),
mlog.String("method", r.Method),
)
c.AppContext = c.AppContext.WithLogger(c.Logger)
if c.Err == nil && h.RequireSession {
c.SessionRequired()
}
if c.Err == nil && h.RequireMfa {
c.MfaRequired()
}
if c.Err == nil && h.DisableWhenBusy && c.App.Srv().Platform().Busy.IsBusy() {
c.SetServerBusyError()
}
if c.Err == nil && h.RequireCloudKey {
c.CloudKeyRequired()
}
if c.Err == nil && h.RequireRemoteClusterToken {
c.RemoteClusterTokenRequired()
}
if c.Err == nil && h.IsLocal {
// if the connection is local, RemoteAddr shouldn't have the
// shape IP:PORT (it will be "@" in Linux, for example)
isLocalOrigin := !strings.Contains(r.RemoteAddr, ":")
if *c.App.Config().ServiceSettings.EnableLocalMode && isLocalOrigin {
c.AppContext = c.AppContext.WithSession(&model.Session{Local: true})
} else if !isLocalOrigin {
c.Err = model.NewAppError("", "api.context.local_origin_required.app_error", nil, "LocalOriginRequired", http.StatusUnauthorized)
}
}
if c.Err == nil {
h.HandleFunc(c, w, r)
}
// Handle errors that have occurred
if c.Err != nil {
h.handleContextError(c, w, r)
return
}
}
func (h Handler) recordMetrics(c *Context, r *http.Request, now time.Time, statusCode string) {
if c.App.Metrics() != nil {
c.App.Metrics().IncrementHTTPRequest()
if r.URL.Path != model.APIURLSuffix+"/websocket" {
elapsed := float64(time.Since(now)) / float64(time.Second)
pageLoadContext := r.Header.Get("X-Page-Load-Context")
if pageLoadContext != "page_load" && pageLoadContext != "reconnect" {
pageLoadContext = ""
}
c.App.Metrics().ObserveAPIEndpointDuration(h.HandlerName, r.Method, statusCode, string(GetOriginClient(r)), pageLoadContext, elapsed)
}
}
}
func (h Handler) handleContextError(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err == nil {
return
}
// We're handling payload limit error here because it needs to be handled globally.
var maxBytesErr *http.MaxBytesError
// check if error is a MaxBytesError error, which occurs when you read more bytes from buffer than configured
if ok := errors.As(c.Err, &maxBytesErr); ok {
// replace the context error with this error if so,
newErr := model.NewAppError(c.Err.Where, "api.context.request_body_too_large.app_error", nil, "Use the setting `MaximumPayloadSizeBytes` in Mattermost config to configure allowed payload limit. Learn more about this setting in Mattermost docs at https://docs.mattermost.com/configure/environment-configuration-settings.html#maximum-payload-size", http.StatusRequestEntityTooLarge)
c.Err = newErr
}
// Detect and fix AppError with missing StatusCode to prevent panics
if c.Err.StatusCode == 0 {
c.Logger.Error("AppError with zero StatusCode detected",
mlog.String("error_id", c.Err.Id),
mlog.String("error_message", c.Err.Message),
mlog.String("error_where", c.Err.Where),
mlog.String("request_path", r.URL.Path),
mlog.String("request_method", r.Method),
mlog.String("detailed_error", c.Err.DetailedError),
)
c.Err.StatusCode = http.StatusInternalServerError
}
c.Err.RequestId = c.AppContext.RequestId()
c.LogErrorByCode(c.Err)
// The locale translation needs to happen after we have logged it.
// We don't want the server logs to be translated as per user locale.
c.Err.Translate(c.AppContext.T)
c.Err.Where = r.URL.Path
// Block out detailed error when not in developer mode
if !*c.App.Config().ServiceSettings.EnableDeveloper {
c.Err.WipeDetailed()
}
// Sanitize all 5xx error messages in hardened mode
if *c.App.Config().ServiceSettings.ExperimentalEnableHardenedMode && c.Err.StatusCode >= 500 {
c.Err.Id = ""
c.Err.Message = "Internal Server Error"
c.Err.WipeDetailed()
c.Err.StatusCode = 500
c.Err.Where = ""
}
if IsAPICall(c.App, r) || IsWebhookCall(c.App, r) || IsOAuthAPICall(c.App, r) || r.Header.Get("X-Mobile-App") != "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(c.Err.StatusCode)
if _, err := w.Write([]byte(c.Err.ToJSON())); err != nil {
c.Logger.Warn("Failed to write error response", mlog.Err(err))
}
} else {
utils.RenderWebAppError(c.App.Config(), w, r, c.Err, c.App.AsymmetricSigningKey())
}
if c.App.Metrics() != nil {
c.App.Metrics().IncrementHTTPError()
}
}
type OriginClient string
const (
OriginClientUnknown OriginClient = "unknown"
OriginClientWeb OriginClient = "web"
OriginClientMobile OriginClient = "mobile"
OriginClientDesktop OriginClient = "desktop"
)
// GetOriginClient returns the device from which the provided request was issued. The algorithm roughly looks like:
// - If the URL contains the query mobilev2=true, then it's mobile
// - If the first field of the user agent starts with either "rnbeta" or "Mattermost", then it's mobile
// - If the last field of the user agent starts with "Mattermost", then it's desktop
// - Otherwise, it's web
func GetOriginClient(r *http.Request) OriginClient {
userAgent := r.Header.Get("User-Agent")
fields := strings.Fields(userAgent)
if len(fields) < 1 {
return OriginClientUnknown
}
// Is mobile post v2?
queryParam := r.URL.Query().Get("mobilev2")
if queryParam == "true" {
return OriginClientMobile
}
// Is mobile pre v2?
clientAgent := fields[0]
if strings.HasPrefix(clientAgent, "rnbeta") || strings.HasPrefix(clientAgent, "Mattermost") {
return OriginClientMobile
}
// Is desktop?
if strings.HasPrefix(fields[len(fields)-1], "Mattermost") {
return OriginClientDesktop
}
// Default to web
return OriginClientWeb
}
// checkCSRFToken performs a CSRF check on the provided request with the given CSRF token. Returns whether
// a CSRF check occurred and whether it succeeded.
func (h *Handler) checkCSRFToken(c *Context, r *http.Request, tokenLocation app.TokenLocation, session *model.Session) (checked bool, passed bool) {
csrfCheckNeeded := session != nil && c.Err == nil && tokenLocation == app.TokenLocationCookie && !h.TrustRequester && r.Method != "GET"
csrfCheckPassed := false
if csrfCheckNeeded {
csrfHeader := r.Header.Get(model.HeaderCsrfToken)
if csrfHeader == session.GetCSRF() {
csrfCheckPassed = true
} else if r.Header.Get(model.HeaderRequestedWith) == model.HeaderRequestedWithXML {
// ToDo(DSchalla) 2019/01/04: Remove after deprecation period and only allow CSRF Header (MM-13657)
csrfErrorMessage := "CSRF Header check failed for request - Please upgrade your web application or custom app to set a CSRF Header"
fields := []mlog.Field{
mlog.String("path", r.URL.Path),
mlog.String("ip", r.RemoteAddr),
mlog.String("session_id", session.Id),
mlog.String("user_id", session.UserId),
}
if *c.App.Config().ServiceSettings.ExperimentalStrictCSRFEnforcement {
c.Logger.Warn(csrfErrorMessage, fields...)
} else {
c.Logger.Debug(csrfErrorMessage, fields...)
csrfCheckPassed = true
}
}
}
return csrfCheckNeeded, csrfCheckPassed
}
// APIHandler provides a handler for API endpoints which do not require the user to be logged in order for access to be
// granted.
func (w *Web) APIHandler(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
handler := &Handler{
Srv: w.srv,
HandleFunc: h,
HandlerName: GetHandlerName(h),
RequireSession: false,
TrustRequester: false,
RequireMfa: false,
IsStatic: false,
IsLocal: false,
}
if *w.srv.Config().ServiceSettings.WebserverMode == "gzip" {
return gzhttp.GzipHandler(handler)
}
return handler
}
// APIHandlerTrustRequester provides a handler for API endpoints which do not require the user to be logged in and are
// allowed to be requested directly rather than via javascript/XMLHttpRequest, such as site branding images or the
// websocket.
func (w *Web) APIHandlerTrustRequester(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
handler := &Handler{
Srv: w.srv,
HandleFunc: h,
HandlerName: GetHandlerName(h),
RequireSession: false,
TrustRequester: true,
RequireMfa: false,
IsStatic: false,
IsLocal: false,
}
if *w.srv.Config().ServiceSettings.WebserverMode == "gzip" {
return gzhttp.GzipHandler(handler)
}
return handler
}
// APISessionRequired provides a handler for API endpoints which require the user to be logged in in order for access to
// be granted.
func (w *Web) APISessionRequired(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
handler := &Handler{
Srv: w.srv,
HandleFunc: h,
HandlerName: GetHandlerName(h),
RequireSession: true,
TrustRequester: false,
RequireMfa: true,
IsStatic: false,
IsLocal: false,
}
if *w.srv.Config().ServiceSettings.WebserverMode == "gzip" {
return gzhttp.GzipHandler(handler)
}
return handler
}