snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,883 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"net/http"
"regexp"
"strings"
"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"
)
type Context struct {
App *app.App
AppContext request.CTX
Logger *mlog.Logger
Params *Params
Err *model.AppError
siteURLHeader string
}
// LogAuditRec logs an audit record using default LevelAPI.
func (c *Context) LogAuditRec(rec *model.AuditRecord) {
// finish populating the context data, in case the session wasn't available during MakeAuditRecord
// (e.g., api4/user.go login)
if rec.Actor.UserId == "" {
rec.Actor.UserId = c.AppContext.Session().UserId
}
if rec.Actor.SessionId == "" {
rec.Actor.SessionId = c.AppContext.Session().Id
}
if _, exists := rec.Meta[model.SessionPropUserAccessTokenId]; !exists && c.AppContext.Session().IsUserAccessToken() {
rec.Meta[model.SessionPropUserAccessTokenId] = c.AppContext.Session().Props[model.SessionPropUserAccessTokenId]
}
c.LogAuditRecWithLevel(rec, app.LevelAPI)
}
// LogAuditRecWithLevel logs an audit record using specified Level.
// If the context is flagged with a permissions error then `level`
// is ignored and the audit record is emitted with `LevelPerms`.
func (c *Context) LogAuditRecWithLevel(rec *model.AuditRecord, level mlog.Level) {
if rec == nil {
return
}
if c.Err != nil {
rec.AddErrorCode(c.Err.StatusCode)
rec.AddErrorDesc(c.Err.Error())
if c.Err.Id == "api.context.permissions.app_error" {
level = app.LevelPerms
}
rec.Fail()
}
c.App.Srv().Audit.LogRecord(level, *rec)
}
// MakeAuditRecord creates an audit record pre-populated with data from this context.
func (c *Context) MakeAuditRecord(event string, initialStatus string) *model.AuditRecord {
meta := map[string]any{
model.AuditKeyAPIPath: c.AppContext.Path(),
model.AuditKeyClusterID: c.App.GetClusterId(),
}
if c.AppContext.Session().IsUserAccessToken() {
meta[model.SessionPropUserAccessTokenId] = c.AppContext.Session().Props[model.SessionPropUserAccessTokenId]
}
rec := &model.AuditRecord{
EventName: event,
Status: initialStatus,
Actor: model.AuditEventActor{
UserId: c.AppContext.Session().UserId,
SessionId: c.AppContext.Session().Id,
Client: c.AppContext.UserAgent(),
IpAddress: c.AppContext.IPAddress(),
XForwardedFor: c.AppContext.XForwardedFor(),
},
Meta: meta,
EventData: model.AuditEventData{
Parameters: map[string]any{},
PriorState: map[string]any{},
ResultState: map[string]any{},
ObjectType: "",
},
}
return rec
}
func (c *Context) LogAudit(extraInfo string) {
audit := &model.Audit{UserId: c.AppContext.Session().UserId, IpAddress: c.AppContext.IPAddress(), Action: c.AppContext.Path(), ExtraInfo: extraInfo, SessionId: c.AppContext.Session().Id}
if err := c.App.Srv().Store().Audit().Save(audit); err != nil {
appErr := model.NewAppError("LogAudit", "app.audit.save.saving.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
c.LogErrorByCode(appErr)
}
}
func (c *Context) LogAuditWithUserId(userId, extraInfo string) {
if c.AppContext.Session().UserId != "" {
extraInfo = strings.TrimSpace(extraInfo + " session_user=" + c.AppContext.Session().UserId)
}
audit := &model.Audit{UserId: userId, IpAddress: c.AppContext.IPAddress(), Action: c.AppContext.Path(), ExtraInfo: extraInfo, SessionId: c.AppContext.Session().Id}
if err := c.App.Srv().Store().Audit().Save(audit); err != nil {
appErr := model.NewAppError("LogAuditWithUserId", "app.audit.save.saving.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
c.LogErrorByCode(appErr)
}
}
func (c *Context) LogErrorByCode(err *model.AppError) {
code := err.StatusCode
msg := err.SystemMessage(i18n.TDefault)
fields := []mlog.Field{
mlog.String("err_where", err.Where),
mlog.Int("http_code", err.StatusCode),
mlog.String("error", err.Error()),
}
switch {
case (code >= http.StatusBadRequest && code < http.StatusInternalServerError) ||
err.Id == "web.check_browser_compatibility.app_error":
c.Logger.Debug(msg, fields...)
case code == http.StatusNotImplemented:
c.Logger.Info(msg, fields...)
default:
c.Logger.Error(msg, fields...)
}
}
func (c *Context) IsSystemAdmin() bool {
return c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
}
func (c *Context) SessionRequired() {
if !*c.App.Config().ServiceSettings.EnableUserAccessTokens &&
c.AppContext.Session().Props[model.SessionPropType] == model.SessionTypeUserAccessToken &&
c.AppContext.Session().Props[model.SessionPropIsBot] != model.SessionPropIsBotValue {
c.Err = model.NewAppError("", "api.context.session_expired.app_error", nil, "UserAccessToken", http.StatusUnauthorized)
return
}
if c.AppContext.Session().UserId == "" {
c.Err = model.NewAppError("", "api.context.session_expired.app_error", nil, "UserRequired", http.StatusUnauthorized)
return
}
}
func (c *Context) CloudKeyRequired() {
if license := c.App.Channels().License(); license == nil || !license.IsCloud() || c.AppContext.Session().Props[model.SessionPropType] != model.SessionTypeCloudKey {
c.Err = model.NewAppError("", "api.context.session_expired.app_error", nil, "TokenRequired", http.StatusUnauthorized)
return
}
}
func (c *Context) RemoteClusterTokenRequired() {
if license := c.App.Channels().License(); license == nil || !license.HasRemoteClusterService() || c.AppContext.Session().Props[model.SessionPropType] != model.SessionTypeRemoteclusterToken {
c.Err = model.NewAppError("", "api.context.session_expired.app_error", nil, "TokenRequired", http.StatusUnauthorized)
return
}
}
func (c *Context) MfaRequired(method string) {
if appErr := c.App.MFARequired(c.AppContext, method); appErr != nil {
c.Err = appErr
}
}
// ExtendSessionExpiryIfNeeded will update Session.ExpiresAt based on session lengths in config.
// Session cookies will be resent to the client with updated max age.
func (c *Context) ExtendSessionExpiryIfNeeded(w http.ResponseWriter, r *http.Request) {
if ok := c.App.ExtendSessionExpiryIfNeeded(c.AppContext, c.AppContext.Session()); ok {
c.App.AttachSessionCookies(c.AppContext, w, r)
}
}
func (c *Context) RemoveSessionCookie(w http.ResponseWriter, r *http.Request) {
subpath, _ := utils.GetSubpathFromConfig(c.App.Config())
cookie := &http.Cookie{
Name: model.SessionCookieToken,
Value: "",
Path: subpath,
MaxAge: -1,
HttpOnly: true,
}
http.SetCookie(w, cookie)
}
func (c *Context) SetInvalidParam(parameter string) {
c.Err = NewInvalidParamError(parameter)
}
func (c *Context) SetInvalidParamWithDetails(parameter string, details string) {
c.Err = NewInvalidParamDetailedError(parameter, details)
}
func (c *Context) SetInvalidParamWithErr(parameter string, err error) {
c.Err = NewInvalidParamError(parameter).Wrap(err)
}
func (c *Context) SetInvalidURLParam(parameter string) {
c.Err = NewInvalidURLParamError(parameter)
}
func (c *Context) SetServerBusyError() {
c.Err = NewServerBusyError()
}
func (c *Context) SetInvalidRemoteIdError(id string) {
c.Err = NewInvalidRemoteIdError(id)
}
func (c *Context) SetInvalidRemoteClusterTokenError() {
c.Err = NewInvalidRemoteClusterTokenError()
}
func (c *Context) SetJSONEncodingError(err error) {
c.Err = NewJSONEncodingError(err)
}
func (c *Context) SetCommandNotFoundError() {
c.Err = model.NewAppError("GetCommand", "store.sql_command.save.get.app_error", nil, "", http.StatusNotFound)
}
func (c *Context) HandleEtag(etag string, routeName string, w http.ResponseWriter, r *http.Request) bool {
metrics := c.App.Metrics()
if et := r.Header.Get(model.HeaderEtagClient); etag != "" {
if et == etag {
w.Header().Set(model.HeaderEtagServer, etag)
w.WriteHeader(http.StatusNotModified)
if metrics != nil {
metrics.IncrementEtagHitCounter(routeName)
}
return true
}
}
if metrics != nil {
metrics.IncrementEtagMissCounter(routeName)
}
return false
}
func NewInvalidParamDetailedError(parameter string, details string) *model.AppError {
err := model.NewAppError("Context", "api.context.invalid_body_param.app_error", map[string]any{"Name": parameter}, details, http.StatusBadRequest)
return err
}
func NewInvalidParamError(parameter string) *model.AppError {
err := model.NewAppError("Context", "api.context.invalid_body_param.app_error", map[string]any{"Name": parameter}, "", http.StatusBadRequest)
return err
}
func NewInvalidURLParamError(parameter string) *model.AppError {
err := model.NewAppError("Context", "api.context.invalid_url_param.app_error", map[string]any{"Name": parameter}, "", http.StatusBadRequest)
return err
}
func NewServerBusyError() *model.AppError {
err := model.NewAppError("Context", "api.context.server_busy.app_error", nil, "", http.StatusServiceUnavailable)
return err
}
func NewInvalidRemoteIdError(parameter string) *model.AppError {
err := model.NewAppError("Context", "api.context.remote_id_invalid.app_error", map[string]any{"RemoteId": parameter}, "", http.StatusBadRequest)
return err
}
func NewInvalidRemoteClusterTokenError() *model.AppError {
err := model.NewAppError("Context", "api.context.remote_id_invalid.app_error", nil, "", http.StatusUnauthorized)
return err
}
func NewJSONEncodingError(err error) *model.AppError {
appErr := model.NewAppError("Context", "api.context.json_encoding.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return appErr
}
func (c *Context) SetPermissionError(permissions ...*model.Permission) {
c.Err = model.MakePermissionError(c.AppContext.Session(), permissions)
}
func (c *Context) SetSiteURLHeader(url string) {
c.siteURLHeader = strings.TrimRight(url, "/")
}
func (c *Context) GetSiteURLHeader() string {
return c.siteURLHeader
}
func (c *Context) RequireUserId() *Context {
if c.Err != nil {
return c
}
if c.Params.UserId == model.Me {
c.Params.UserId = c.AppContext.Session().UserId
}
if !model.IsValidId(c.Params.UserId) {
c.SetInvalidURLParam("user_id")
}
return c
}
func (c *Context) RequireOtherUserId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.OtherUserId) {
c.SetInvalidURLParam("other_user_id")
}
return c
}
func (c *Context) RequireTeamId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.TeamId) {
c.SetInvalidURLParam("team_id")
}
return c
}
func (c *Context) RequireCategoryId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidCategoryId(c.Params.CategoryId) {
c.SetInvalidURLParam("category_id")
}
return c
}
func (c *Context) RequireInviteId() *Context {
if c.Err != nil {
return c
}
if c.Params.InviteId == "" {
c.SetInvalidURLParam("invite_id")
}
return c
}
func (c *Context) RequireTokenId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.TokenId) {
c.SetInvalidURLParam("token_id")
}
return c
}
func (c *Context) RequireThreadId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.ThreadId) {
c.SetInvalidURLParam("thread_id")
}
return c
}
func (c *Context) RequireTimestamp() *Context {
if c.Err != nil {
return c
}
if c.Params.Timestamp == 0 {
c.SetInvalidURLParam("timestamp")
}
return c
}
func (c *Context) RequireChannelId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.ChannelId) {
c.SetInvalidURLParam("channel_id")
}
return c
}
func (c *Context) RequireUsername() *Context {
if c.Err != nil {
return c
}
if !model.IsValidUsername(c.Params.Username) {
c.SetInvalidParam("username")
}
return c
}
func (c *Context) RequirePostId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.PostId) {
c.SetInvalidURLParam("post_id")
}
return c
}
func (c *Context) RequirePolicyId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.PolicyId) {
c.SetInvalidURLParam("policy_id")
}
return c
}
func (c *Context) RequireAppId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.AppId) {
c.SetInvalidURLParam("app_id")
}
return c
}
func (c *Context) RequireOutgoingOAuthConnectionId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.OutgoingOAuthConnectionID) {
c.SetInvalidURLParam("outgoing_oauth_connection_id")
}
return c
}
func (c *Context) RequireFileId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.FileId) {
c.SetInvalidURLParam("file_id")
}
return c
}
func (c *Context) RequireUploadId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.UploadId) {
c.SetInvalidURLParam("upload_id")
}
return c
}
func (c *Context) RequireFilename() *Context {
if c.Err != nil {
return c
}
if c.Params.Filename == "" {
c.SetInvalidURLParam("filename")
}
return c
}
func (c *Context) RequirePluginId() *Context {
if c.Err != nil {
return c
}
if c.Params.PluginId == "" {
c.SetInvalidURLParam("plugin_id")
}
return c
}
func (c *Context) RequireReportId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.ReportId) {
c.SetInvalidURLParam("report_id")
}
return c
}
func (c *Context) RequireEmojiId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.EmojiId) {
c.SetInvalidURLParam("emoji_id")
}
return c
}
func (c *Context) RequireTeamName() *Context {
if c.Err != nil {
return c
}
if !model.IsValidTeamName(c.Params.TeamName) {
c.SetInvalidURLParam("team_name")
}
return c
}
func (c *Context) RequireChannelName() *Context {
if c.Err != nil {
return c
}
if !model.IsValidChannelIdentifier(c.Params.ChannelName) {
c.SetInvalidURLParam("channel_name")
}
return c
}
func (c *Context) SanitizeEmail() *Context {
if c.Err != nil {
return c
}
c.Params.Email = strings.ToLower(c.Params.Email)
if !model.IsValidEmail(c.Params.Email) {
c.SetInvalidURLParam("email")
}
return c
}
func (c *Context) RequireCategory() *Context {
if c.Err != nil {
return c
}
if !model.IsValidAlphaNumHyphenUnderscore(c.Params.Category, true) {
c.SetInvalidURLParam("category")
}
return c
}
func (c *Context) RequireService() *Context {
if c.Err != nil {
return c
}
if c.Params.Service == "" {
c.SetInvalidURLParam("service")
}
return c
}
func (c *Context) RequirePreferenceName() *Context {
if c.Err != nil {
return c
}
if !model.IsValidAlphaNumHyphenUnderscore(c.Params.PreferenceName, true) {
c.SetInvalidURLParam("preference_name")
}
return c
}
func (c *Context) RequireEmojiName() *Context {
if c.Err != nil {
return c
}
validName := regexp.MustCompile(`^[a-zA-Z0-9\-\+_]+$`)
if c.Params.EmojiName == "" || len(c.Params.EmojiName) > model.EmojiNameMaxLength || !validName.MatchString(c.Params.EmojiName) {
c.SetInvalidURLParam("emoji_name")
}
return c
}
func (c *Context) RequireHookId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.HookId) {
c.SetInvalidURLParam("hook_id")
}
return c
}
func (c *Context) RequireCommandId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.CommandId) {
c.SetInvalidURLParam("command_id")
}
return c
}
func (c *Context) RequireJobId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.JobId) {
c.SetInvalidURLParam("job_id")
}
return c
}
func (c *Context) RequireJobType() *Context {
if c.Err != nil {
return c
}
if c.Params.JobType == "" || len(c.Params.JobType) > 32 {
c.SetInvalidURLParam("job_type")
}
return c
}
func (c *Context) RequireRoleId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.RoleId) {
c.SetInvalidURLParam("role_id")
}
return c
}
func (c *Context) RequireFieldId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.FieldId) {
c.SetInvalidURLParam("field_id")
}
return c
}
func (c *Context) RequireGroupName() *Context {
if c.Err != nil {
return c
}
if !model.IsValidPropertyGroupName(c.Params.GroupName) {
c.SetInvalidURLParam("group_name")
}
return c
}
func (c *Context) RequireObjectType() *Context {
if c.Err != nil {
return c
}
if !model.IsValidPropertyFieldObjectType(c.Params.ObjectType) {
c.SetInvalidURLParam("object_type")
}
return c
}
func (c *Context) RequireTargetId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.TargetId) {
c.SetInvalidURLParam("target_id")
}
return c
}
func (c *Context) RequireSchemeId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.SchemeId) {
c.SetInvalidURLParam("scheme_id")
}
return c
}
func (c *Context) RequireRoleName() *Context {
if c.Err != nil {
return c
}
if !model.IsValidRoleName(c.Params.RoleName) {
c.SetInvalidURLParam("role_name")
}
return c
}
func (c *Context) RequireGroupId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.GroupId) {
c.SetInvalidURLParam("group_id")
}
return c
}
func (c *Context) RequireRemoteId() *Context {
if c.Err != nil {
return c
}
if c.Params.RemoteId == "" {
c.SetInvalidURLParam("remote_id")
}
return c
}
func (c *Context) RequireSyncableId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.SyncableId) {
c.SetInvalidURLParam("syncable_id")
}
return c
}
func (c *Context) RequireSyncableType() *Context {
if c.Err != nil {
return c
}
if c.Params.SyncableType != model.GroupSyncableTypeTeam && c.Params.SyncableType != model.GroupSyncableTypeChannel {
c.SetInvalidURLParam("syncable_type")
}
return c
}
func (c *Context) RequireBotUserId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.BotUserId) {
c.SetInvalidURLParam("bot_user_id")
}
return c
}
func (c *Context) RequireInvoiceId() *Context {
if c.Err != nil {
return c
}
if len(c.Params.InvoiceId) != 27 && c.Params.InvoiceId != model.UpcomingInvoice {
c.SetInvalidURLParam("invoice_id")
}
return c
}
func (c *Context) RequireContentReviewerId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.ContentReviewerId) {
c.SetInvalidURLParam("content_reviewer_id")
}
return c
}
func (c *Context) RequireRecapId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.RecapId) {
c.SetInvalidURLParam("recap_id")
}
return c
}
func (c *Context) RequireScheduledRecapId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.ScheduledRecapId) {
c.SetInvalidURLParam("scheduled_recap_id")
}
return c
}
func (c *Context) RequireViewId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.ViewId) {
c.SetInvalidURLParam("view_id")
}
return c
}
func (c *Context) RequirePermissionToManageSecureConnections() *Context {
if c.Err != nil {
return c
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) {
c.SetPermissionError(model.PermissionManageSecureConnections)
}
return c
}
func (c *Context) RequirePermissionToManageSharedChannels() *Context {
if c.Err != nil {
return c
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSharedChannels) {
c.SetPermissionError(model.PermissionManageSharedChannels)
}
return c
}
func (c *Context) RequirePermissionToManageSecureConnectionsOrSharedChannels() *Context {
if c.Err != nil {
return c
}
if !c.App.SessionHasPermissionToAny(*c.AppContext.Session(), []*model.Permission{
model.PermissionManageSecureConnections,
model.PermissionManageSharedChannels,
}) {
c.SetPermissionError(model.PermissionManageSecureConnections, model.PermissionManageSharedChannels)
}
return c
}
func (c *Context) GetRemoteID(r *http.Request) string {
return r.Header.Get(model.HeaderRemoteclusterId)
}

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
func TestMakeAuditRecord(t *testing.T) {
t.Run("PAT session includes token ID in meta", func(t *testing.T) {
th := SetupWithStoreMock(t)
tokenID := model.NewId()
th.Context = th.Context.WithSession(&model.Session{
Id: model.NewId(),
UserId: model.NewId(),
Props: model.StringMap{
model.SessionPropType: model.SessionTypeUserAccessToken,
model.SessionPropUserAccessTokenId: tokenID,
},
})
c := &Context{App: th.App, AppContext: th.Context}
rec := c.MakeAuditRecord("test_event", model.AuditStatusAttempt)
require.NotNil(t, rec)
assert.Equal(t, tokenID, rec.Meta[model.SessionPropUserAccessTokenId])
})
t.Run("non-PAT session omits token ID from meta", func(t *testing.T) {
th := SetupWithStoreMock(t)
th.Context = th.Context.WithSession(&model.Session{
Id: model.NewId(),
UserId: model.NewId(),
})
c := &Context{App: th.App, AppContext: th.Context}
rec := c.MakeAuditRecord("test_event", model.AuditStatusAttempt)
require.NotNil(t, rec)
_, exists := rec.Meta[model.SessionPropUserAccessTokenId]
assert.False(t, exists, "token ID key should be absent from Meta for non-PAT sessions")
})
}
func TestLogAuditRec(t *testing.T) {
t.Run("late-fills token ID for PAT session", func(t *testing.T) {
th := SetupWithStoreMock(t)
tokenID := model.NewId()
th.Context = th.Context.WithSession(&model.Session{
Id: model.NewId(),
UserId: model.NewId(),
Props: model.StringMap{
model.SessionPropType: model.SessionTypeUserAccessToken,
model.SessionPropUserAccessTokenId: tokenID,
},
})
c := &Context{App: th.App, AppContext: th.Context}
// Simulate a record created before the session was established (e.g., login flow)
rec := &model.AuditRecord{
EventName: "test_event",
Status: model.AuditStatusAttempt,
Actor: model.AuditEventActor{},
Meta: map[string]any{},
EventData: model.AuditEventData{},
}
c.LogAuditRec(rec)
assert.Equal(t, tokenID, rec.Meta[model.SessionPropUserAccessTokenId])
})
t.Run("does not add token ID for non-PAT session", func(t *testing.T) {
th := SetupWithStoreMock(t)
th.Context = th.Context.WithSession(&model.Session{
Id: model.NewId(),
UserId: model.NewId(),
})
c := &Context{App: th.App, AppContext: th.Context}
rec := &model.AuditRecord{
EventName: "test_event",
Status: model.AuditStatusAttempt,
Actor: model.AuditEventActor{},
Meta: map[string]any{},
EventData: model.AuditEventData{},
}
c.LogAuditRec(rec)
_, exists := rec.Meta[model.SessionPropUserAccessTokenId]
assert.False(t, exists, "token ID key should be absent from Meta for non-PAT sessions")
})
}
func TestRequireHookId(t *testing.T) {
c := &Context{}
t.Run("WhenHookIdIsValid", func(t *testing.T) {
c.Params = &Params{HookId: "abcdefghijklmnopqrstuvwxyz"}
c.RequireHookId()
require.Nil(t, c.Err, "Hook Id is Valid. Should not have set error in context")
})
t.Run("WhenHookIdIsInvalid", func(t *testing.T) {
c.Params = &Params{HookId: "abc"}
c.RequireHookId()
require.NotNil(t, c.Err, "Should have set Error in context")
require.Equal(t, http.StatusBadRequest, c.Err.StatusCode, "Should have set status as 400")
})
}
func TestCloudKeyRequired(t *testing.T) {
th := SetupWithStoreMock(t)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
c := &Context{
App: th.App,
AppContext: th.Context,
}
c.CloudKeyRequired()
assert.Equal(t, c.Err.Id, "api.context.session_expired.app_error")
}
func TestMfaRequired(t *testing.T) {
th := SetupWithStoreMock(t)
mockStore := th.App.Srv().Store().(*mocks.Store)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockUserStore.On("Get", mock.Anything, "userid").Return(nil, model.NewAppError("Userstore.Get", "storeerror", nil, "store error", http.StatusInternalServerError))
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(model.PostMessageMaxBytesV2, nil)
mockSystemStore := mocks.SystemStore{}
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
mockStore.On("GetDBSchemaVersion").Return(1, nil)
th.App.Srv().SetLicense(model.NewTestLicense("mfa"))
th.Context = th.Context.WithSession(&model.Session{Id: "abc", UserId: "userid"})
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AnnouncementSettings.UserNoticesEnabled = false
*cfg.AnnouncementSettings.AdminNoticesEnabled = false
*cfg.ServiceSettings.EnableMultifactorAuthentication = true
*cfg.ServiceSettings.EnforceMultifactorAuthentication = true
})
c := &Context{
App: th.App,
AppContext: th.Context,
}
c.MfaRequired(http.MethodGet)
assert.Equal(t, c.Err.Id, "api.context.get_user.app_error")
}

View File

@@ -0,0 +1,602 @@
// 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"
)
// tokenDigest returns a stable, non-reversible identifier for a token, usable for correlation.
func tokenDigest(token string) string {
if token == "" {
return "<none>"
}
return utils.HashSha256(token)[:16]
}
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 directives needed for the inline scripts injected into root.html.
// These values are fixed on server start and intentionally require a restart to take effect.
cfg := w.srv.Config()
subpath, _ := utils.GetSubpathFromConfig(cfg)
return &Handler{
Srv: w.srv,
HandleFunc: h,
HandlerName: GetHandlerName(h),
RequireSession: false,
TrustRequester: false,
RequireMfa: false,
IsStatic: true,
cspShaDirective: utils.GetStaticScriptHashes(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))
if c.AppContext.Session().IsUserAccessToken() {
responseLogFields = append(responseLogFields, mlog.String("user_access_token_id", c.AppContext.Session().Props[model.SessionPropUserAccessTokenId]))
}
}
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,
)
if connectionId := r.Header.Get(model.ConnectionId); connectionId != "" {
c.AppContext = c.AppContext.WithConnectionId(connectionId)
}
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.String("error", strings.ReplaceAll(err.Error(), token, tokenDigest(token))))
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_sha256="+tokenDigest(token), http.StatusUnauthorized)
}
} else if !session.IsOAuth && tokenLocation == app.TokenLocationQueryString {
c.Err = model.NewAppError("ServeHTTP", "api.context.token_provided.app_error", nil, "token_sha256="+tokenDigest(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(r.Context(), 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_sha256="+tokenDigest(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.String("error", strings.ReplaceAll(err.Error(), token, tokenDigest(token))))
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.String("error", strings.ReplaceAll(err.Error(), token, tokenDigest(token))))
c.Err = err
} else {
c.AppContext = c.AppContext.WithSession(session)
}
}
}
loggerFields := []mlog.Field{
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),
}
if c.AppContext.Session().IsUserAccessToken() {
loggerFields = append(loggerFields, mlog.String("user_access_token_id", c.AppContext.Session().Props[model.SessionPropUserAccessTokenId]))
}
c.Logger = c.App.Log().With(loggerFields...)
c.AppContext = c.AppContext.WithLogger(c.Logger)
c.App.ProcessSessionAttributesRequest(c.AppContext, r)
if c.Err == nil && h.RequireSession {
c.SessionRequired()
}
if c.Err == nil && h.RequireMfa {
c.MfaRequired(r.Method)
}
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.EnableHardenedMode && c.Err.StatusCode >= 500 {
c.Err.Id = ""
c.Err.Message = "Internal Server Error"
c.Err.WipeDetailed()
// Props is always exposed to clients, so scrub it along with the rest of the sanitized 5xx.
c.Err.Props = nil
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
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"html"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
func (w *Web) InitMagicLink() {
w.MainRouter.Handle("/login/one_time_link", w.APIHandler(loginWithMagicLinkToken)).Methods(http.MethodGet)
}
func loginWithMagicLinkToken(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventLogin, model.AuditStatusFail)
auditRec.AddMeta("login_method", "guest_magic_link")
defer c.LogAuditRec(auditRec)
if !*c.App.Config().GuestAccountsSettings.EnableGuestMagicLink {
http.Redirect(w, r, c.GetSiteURLHeader()+"/login?extra=login_error&message="+html.EscapeString("Magic link is disabled"), http.StatusFound)
return
}
if c.AppContext.Session() != nil && c.AppContext.Session().UserId != "" {
http.Redirect(w, r, c.GetSiteURLHeader()+"/error?type=magic_link_already_logged_in", http.StatusFound)
return
}
tokenString := r.URL.Query().Get("t")
if tokenString == "" {
c.Err = model.NewAppError("loginWithMagicLinkToken", "api.user.guest_magic_link.missing_token.app_error", nil, "", http.StatusBadRequest)
return
}
// Rate limit by IP to prevent brute force attacks on tokens
if c.App.Srv().RateLimiter != nil {
rateLimitKey := c.App.Srv().RateLimiter.GenerateKey(r)
if c.App.Srv().RateLimiter.RateLimitWriter(r.Context(), rateLimitKey, w) {
return
}
}
redirectURL := html.EscapeString(r.URL.Query().Get("redirect_to"))
user, err := c.App.AuthenticateUserForGuestMagicLink(c.AppContext, tokenString)
if err != nil {
http.Redirect(w, r, c.GetSiteURLHeader()+"/login?extra=login_error&message="+html.EscapeString(err.Message), http.StatusFound)
return
}
auditRec.AddMeta("user_id", user.Id)
c.LogAuditWithUserId(user.Id, "attempt - guest_magic_link")
// Check user authentication criteria
if authErr := c.App.CheckUserAllAuthenticationCriteria(c.AppContext, user, ""); authErr != nil {
c.LogAuditWithUserId(user.Id, "failure - guest_magic_link")
utils.RenderWebAppError(c.App.Config(), w, r, authErr, c.App.AsymmetricSigningKey())
return
}
// Create session and log user in
session, err := c.App.DoLogin(c.AppContext, w, r, user, model.LoginOptions{
IsSaml: true,
})
if err != nil {
utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey())
return
}
c.AppContext = c.AppContext.WithSession(session)
// Mark login as successful in audit log
auditRec.Success()
c.LogAuditWithUserId(user.Id, "success - guest_magic_link")
// Attach session cookies and redirect
c.App.AttachSessionCookies(c.AppContext, w, r)
// Determine redirect URL
if redirectURL == "" {
// No redirect specified - go to root and let webapp route to appropriate location
redirectURL = c.GetSiteURLHeader()
} else {
// Validate and make redirect URL fully qualified
redirectURL = fullyQualifiedRedirectURL(c.GetSiteURLHeader(), redirectURL, c.App.Config().NativeAppSettings.AppCustomURLSchemes)
}
http.Redirect(w, r, redirectURL, http.StatusFound)
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
var mainHelper *testlib.MainHelper
func TestMain(m *testing.M) {
var options = testlib.HelperOptions{
EnableStore: true,
EnableResources: true,
}
mainHelper = testlib.NewMainHelperWithOptions(&options)
defer mainHelper.Close()
mainHelper.Main(m)
}

View File

@@ -0,0 +1,715 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"encoding/json"
"fmt"
"html"
"net/http"
"net/url"
"path"
"path/filepath"
"slices"
"strings"
"time"
"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/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
)
const (
callbackHost = "callback"
)
func (w *Web) InitOAuth() {
// OAuth 2.0 Authorization Server Metadata endpoint (RFC 8414)
// Match the exact path and any path with additional segments after it
w.MainRouter.PathPrefix(model.OAuthMetadataEndpoint).Handler(w.APIHandlerTrustRequester(getAuthorizationServerMetadata)).Methods(http.MethodGet)
// API version independent OAuth 2.0 as a service provider endpoints
w.MainRouter.Handle(model.OAuthAuthorizeEndpoint, w.APIHandlerTrustRequester(authorizeOAuthPage)).Methods(http.MethodGet)
w.MainRouter.Handle(model.OAuthAuthorizeEndpoint, w.APISessionRequired(authorizeOAuthApp)).Methods(http.MethodPost)
w.MainRouter.Handle(model.OAuthDeauthorizeEndpoint, w.APISessionRequired(deauthorizeOAuthApp)).Methods(http.MethodPost)
w.MainRouter.Handle(model.OAuthAccessTokenEndpoint, w.APIHandlerTrustRequester(getAccessToken)).Methods(http.MethodPost)
// API version independent OAuth as a client endpoints
w.MainRouter.Handle("/oauth/{service:[A-Za-z0-9]+}/complete", w.APIHandler(completeOAuth)).Methods(http.MethodGet)
w.MainRouter.Handle("/oauth/{service:[A-Za-z0-9]+}/login", w.APIHandler(loginWithOAuth)).Methods(http.MethodGet)
w.MainRouter.Handle("/oauth/{service:[A-Za-z0-9]+}/mobile_login", w.APIHandler(mobileLoginWithOAuth)).Methods(http.MethodGet)
w.MainRouter.Handle("/oauth/{service:[A-Za-z0-9]+}/signup", w.APIHandler(signupWithOAuth)).Methods(http.MethodGet)
// Intune MAM authentication endpoint
w.MainRouter.Handle("/oauth/intune", w.APIHandler(loginByIntune)).Methods(http.MethodPost)
// Old endpoints for backwards compatibility, needed to not break SSO for any old setups
w.MainRouter.Handle("/api/v3/oauth/{service:[A-Za-z0-9]+}/complete", w.APIHandler(completeOAuth)).Methods(http.MethodGet)
w.MainRouter.Handle("/signup/{service:[A-Za-z0-9]+}/complete", w.APIHandler(completeOAuth)).Methods(http.MethodGet)
w.MainRouter.Handle("/login/{service:[A-Za-z0-9]+}/complete", w.APIHandler(completeOAuth)).Methods(http.MethodGet)
w.MainRouter.Handle("/api/v4/oauth_test", w.APISessionRequired(testHandler)).Methods(http.MethodGet)
}
func testHandler(c *Context, w http.ResponseWriter, r *http.Request) {
ReturnStatusOK(w)
}
func authorizeOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) {
var authRequest *model.AuthorizeRequest
err := json.NewDecoder(r.Body).Decode(&authRequest)
if err != nil || authRequest == nil {
c.SetInvalidParamWithErr("authorize_request", err)
return
}
if err := authRequest.IsValid(); err != nil {
c.Err = err
return
}
if c.AppContext.Session().IsOAuth {
c.SetPermissionError(model.PermissionEditOtherUsers)
c.Err.DetailedError += ", attempted access by oauth app"
return
}
auditRec := c.MakeAuditRecord(model.AuditEventAuthorizeOAuthApp, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
redirectURL, appErr := c.App.AllowOAuthAppAccessToUser(c.AppContext, c.AppContext.Session().UserId, authRequest)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
c.LogAudit("success")
_, err = w.Write([]byte(model.MapToJSON(map[string]string{"redirect": redirectURL})))
if err != nil {
c.Logger.Warn("Error writing response", mlog.Err(err))
}
}
func deauthorizeOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) {
requestData := model.MapFromJSON(r.Body)
clientId := requestData["client_id"]
if !model.IsValidId(clientId) {
c.SetInvalidParam("client_id")
return
}
if c.AppContext.Session().IsOAuth {
c.SetPermissionError(model.PermissionEditOtherUsers)
c.Err.DetailedError += ", attempted access by oauth app"
return
}
auditRec := c.MakeAuditRecord(model.AuditEventDeauthorizeOAuthApp, model.AuditStatusFail)
auditRec.AddMeta("client_id", clientId)
defer c.LogAuditRec(auditRec)
err := c.App.DeauthorizeOAuthAppForUser(c.AppContext, c.AppContext.Session().UserId, clientId)
if err != nil {
c.Err = err
return
}
auditRec.Success()
c.LogAudit("success")
ReturnStatusOK(w)
}
func authorizeOAuthPage(c *Context, w http.ResponseWriter, r *http.Request) {
if !*c.App.Config().ServiceSettings.EnableOAuthServiceProvider {
err := model.NewAppError("authorizeOAuth", "api.oauth.authorize_oauth.disabled.app_error", nil, "", http.StatusNotImplemented)
utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey())
return
}
authRequest := &model.AuthorizeRequest{
ResponseType: r.URL.Query().Get("response_type"),
ClientId: r.URL.Query().Get("client_id"),
RedirectURI: r.URL.Query().Get("redirect_uri"),
Scope: r.URL.Query().Get("scope"),
State: r.URL.Query().Get("state"),
CodeChallenge: r.URL.Query().Get("code_challenge"),
CodeChallengeMethod: r.URL.Query().Get("code_challenge_method"),
Resource: r.URL.Query().Get("resource"),
}
loginHint := r.URL.Query().Get("login_hint")
if err := authRequest.IsValid(); err != nil {
utils.RenderWebError(c.App.Config(), w, r, err.StatusCode,
url.Values{
"type": []string{"oauth_invalid_param"},
"message": []string{err.Message},
}, c.App.AsymmetricSigningKey())
return
}
auditRec := c.MakeAuditRecord(model.AuditEventAuthorizeOAuthPage, model.AuditStatusFail)
auditRec.AddMeta("client_id", authRequest.ClientId)
auditRec.AddMeta("scope", authRequest.Scope)
defer c.LogAuditRec(auditRec)
oauthApp, err := c.App.GetOAuthApp(authRequest.ClientId)
if err != nil {
utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey())
return
}
// here we should check if the user is logged in
if c.AppContext.Session().UserId == "" {
auditRec.Success()
c.LogAudit("success")
if loginHint == model.UserAuthServiceSaml {
http.Redirect(w, r, c.GetSiteURLHeader()+"/login/sso/saml?redirect_to="+url.QueryEscape(r.RequestURI), http.StatusFound)
} else {
http.Redirect(w, r, c.GetSiteURLHeader()+"/login?redirect_to="+url.QueryEscape(r.RequestURI), http.StatusFound)
}
return
}
if !oauthApp.IsValidRedirectURL(authRequest.RedirectURI) {
err := model.NewAppError("authorizeOAuthPage", "api.oauth.allow_oauth.redirect_callback.app_error", nil, "", http.StatusBadRequest)
utils.RenderWebError(c.App.Config(), w, r, err.StatusCode,
url.Values{
"type": []string{"oauth_invalid_redirect_url"},
"message": []string{i18n.T("api.oauth.allow_oauth.redirect_callback.app_error")},
}, c.App.AsymmetricSigningKey())
return
}
// Validate PKCE requirements for public clients using authorization code flow
// Implicit flow doesn't require PKCE as it doesn't use code exchange
if oauthApp.IsPublicClient() && authRequest.ResponseType == model.AuthCodeResponseType && authRequest.CodeChallenge == "" {
err := model.NewAppError("authorizeOAuthPage", "api.oauth.allow_oauth.pkce_required_public.app_error", nil, "", http.StatusBadRequest)
utils.RenderWebError(c.App.Config(), w, r, err.StatusCode,
url.Values{
"type": []string{"oauth_pkce_required"},
"message": []string{"PKCE is required for public clients using authorization code flow"},
}, c.App.AsymmetricSigningKey())
return
}
isAuthorized := false
if _, err := c.App.GetPreferenceByCategoryAndNameForUser(c.AppContext, c.AppContext.Session().UserId, model.PreferenceCategoryAuthorizedOAuthApp, authRequest.ClientId); err == nil {
// when we support scopes we should check if the scopes match
isAuthorized = true
}
// Automatically allow if the app is trusted
if oauthApp.IsTrusted || isAuthorized {
redirectURL, err := c.App.AllowOAuthAppAccessToUser(c.AppContext, c.AppContext.Session().UserId, authRequest)
if err != nil {
utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey())
return
}
auditRec.Success()
c.LogAudit("success")
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
auditRec.Success()
c.LogAudit("success")
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
w.Header().Set("Content-Security-Policy", fmt.Sprintf("frame-ancestors 'self' %s", *c.App.Config().ServiceSettings.FrameAncestors))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, max-age=31556926")
staticDir, _ := fileutils.FindDir(model.ClientDir)
http.ServeFile(w, r, filepath.Join(staticDir, "root.html"))
}
func getAccessToken(c *Context, w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
c.Err = model.NewAppError("getAccessToken", "api.oauth.get_access_token.bad_request.app_error", nil, "", http.StatusBadRequest)
return
}
code := r.FormValue("code")
refreshToken := r.FormValue("refresh_token")
grantType := r.FormValue("grant_type")
switch grantType {
case model.AccessTokenGrantType:
if code == "" {
c.Err = model.NewAppError("getAccessToken", "api.oauth.get_access_token.missing_code.app_error", nil, "", http.StatusBadRequest)
return
}
case model.RefreshTokenGrantType:
if refreshToken == "" {
c.Err = model.NewAppError("getAccessToken", "api.oauth.get_access_token.missing_refresh_token.app_error", nil, "", http.StatusBadRequest)
return
}
default:
c.Err = model.NewAppError("getAccessToken", "api.oauth.get_access_token.bad_grant.app_error", nil, "", http.StatusBadRequest)
return
}
clientId := r.FormValue("client_id")
if !model.IsValidId(clientId) {
c.Err = model.NewAppError("getAccessToken", "api.oauth.get_access_token.bad_client_id.app_error", nil, "", http.StatusBadRequest)
return
}
secret := r.FormValue("client_secret")
codeVerifier := r.FormValue("code_verifier")
// Authentication validation will be done at app layer based on client type
// For public clients: client_secret should be empty, code_verifier required
// For confidential clients: client_secret required, code_verifier optional but enforced if used
redirectURI := r.FormValue("redirect_uri")
resource := r.FormValue("resource")
auditRec := c.MakeAuditRecord(model.AuditEventGetAccessToken, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
auditRec.AddMeta("grant_type", grantType)
auditRec.AddMeta("client_id", clientId)
c.LogAudit("attempt")
accessRsp, err := c.App.GetOAuthAccessTokenForCodeFlow(c.AppContext, clientId, grantType, redirectURI, code, secret, refreshToken, codeVerifier, resource)
if err != nil {
c.Err = err
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
auditRec.Success()
c.LogAudit("success")
if err := json.NewEncoder(w).Encode(accessRsp); err != nil {
c.Logger.Warn("Error writing response", mlog.Err(err))
}
}
func completeOAuth(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireService()
if c.Err != nil {
return
}
service := c.Params.Service
auditRec := c.MakeAuditRecord(model.AuditEventCompleteOAuth, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "service", service)
oauthError := r.URL.Query().Get("error")
if oauthError == "access_denied" {
utils.RenderWebError(c.App.Config(), w, r, http.StatusTemporaryRedirect, url.Values{
"type": []string{"oauth_access_denied"},
"service": []string{strings.Title(service)},
}, c.App.AsymmetricSigningKey())
return
}
code := r.URL.Query().Get("code")
if code == "" {
utils.RenderWebError(c.App.Config(), w, r, http.StatusTemporaryRedirect, url.Values{
"type": []string{"oauth_missing_code"},
"service": []string{strings.Title(service)},
}, c.App.AsymmetricSigningKey())
return
}
state := r.URL.Query().Get("state")
uri := c.GetSiteURLHeader() + "/signup/" + service + "/complete"
body, props, tokenUser, err := c.App.AuthorizeOAuthUser(c.AppContext, w, r, service, code, state, uri)
action := ""
hasRedirectURL := false
isMobile := false
redirectURL := ""
if props != nil {
action = props["action"]
isMobile = action == model.OAuthActionMobile
if val, ok := props["redirect_to"]; ok {
redirectURL = val
hasRedirectURL = redirectURL != ""
}
}
redirectURL = fullyQualifiedRedirectURL(c.GetSiteURLHeader(), redirectURL, c.App.Config().NativeAppSettings.AppCustomURLSchemes)
renderError := func(err *model.AppError) {
if isMobile && hasRedirectURL {
utils.RenderMobileError(c.App.Config(), w, err, redirectURL)
} else {
utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey())
}
}
if err != nil {
err.Translate(c.AppContext.T)
c.LogErrorByCode(err)
renderError(err)
return
}
user, err := c.App.CompleteOAuth(c.AppContext, service, body, props, tokenUser)
if err != nil {
err.Translate(c.AppContext.T)
c.LogErrorByCode(err)
renderError(err)
return
}
if action == model.OAuthActionEmailToSSO {
redirectURL = c.GetSiteURLHeader() + "/login?extra=signin_change"
} else if action == model.OAuthActionSSOToEmail {
redirectURL = app.GetProtocol(r) + "://" + r.Host + "/claim?email=" + url.QueryEscape(props["email"])
} else {
desktopToken := ""
if val, ok := props["desktop_token"]; ok {
desktopToken = val
}
// If it's a desktop login we generate a token and redirect to another endpoint to handle session creation
if desktopToken != "" {
serverToken, serverTokenErr := c.App.GenerateAndSaveDesktopToken(time.Now().Unix(), user)
if serverTokenErr != nil {
serverTokenErr.Translate(c.AppContext.T)
c.LogErrorByCode(serverTokenErr)
renderError(serverTokenErr)
return
}
queryString := map[string]string{
"client_token": desktopToken,
"server_token": *serverToken,
}
if val, ok := props["redirect_to"]; ok {
queryString["redirect_to"] = val
}
if strings.HasPrefix(desktopToken, "dev-") {
queryString["isDesktopDev"] = "true"
}
redirectURL = utils.AppendQueryParamsToURL(c.GetSiteURLHeader()+"/login/desktop", queryString)
auditRec.Success()
auditRec.Actor.UserId = user.Id
c.LogAuditWithUserId(user.Id, "authenticated")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
return
}
isOAuthUser := user.IsOAuthUser()
session, err := c.App.DoLogin(c.AppContext, w, r, user, model.LoginOptions{
IsMobile: isMobile,
IsOAuthUser: isOAuthUser,
})
if err != nil {
err.Translate(c.AppContext.T)
c.Logger.Error(err.Error())
renderError(err)
return
}
c.AppContext = c.AppContext.WithSession(session)
// Old mobile version
if isMobile && !hasRedirectURL {
c.App.AttachSessionCookies(c.AppContext, w, r)
auditRec.Success()
c.LogAudit("success")
return
} else
// New mobile version
if isMobile && hasRedirectURL {
redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{
model.SessionCookieToken: c.AppContext.Session().Token,
model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(),
"srv": c.App.GetSiteURL(), // Server URL for mobile client verification
})
utils.RenderMobileAuthComplete(w, redirectURL)
auditRec.Success()
c.LogAudit("success")
return
}
// For web
c.App.AttachSessionCookies(c.AppContext, w, r)
}
auditRec.Success()
c.LogAudit("success")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
}
func loginWithOAuth(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireService()
if c.Err != nil {
return
}
loginHint := r.URL.Query().Get("login_hint")
redirectURL := r.URL.Query().Get("redirect_to")
desktopToken := r.URL.Query().Get("desktop_token")
if redirectURL != "" {
if err := utils.ValidateWebAuthRedirectUrl(c.App.Config(), redirectURL); err != nil {
c.Err = model.NewAppError("loginWithOAuth", "api.invalid_redirect_url", nil, err.Error(), http.StatusBadRequest)
return
}
}
auditRec := c.MakeAuditRecord(model.AuditEventLoginWithOAuth, model.AuditStatusFail)
auditRec.AddMeta("service", c.Params.Service)
defer c.LogAuditRec(auditRec)
// Get invite token or ID instead of team_id
tokenID := r.URL.Query().Get("t")
inviteId := r.URL.Query().Get("id")
authURL, err := c.App.GetOAuthLoginEndpoint(c.AppContext, w, r, c.Params.Service, model.OAuthActionLogin, redirectURL, loginHint, false, desktopToken, tokenID, inviteId)
if err != nil {
c.Err = err
return
}
auditRec.Success()
c.LogAudit("success")
http.Redirect(w, r, authURL, http.StatusFound)
}
func mobileLoginWithOAuth(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireService()
if c.Err != nil {
return
}
redirectURL := html.EscapeString(r.URL.Query().Get("redirect_to"))
if redirectURL != "" && !utils.IsValidMobileAuthRedirectURL(c.App.Config(), redirectURL) {
err := model.NewAppError("mobileLoginWithOAuth", "api.invalid_custom_url_scheme", nil, "", http.StatusBadRequest)
utils.RenderMobileError(c.App.Config(), w, err, redirectURL)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventMobileLoginWithOAuth, model.AuditStatusFail)
auditRec.AddMeta("service", c.Params.Service)
defer c.LogAuditRec(auditRec)
// Get invite token or ID instead of team_id
tokenID := r.URL.Query().Get("t")
inviteId := r.URL.Query().Get("id")
authURL, err := c.App.GetOAuthLoginEndpoint(c.AppContext, w, r, c.Params.Service, model.OAuthActionMobile, redirectURL, "", true, "", tokenID, inviteId)
if err != nil {
c.Err = err
return
}
auditRec.Success()
c.LogAudit("success")
http.Redirect(w, r, authURL, http.StatusFound)
}
func signupWithOAuth(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireService()
if c.Err != nil {
return
}
if !*c.App.Config().TeamSettings.EnableUserCreation {
utils.RenderWebError(c.App.Config(), w, r, http.StatusBadRequest, url.Values{
"message": []string{i18n.T("api.oauth.singup_with_oauth.disabled.app_error")},
}, c.App.AsymmetricSigningKey())
return
}
auditRec := c.MakeAuditRecord(model.AuditEventSignupWithOAuth, model.AuditStatusFail)
auditRec.AddMeta("service", c.Params.Service)
defer c.LogAuditRec(auditRec)
// Get invite token or ID instead of team_id
tokenID := r.URL.Query().Get("t")
inviteId := r.URL.Query().Get("id")
desktopToken := r.URL.Query().Get("desktop_token")
authURL, err := c.App.GetOAuthSignupEndpoint(c.AppContext, w, r, c.Params.Service, desktopToken, tokenID, inviteId)
if err != nil {
c.Err = err
return
}
auditRec.Success()
c.LogAudit("success")
http.Redirect(w, r, authURL, http.StatusFound)
}
// pathWithinPrefix reports whether target is at or under prefix, comparing on
// path segments so /mmfoo is not treated as under /mm. An empty or "/" prefix
// (root SiteURL) matches the root and any absolute path.
func pathWithinPrefix(target, prefix string) bool {
target = path.Clean(target)
prefix = path.Clean(prefix)
if prefix == "." || prefix == "/" {
return target == "." || strings.HasPrefix(target, "/")
}
return target == prefix || strings.HasPrefix(target, prefix+"/")
}
func fullyQualifiedRedirectURL(siteURLPrefix, targetURL string, otherValidSchemes []string) string {
parsed, err := url.Parse(targetURL)
if err != nil {
return siteURLPrefix
}
prefixParsed, err := url.Parse(siteURLPrefix)
if err != nil {
return siteURLPrefix
}
// mobile access
if slices.Contains(otherValidSchemes, fmt.Sprintf("%v://", parsed.Scheme)) &&
parsed.Host == callbackHost &&
parsed.Path == "" &&
parsed.RawQuery == "" &&
parsed.Fragment == "" {
return targetURL
}
// Check if the targetURL is valid and within the siteURLPrefix, excluding native app schemes like mmauth://
sameScheme := parsed.Scheme == prefixParsed.Scheme
sameHost := parsed.Host == prefixParsed.Host
safePath := pathWithinPrefix(parsed.Path, prefixParsed.Path)
if sameScheme && sameHost && safePath {
return targetURL
} else if parsed.Scheme != "" || parsed.Host != "" {
return siteURLPrefix
}
// For relative URLs, normalize and join with siteURLPrefix
if targetURL != "" && targetURL[0] != '/' {
targetURL = "/" + targetURL
}
// Check for path traversal
joinedURL, err := url.JoinPath(siteURLPrefix, targetURL)
if err != nil {
return siteURLPrefix
}
unescapedURL, err := url.PathUnescape(joinedURL)
if err != nil {
return siteURLPrefix
}
parsed, err = url.Parse(unescapedURL)
if err != nil {
return siteURLPrefix
}
if !pathWithinPrefix(parsed.Path, prefixParsed.Path) {
return siteURLPrefix
}
return parsed.String()
}
func getAuthorizationServerMetadata(c *Context, w http.ResponseWriter, r *http.Request) {
if !*c.App.Config().ServiceSettings.EnableOAuthServiceProvider {
c.Err = model.NewAppError("getAuthorizationServerMetadata", "api.oauth.authorization_server_metadata.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
metadata, err := c.App.GetAuthorizationServerMetadata(c.AppContext)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(metadata); err != nil {
c.Logger.Warn("Error writing authorization server metadata response", mlog.Err(err))
}
}
// loginByIntune handles authentication using Microsoft Intune MAM with Entra ID tokens
func loginByIntune(c *Context, w http.ResponseWriter, r *http.Request) {
var req model.IntuneLoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.SetInvalidParamWithErr("request_body", err)
return
}
if req.AccessToken == "" {
c.SetInvalidParam("access_token")
return
}
if req.VoIPDeviceId != "" && !model.IsValidVoIPDeviceId(req.VoIPDeviceId) {
c.SetInvalidParam("voip_device_id")
return
}
auditRec := c.MakeAuditRecord("login_by_intune", model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
// Authenticate user via Intune MAM
user, err := c.App.LoginByIntune(c.AppContext, req.AccessToken)
if err != nil {
c.Err = err
return
}
auditRec.AddMeta("obtained_user_id", user.Id)
c.LogAuditWithUserId(user.Id, "obtained user")
model.AddEventParameterToAuditRec(auditRec, "device_id", req.DeviceId)
model.AddEventParameterToAuditRec(auditRec, "voip_device_id", req.VoIPDeviceId)
session, err := c.App.DoLogin(c.AppContext, w, r, user, model.LoginOptions{
DeviceId: req.DeviceId,
VoIPDeviceId: req.VoIPDeviceId,
IsOAuthUser: true,
})
if err != nil {
c.Err = err
return
}
c.AppContext = c.AppContext.WithSession(session)
c.App.AttachSessionCookies(c.AppContext, w, r)
auditRec.Success()
c.LogAuditWithUserId(user.Id, "success")
user.Sanitize(map[string]bool{})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(user); err != nil {
c.Logger.Warn("Failed to encode user response", mlog.Err(err))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,331 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"net/http"
"regexp"
"strconv"
"strings"
"github.com/gorilla/mux"
"github.com/mattermost/mattermost/server/public/model"
)
const (
PageDefault = 0
PerPageDefault = 60
PerPageMaximum = 200
LogsPerPageDefault = 10000
LogsPerPageMaximum = 10000
LimitDefault = 60
LimitMaximum = 200
)
type Params struct {
UserId string
OtherUserId string
TeamId string
InviteId string
TokenId string
ThreadId string
Timestamp int64
TimeRange string
ChannelId string
PostId string
PolicyId string
FileId string
Filename string
UploadId string
PluginId string
CommandId string
HookId string
ReportId string
EmojiId string
AppId string
Email string
Username string
TeamName string
ChannelName string
PreferenceName string
EmojiName string
Category string
Service string
JobId string
JobType string
RecapId string
ScheduledRecapId string
ActionId string
RoleId string
RoleName string
SchemeId string
Scope string
GroupId string
Page int
PerPage int
LogsPerPage int
Permanent bool
RemoteId string
SyncableId string
SyncableType model.GroupSyncableType
BotUserId string
Q string
IsLinked *bool
IsConfigured *bool
NotAssociatedToTeam string
NotAssociatedToChannel string
Paginate *bool
IncludeMemberCount bool
IncludeMemberIDs bool
NotAssociatedToGroup string
ExcludeDefaultChannels bool
LimitAfter int
LimitBefore int
GroupIDs string
IncludeTotalCount bool
IncludeDeleted bool
FilterAllowReference bool
FilterArchived bool
FilterParentTeamPermitted bool
CategoryId string
ExportName string
ImportName string
ExcludePolicyConstrained bool
ForDirectory bool
GroupSource model.GroupSource
FilterHasMember string
IncludeChannelMemberCount string
OutgoingOAuthConnectionID string
ExcludeOffline bool
InChannel string
NotInChannel string
Topic string
CreatorId string
OnlyConfirmed bool
OnlyPlugins bool
IncludeUnconfirmed bool
ExcludeConfirmed bool
ExcludePlugins bool
ExcludeHome bool
ExcludeRemote bool
AccessControlPolicyEnforced bool
ExcludeAccessControlPolicyEnforced bool
ContentReviewerId string
//Bookmarks
ChannelBookmarkId string
BookmarksSince int64
// Views
ViewId string
// Cloud
InvoiceId string
// Custom Profile Attributes
FieldId string
// Properties
GroupName string
ObjectType string
TargetId string
// Channel join requests
RequestId string
}
var getChannelMembersForUserRegex = regexp.MustCompile("/api/v4/users/[A-Za-z0-9]{26}/channel_members")
func ParamsFromRequest(r *http.Request) *Params {
params := &Params{}
props := mux.Vars(r)
query := r.URL.Query()
params.UserId = props["user_id"]
params.OtherUserId = props["other_user_id"]
params.TeamId = props["team_id"]
params.CategoryId = props["category_id"]
params.InviteId = props["invite_id"]
params.TokenId = props["token_id"]
params.ThreadId = props["thread_id"]
if val, ok := props["channel_id"]; ok {
params.ChannelId = val
} else {
params.ChannelId = query.Get("channel_id")
}
params.PostId = props["post_id"]
params.PolicyId = props["policy_id"]
params.FileId = props["file_id"]
params.Filename = query.Get("filename")
params.UploadId = props["upload_id"]
if val, ok := props["plugin_id"]; ok {
params.PluginId = val
} else {
params.PluginId = query.Get("plugin_id")
}
params.CommandId = props["command_id"]
params.HookId = props["hook_id"]
params.ReportId = props["report_id"]
params.EmojiId = props["emoji_id"]
params.AppId = props["app_id"]
params.Email = props["email"]
params.Username = props["username"]
params.TeamName = strings.ToLower(props["team_name"])
params.ChannelName = strings.ToLower(props["channel_name"])
params.Category = props["category"]
params.Service = props["service"]
params.PreferenceName = props["preference_name"]
params.EmojiName = props["emoji_name"]
params.JobId = props["job_id"]
params.JobType = props["job_type"]
params.RecapId = props["recap_id"]
params.ScheduledRecapId = props["scheduled_recap_id"]
params.ActionId = props["action_id"]
params.RoleId = props["role_id"]
params.RoleName = props["role_name"]
params.SchemeId = props["scheme_id"]
params.GroupId = props["group_id"]
params.RemoteId = props["remote_id"]
params.InvoiceId = props["invoice_id"]
params.OutgoingOAuthConnectionID = props["outgoing_oauth_connection_id"]
params.ExcludeOffline, _ = strconv.ParseBool(query.Get("exclude_offline"))
params.InChannel = query.Get("in_channel")
params.NotInChannel = query.Get("not_in_channel")
params.Topic = query.Get("topic")
params.CreatorId = query.Get("creator_id")
params.OnlyConfirmed, _ = strconv.ParseBool(query.Get("only_confirmed"))
params.OnlyPlugins, _ = strconv.ParseBool(query.Get("only_plugins"))
params.IncludeUnconfirmed, _ = strconv.ParseBool(query.Get("include_unconfirmed"))
params.ExcludeConfirmed, _ = strconv.ParseBool(query.Get("exclude_confirmed"))
params.ExcludePlugins, _ = strconv.ParseBool(query.Get("exclude_plugins"))
params.ExcludeHome, _ = strconv.ParseBool(query.Get("exclude_home"))
params.ExcludeRemote, _ = strconv.ParseBool(query.Get("exclude_remote"))
params.ChannelBookmarkId = props["bookmark_id"]
params.ViewId = props["view_id"]
params.FieldId = props["field_id"]
params.GroupName = props["group_name"]
params.ObjectType = props["object_type"]
params.TargetId = props["target_id"]
params.RequestId = props["request_id"]
params.Scope = query.Get("scope")
if val, err := strconv.Atoi(query.Get("page")); err != nil || (val < 0 && params.UserId == "" && !getChannelMembersForUserRegex.MatchString(r.URL.Path)) {
// We don't want to apply this logic for the getChannelMembersForUser API handler
// because that API allows page=-1 to switch to streaming mode.
params.Page = PageDefault
} else {
params.Page = val
}
if val, err := strconv.ParseInt(props["timestamp"], 10, 64); err != nil || val < 0 {
params.Timestamp = 0
} else {
params.Timestamp = val
}
params.TimeRange = query.Get("time_range")
params.Permanent, _ = strconv.ParseBool(query.Get("permanent"))
val, err := strconv.Atoi(query.Get("per_page"))
if err != nil || val < 0 {
params.PerPage = PerPageDefault
} else if val > PerPageMaximum {
params.PerPage = PerPageMaximum
} else {
params.PerPage = val
}
if val, err := strconv.Atoi(query.Get("logs_per_page")); err != nil || val < 0 {
params.LogsPerPage = LogsPerPageDefault
} else if val > LogsPerPageMaximum {
params.LogsPerPage = LogsPerPageMaximum
} else {
params.LogsPerPage = val
}
if val, err := strconv.Atoi(query.Get("limit_after")); err != nil || val < 0 {
params.LimitAfter = LimitDefault
} else if val > LimitMaximum {
params.LimitAfter = LimitMaximum
} else {
params.LimitAfter = val
}
if val, err := strconv.Atoi(query.Get("limit_before")); err != nil || val < 0 {
params.LimitBefore = LimitDefault
} else if val > LimitMaximum {
params.LimitBefore = LimitMaximum
} else {
params.LimitBefore = val
}
params.SyncableId = props["syncable_id"]
switch props["syncable_type"] {
case "teams":
params.SyncableType = model.GroupSyncableTypeTeam
case "channels":
params.SyncableType = model.GroupSyncableTypeChannel
}
params.BotUserId = props["bot_user_id"]
params.Q = query.Get("q")
if val, err := strconv.ParseBool(query.Get("is_linked")); err == nil {
params.IsLinked = &val
}
if val, err := strconv.ParseBool(query.Get("is_configured")); err == nil {
params.IsConfigured = &val
}
params.NotAssociatedToTeam = query.Get("not_associated_to_team")
params.NotAssociatedToChannel = query.Get("not_associated_to_channel")
params.FilterAllowReference, _ = strconv.ParseBool(query.Get("filter_allow_reference"))
params.FilterArchived, _ = strconv.ParseBool(query.Get("filter_archived"))
params.FilterParentTeamPermitted, _ = strconv.ParseBool(query.Get("filter_parent_team_permitted"))
params.IncludeChannelMemberCount = query.Get("include_channel_member_count")
if val, err := strconv.ParseBool(query.Get("paginate")); err == nil {
params.Paginate = &val
}
params.IncludeMemberCount, _ = strconv.ParseBool(query.Get("include_member_count"))
params.IncludeMemberIDs, _ = strconv.ParseBool(query.Get("include_member_ids"))
params.NotAssociatedToGroup = query.Get("not_associated_to_group")
params.ExcludeDefaultChannels, _ = strconv.ParseBool(query.Get("exclude_default_channels"))
params.GroupIDs = query.Get("group_ids")
params.IncludeTotalCount, _ = strconv.ParseBool(query.Get("include_total_count"))
params.IncludeDeleted, _ = strconv.ParseBool(query.Get("include_deleted"))
params.ExportName = props["export_name"]
params.ImportName = props["import_name"]
params.ExcludePolicyConstrained, _ = strconv.ParseBool(query.Get("exclude_policy_constrained"))
params.ForDirectory, _ = strconv.ParseBool(query.Get("for_directory"))
params.AccessControlPolicyEnforced, _ = strconv.ParseBool(query.Get("access_control_policy_enforced"))
params.ExcludeAccessControlPolicyEnforced, _ = strconv.ParseBool(query.Get("exclude_access_control_policy_enforced"))
params.ContentReviewerId = props["content_reviewer_id"]
if val := query.Get("group_source"); val != "" {
switch val {
case "custom":
params.GroupSource = model.GroupSourceCustom
default:
params.GroupSource = model.GroupSourceLdap
}
}
params.FilterHasMember = query.Get("filter_has_member")
if val, err := strconv.ParseInt(query.Get("bookmarks_since"), 10, 64); err != nil || val < 0 {
params.BookmarksSince = 0
} else {
params.BookmarksSince = val
}
return params
}

View File

@@ -0,0 +1,498 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"net/http"
"net/url"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestParamsFromRequest(t *testing.T) {
testCases := []struct {
Description string
URL *url.URL
Vars map[string]string
Params *Params
}{
{
"empty params",
mustURL("/"),
nil,
&Params{
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitAfter: LimitDefault,
LimitBefore: LimitDefault,
},
},
{
"query params",
mustURL("/page?" +
"channel_id=abc123&" +
"filename=file.ext&" +
"page=42&" +
"time_range=then-till-now&" +
"permanent=1&" +
"logs_per_page=5&" +
"limit_after=6&" +
"limit_before=7&" +
"q=picard&" +
"is_linked=t&" +
"is_configured=TRUE&" +
"not_associated_to_team=this_team&" +
"not_associated_to_channel=this_channel&" +
"filter_allow_reference=true&" +
"filter_parent_team_permitted=True&" +
"paginate=T&" +
"include_member_count=1&" +
"not_associated_to_group=test&" +
"exclude_default_channels=1&" +
"group_ids=hello,world&" +
"include_total_count=T&" +
"include_deleted=True&" +
"exclude_policy_constrained=TRUE&" +
"filter_has_member=xyz"),
nil,
&Params{
ChannelId: "abc123",
Filename: "file.ext",
Page: 42,
TimeRange: "then-till-now",
PerPage: PerPageDefault,
Permanent: true,
LogsPerPage: 5,
LimitAfter: 6,
LimitBefore: 7,
Q: "picard",
IsLinked: new(true),
IsConfigured: new(true),
NotAssociatedToTeam: "this_team",
NotAssociatedToChannel: "this_channel",
FilterAllowReference: true,
FilterParentTeamPermitted: true,
Paginate: new(true),
IncludeMemberCount: true,
NotAssociatedToGroup: "test",
ExcludeDefaultChannels: true,
GroupIDs: "hello,world",
IncludeTotalCount: true,
IncludeDeleted: true,
ExcludePolicyConstrained: true,
FilterHasMember: "xyz",
},
},
{
"page invalid",
mustURL("?page=hello"),
nil,
&Params{
Page: PageDefault,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitAfter: LimitDefault,
LimitBefore: LimitDefault,
},
},
{
"page negative",
mustURL("?page=-1"),
nil,
&Params{
Page: PageDefault,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitAfter: LimitDefault,
LimitBefore: LimitDefault,
},
},
{
"per page valid",
mustURL("?per_page=123"),
nil,
&Params{
PerPage: 123,
LogsPerPage: LogsPerPageDefault,
LimitAfter: LimitDefault,
LimitBefore: LimitDefault,
},
},
{
"per page too small",
mustURL("?per_page=-100"),
nil,
&Params{
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitAfter: LimitDefault,
LimitBefore: LimitDefault,
},
},
{
"per page too big",
mustURL("?per_page=100000"),
nil,
&Params{
PerPage: PerPageMaximum,
LogsPerPage: LogsPerPageDefault,
LimitAfter: LimitDefault,
LimitBefore: LimitDefault,
},
},
{
"logs per page valid",
mustURL("?logs_per_page=512"),
nil,
&Params{
LogsPerPage: 512,
PerPage: PerPageDefault,
LimitAfter: LimitDefault,
LimitBefore: LimitDefault,
},
},
{
"logs per page invalid",
mustURL("?logs_per_page=logs"),
nil,
&Params{
LogsPerPage: LogsPerPageDefault,
PerPage: PerPageDefault,
LimitAfter: LimitDefault,
LimitBefore: LimitDefault,
},
},
{
"logs per page too small",
mustURL("?logs_per_page=-512"),
nil,
&Params{
LogsPerPage: LogsPerPageDefault,
PerPage: PerPageDefault,
LimitAfter: LimitDefault,
LimitBefore: LimitDefault,
},
},
{
"logs per page too big",
mustURL("?logs_per_page=99999999"),
nil,
&Params{
LogsPerPage: LogsPerPageMaximum,
PerPage: PerPageDefault,
LimitAfter: LimitDefault,
LimitBefore: LimitDefault,
},
},
{
"limit before valid",
mustURL("?limit_before=100"),
nil,
&Params{
LimitBefore: 100,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitAfter: LimitDefault,
},
},
{
"limit before invalid",
mustURL("?limit_before=limit"),
nil,
&Params{
LimitBefore: LimitDefault,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitAfter: LimitDefault,
},
},
{
"limit before too small",
mustURL("?limit_before=-100"),
nil,
&Params{
LimitBefore: LimitDefault,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitAfter: LimitDefault,
},
},
{
"limit before too big",
mustURL("?limit_before=99999"),
nil,
&Params{
LimitBefore: LimitMaximum,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitAfter: LimitDefault,
},
},
{
"limit after valid",
mustURL("?limit_after=100"),
nil,
&Params{
LimitAfter: 100,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
},
},
{
"limit after invalid",
mustURL("?limit_after=limit"),
nil,
&Params{
LimitAfter: LimitDefault,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
},
},
{
"limit after too small",
mustURL("?limit_aftere=-100"),
nil,
&Params{
LimitAfter: LimitDefault,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
},
},
{
"limit after too big",
mustURL("?limit_after=99999"),
nil,
&Params{
LimitAfter: LimitMaximum,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
},
},
{
"group source custom",
mustURL("?group_source=custom"),
nil,
&Params{
GroupSource: model.GroupSourceCustom,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
LimitAfter: LimitDefault,
},
},
{
"group source LDAP",
mustURL("?group_source=ldap"),
nil,
&Params{
GroupSource: model.GroupSourceLdap,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
LimitAfter: LimitDefault,
},
},
{
"group source other",
mustURL("?group_source=aabbcc"),
nil,
&Params{
GroupSource: model.GroupSourceLdap,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
LimitAfter: LimitDefault,
},
},
{
"group source empty",
mustURL("?group_souce="),
nil,
&Params{
GroupSource: "",
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
LimitAfter: LimitDefault,
},
},
{
"timestamp valid",
mustURL("/"),
map[string]string{
"timestamp": "1234567",
},
&Params{
Timestamp: 1234567,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
LimitAfter: LimitDefault,
},
},
{
"timestamp valid",
mustURL("/"),
map[string]string{
"timestamp": "yes",
},
&Params{
Timestamp: 0,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
LimitAfter: LimitDefault,
},
},
{
"timestamp too small",
mustURL("/"),
map[string]string{
"timestamp": "-1234567",
},
&Params{
Timestamp: 0,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
LimitAfter: LimitDefault,
},
},
{
"syncable type teams",
mustURL("/"),
map[string]string{
"syncable_type": "teams",
},
&Params{
SyncableType: model.GroupSyncableTypeTeam,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
LimitAfter: LimitDefault,
},
},
{
"syncable type channels",
mustURL("/"),
map[string]string{
"syncable_type": "channels",
},
&Params{
SyncableType: model.GroupSyncableTypeChannel,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
LimitAfter: LimitDefault,
},
},
{
"syncable type other",
mustURL("/"),
map[string]string{
"syncable_type": "unknownvalue",
},
&Params{
SyncableType: "",
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
LimitAfter: LimitDefault,
},
},
{
"include channel bookmarks",
mustURL("/?include_bookmarks=true"),
nil,
&Params{
BookmarksSince: 0,
LimitAfter: LimitDefault,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
},
},
{
"include channel bookmarks with negative bookmark since",
mustURL("/?include_bookmarks=true&bookmarks_since=-1"),
nil,
&Params{
BookmarksSince: 0,
LimitAfter: LimitDefault,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
},
},
{
"include channel bookmarks with bookmark since",
mustURL("/?include_bookmarks=true&bookmarks_since=123456789"),
nil,
&Params{
BookmarksSince: 123456789,
LimitAfter: LimitDefault,
PerPage: PerPageDefault,
LogsPerPage: LogsPerPageDefault,
LimitBefore: LimitDefault,
},
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
t.Parallel()
r := &http.Request{URL: testCase.URL}
r = mux.SetURLVars(r, testCase.Vars)
params := ParamsFromRequest(r)
require.Equal(t, testCase.Params, params)
})
}
}
func mustURL(u string) *url.URL {
parsed, err := url.Parse(u)
if err != nil {
panic(err)
}
return parsed
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"bufio"
"errors"
"net"
"net/http"
)
type responseWriterWrapper struct {
http.ResponseWriter
statusCode int
statusCodeWritten bool
hijacker http.Hijacker
flusher http.Flusher
}
func newWrappedWriter(original http.ResponseWriter) *responseWriterWrapper {
hijacker, _ := original.(http.Hijacker)
flusher, _ := original.(http.Flusher)
return &responseWriterWrapper{
ResponseWriter: original,
statusCodeWritten: false,
hijacker: hijacker,
flusher: flusher,
}
}
func (rw *responseWriterWrapper) StatusCode() int {
return rw.statusCode
}
func (rw *responseWriterWrapper) WriteHeader(statusCode int) {
rw.statusCode = statusCode
rw.statusCodeWritten = true
rw.ResponseWriter.WriteHeader(statusCode)
}
func (rw *responseWriterWrapper) Write(data []byte) (int, error) {
if !rw.statusCodeWritten {
rw.statusCode = http.StatusOK
}
return rw.ResponseWriter.Write(data)
}
// Using as embedded makes the ResponseWrite be stored as interface and that way
// it loses the access to the implementation for Hijack or Flush
func (rw *responseWriterWrapper) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if rw.hijacker == nil {
return nil, nil, errors.New("Hijacker interface not supported by the wrapped ResponseWriter")
}
return rw.hijacker.Hijack()
}
func (rw *responseWriterWrapper) Flush() {
if rw.flusher != nil {
rw.flusher.Flush()
}
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"bufio"
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type TestHandler struct {
TestFunc func(w http.ResponseWriter, r *http.Request)
}
func (h *TestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.TestFunc(w, r)
}
type responseRecorderHijack struct {
httptest.ResponseRecorder
}
func (r *responseRecorderHijack) Hijack() (net.Conn, *bufio.ReadWriter, error) {
r.WriteHeader(http.StatusOK)
return nil, nil, nil
}
func newResponseWithHijack(original *httptest.ResponseRecorder) *responseRecorderHijack {
return &responseRecorderHijack{*original}
}
func TestStatusCodeIsAccessible(t *testing.T) {
resp := newWrappedWriter(httptest.NewRecorder())
req := httptest.NewRequest("GET", "/api/v4/test", nil)
handler := TestHandler{func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}}
handler.ServeHTTP(resp, req)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode())
}
func TestStatusCodeShouldBe200IfNotHeaderWritten(t *testing.T) {
resp := newWrappedWriter(httptest.NewRecorder())
req := httptest.NewRequest("GET", "/api/v4/test", nil)
handler := TestHandler{func(w http.ResponseWriter, r *http.Request) {
n, err := w.Write([]byte{})
require.NoError(t, err, "Failed to write response")
require.Equal(t, 0, n, "Expected to write 0 bytes")
}}
handler.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.StatusCode())
}
func TestForUnsupportedHijack(t *testing.T) {
resp := newWrappedWriter(httptest.NewRecorder())
req := httptest.NewRequest("GET", "/api/v4/test", nil)
handler := TestHandler{func(w http.ResponseWriter, r *http.Request) {
conn, rw, err := w.(*responseWriterWrapper).Hijack()
assert.Error(t, err)
assert.Equal(t, "Hijacker interface not supported by the wrapped ResponseWriter", err.Error())
assert.Nil(t, conn, "Expected nil connection")
assert.Nil(t, rw, "Expected nil buffer")
}}
handler.ServeHTTP(resp, req)
}
func TestForSupportedHijack(t *testing.T) {
resp := newWrappedWriter(newResponseWithHijack(httptest.NewRecorder()))
req := httptest.NewRequest("GET", "/api/v4/test", nil)
handler := TestHandler{func(w http.ResponseWriter, r *http.Request) {
conn, rw, err := w.(*responseWriterWrapper).Hijack()
require.NoError(t, err, "Hijack should succeed with supporting ResponseWriter")
assert.Nil(t, conn, "Expected nil connection from test implementation")
assert.Nil(t, rw, "Expected nil buffer from test implementation")
}}
handler.ServeHTTP(resp, req)
}
func TestForSupportedFlush(t *testing.T) {
resp := newWrappedWriter(httptest.NewRecorder())
req := httptest.NewRequest("GET", "/api/v4/test", nil)
handler := TestHandler{func(w http.ResponseWriter, r *http.Request) {
n, err := w.Write([]byte{})
require.NoError(t, err, "Failed to write response")
require.Equal(t, 0, n, "Expected to write 0 bytes")
w.(*responseWriterWrapper).Flush()
}}
handler.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.StatusCode())
}

318
server/channels/web/saml.go Normal file
View File

@@ -0,0 +1,318 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"html"
"net/http"
"strconv"
"strings"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
const maxSAMLResponseSize = 2 * 1024 * 1024 // 2MB
// maxRelayStatePropsSize is a sanity bound on the signed relayProps payload size. It isn't tied
// to any storage constraint (RelayState is no longer persisted) - it just keeps a maliciously
// large redirect_to from bloating the RelayState round-tripped through the IdP indefinitely.
const maxRelayStatePropsSize = 4096
func (w *Web) InitSaml() {
w.MainRouter.Handle("/login/sso/saml", w.APIHandler(loginWithSaml)).Methods(http.MethodGet)
w.MainRouter.Handle("/login/sso/saml", w.APIHandlerTrustRequester(completeSaml)).Methods(http.MethodPost)
}
func loginWithSaml(c *Context, w http.ResponseWriter, r *http.Request) {
samlInterface := c.App.Saml()
if samlInterface == nil {
c.Err = model.NewAppError("loginWithSaml", "api.user.saml.not_available.app_error", nil, "", http.StatusFound)
return
}
tokenID := r.URL.Query().Get("t")
inviteId := r.URL.Query().Get("id")
action := r.URL.Query().Get("action")
isMobile := action == model.OAuthActionMobile
redirectURL := html.EscapeString(r.URL.Query().Get("redirect_to"))
// Optional SAML challenge parameters for mobile code-exchange
state := r.URL.Query().Get("state")
codeChallenge := r.URL.Query().Get("code_challenge")
codeChallengeMethod := r.URL.Query().Get("code_challenge_method")
relayProps := map[string]string{}
relayState := ""
if action != "" {
if tokenID != "" {
relayProps["invite_token"] = tokenID
} else if inviteId != "" {
relayProps["invite_id"] = inviteId
}
relayProps["action"] = action
if action == model.OAuthActionEmailToSSO {
relayProps["email_token"] = r.URL.Query().Get("email_token")
}
}
if redirectURL != "" {
if isMobile && !utils.IsValidMobileAuthRedirectURL(c.App.Config(), redirectURL) {
invalidSchemeErr := model.NewAppError("loginWithOAuth", "api.invalid_custom_url_scheme", nil, "", http.StatusBadRequest)
utils.RenderMobileError(c.App.Config(), w, invalidSchemeErr, redirectURL)
return
}
relayProps["redirect_to"] = redirectURL
}
// Forward SAML challenge values via RelayState so the complete step can prefer code-exchange
if isMobile {
if state != "" {
relayProps["state"] = state
}
if codeChallenge != "" {
relayProps["code_challenge"] = codeChallenge
}
if codeChallengeMethod != "" {
relayProps["code_challenge_method"] = codeChallengeMethod
}
}
desktopToken := r.URL.Query().Get("desktop_token")
if desktopToken != "" {
relayProps["desktop_token"] = desktopToken
}
relayProps[model.UserAuthServiceIsMobile] = strconv.FormatBool(isMobile)
if len(relayProps) > 0 {
if size := len(model.MapToJSON(relayProps)); size > maxRelayStatePropsSize {
c.Err = model.NewAppError("loginWithSaml", "api.user.saml.relay_state_too_long.app_error", nil, "", http.StatusBadRequest)
return
}
relayState = model.SignSamlRelayState(c.App.SamlRelayStateSigningKey(), relayProps)
}
data, err := samlInterface.BuildRequest(c.AppContext, relayState)
if err != nil {
c.Err = err
return
}
w.Header().Set("Content-Type", "application/x-www-form-urlencoded")
http.Redirect(w, r, data.URL, http.StatusFound)
}
func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) {
samlInterface := c.App.Saml()
if samlInterface == nil {
c.Err = model.NewAppError("completeSaml", "api.user.saml.not_available.app_error", nil, "", http.StatusFound)
return
}
// Validate that the user is with SAML and all that
encodedXML := r.FormValue("SAMLResponse")
relayState := r.FormValue("RelayState")
relayProps := make(map[string]string)
if relayState != "" {
props, err := model.VerifySamlRelayState(c.App.SamlRelayStateSigningKey(), relayState)
if err != nil {
c.Err = model.NewAppError("completeSaml", "api.user.authorize_oauth_user.invalid_state.app_error", nil, "", http.StatusFound).Wrap(err)
return
}
relayProps = props
}
auditRec := c.MakeAuditRecord(model.AuditEventCompleteSaml, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
action := relayProps["action"]
auditRec.AddMeta("action", action)
isMobile := action == model.OAuthActionMobile
redirectURL := ""
hasRedirectURL := false
if val, ok := relayProps["redirect_to"]; ok {
redirectURL = val
hasRedirectURL = val != ""
}
redirectURL = fullyQualifiedRedirectURL(c.GetSiteURLHeader(), redirectURL, c.App.Config().NativeAppSettings.AppCustomURLSchemes)
handleError := func(err *model.AppError) {
if isMobile && hasRedirectURL {
err.Translate(c.AppContext.T)
utils.RenderMobileError(c.App.Config(), w, err, redirectURL)
} else {
c.Err = err
c.Err.StatusCode = http.StatusFound
}
}
if len(encodedXML) > maxSAMLResponseSize {
err := model.NewAppError("completeSaml", "api.user.authorize_oauth_user.saml_response_too_long.app_error", nil, "SAML response is too long", http.StatusBadRequest)
handleError(err)
return
}
user, assertion, err := samlInterface.DoLogin(c.AppContext, encodedXML, relayProps)
if err != nil {
c.LogAudit("fail")
handleError(err)
return
}
err = c.App.CheckUserAllAuthenticationCriteria(c.AppContext, user, "")
if err != nil {
handleError(err)
return
}
switch action {
case model.OAuthActionSignup:
inviteToken := relayProps["invite_token"]
inviteId := relayProps["invite_id"]
if err = c.App.AddUserToTeamByInviteIfNeeded(c.AppContext, user, inviteToken, inviteId); err != nil {
c.LogErrorByCode(err)
break
}
case model.OAuthActionEmailToSSO:
if err = c.App.RevokeAllSessions(c.AppContext, user.Id); err != nil {
c.Err = err
return
}
auditRec.AddMeta("revoked_user_id", user.Id)
auditRec.AddMeta("revoked", "Revoked all sessions for user")
c.LogAuditWithUserId(user.Id, "Revoked all sessions for user")
c.App.Srv().Go(func() {
if err := c.App.Srv().EmailService.SendSignInChangeEmail(user.Email, strings.Title(model.UserAuthServiceSaml)+" SSO", user.Locale, c.App.GetSiteURL()); err != nil {
c.LogErrorByCode(model.NewAppError("SendSignInChangeEmail", "api.user.send_sign_in_change_email_and_forget.error", nil, "", http.StatusInternalServerError).Wrap(err))
}
})
}
pluginContext := &plugin.Context{
RequestId: c.AppContext.RequestId(),
SessionId: c.AppContext.Session().Id,
IPAddress: c.AppContext.IPAddress(),
AcceptLanguage: c.AppContext.AcceptLanguage(),
UserAgent: c.AppContext.UserAgent(),
}
var hookErr error
c.App.Channels().RunMultiHook(func(hooks plugin.Hooks, manifest *model.Manifest) bool {
hookErr = hooks.OnSAMLLogin(pluginContext, user, assertion)
return hookErr == nil
}, plugin.OnSAMLLoginID)
if hookErr != nil {
handleError(model.NewAppError("completeSaml", "api.user.authorize_oauth_user.saml_hook_error.app_error", nil, "", http.StatusInternalServerError).Wrap(hookErr))
return
}
auditRec.AddMeta("obtained_user_id", user.Id)
auditRec.AddMeta("obtained_user_email", user.Email)
c.LogAuditWithUserId(user.Id, "obtained user")
desktopToken := relayProps["desktop_token"]
// If it's a desktop login we generate a token and redirect to another endpoint to handle session creation
if desktopToken != "" {
serverToken, serverTokenErr := c.App.GenerateAndSaveDesktopToken(time.Now().Unix(), user)
if serverTokenErr != nil {
handleError(serverTokenErr)
return
}
queryString := map[string]string{
"client_token": desktopToken,
"server_token": *serverToken,
}
if val, ok := relayProps["redirect_to"]; ok {
queryString["redirect_to"] = val
}
if strings.HasPrefix(desktopToken, "dev-") {
queryString["isDesktopDev"] = "true"
}
redirectURL = utils.AppendQueryParamsToURL(c.GetSiteURLHeader()+"/login/desktop", queryString)
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
// Decide between legacy token-in-URL vs SAML code-exchange for mobile
samlState := relayProps["state"]
samlChallenge := relayProps["code_challenge"]
samlMethod := relayProps["code_challenge_method"]
if isMobile && hasRedirectURL && samlChallenge != "" && c.App.Config().FeatureFlags.MobileSSOCodeExchange {
// Issue one-time login_code bound to user and SAML challenge values; do not create a session here
extra := model.MapToJSON(map[string]string{
"user_id": user.Id,
"state": samlState,
"code_challenge": samlChallenge,
"code_challenge_method": samlMethod,
})
var code *model.Token
code, err = c.App.CreateSamlRelayToken(model.TokenTypeSSOCodeExchange, extra)
if err != nil {
handleError(model.NewAppError("completeSaml", "app.recover.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err))
return
}
redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{
"login_code": code.Token,
"srv": c.App.GetSiteURL(), // Server URL for mobile client verification
})
utils.RenderMobileAuthComplete(w, redirectURL)
return
}
// Legacy: create a session and attach tokens (web/mobile without SAML code exchange)
session, err := c.App.DoLogin(c.AppContext, w, r, user, model.LoginOptions{
IsMobile: isMobile,
IsSaml: true,
})
if err != nil {
handleError(err)
return
}
c.AppContext = c.AppContext.WithSession(session)
auditRec.Success()
c.LogAuditWithUserId(user.Id, "success")
c.App.AttachSessionCookies(c.AppContext, w, r)
if hasRedirectURL {
if isMobile {
// Mobile clients with redirect url support
// Always add tokens for mobile in legacy path (we only reach here if code-exchange was skipped)
redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{
model.SessionCookieToken: c.AppContext.Session().Token,
model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(),
"srv": c.App.GetSiteURL(), // Server URL for mobile client verification (config-based, not request Host)
})
utils.RenderMobileAuthComplete(w, redirectURL)
} else {
http.Redirect(w, r, redirectURL, http.StatusFound)
}
return
}
switch action {
// Mobile clients with web view implementation
case model.OAuthActionMobile:
ReturnStatusOK(w)
case model.OAuthActionEmailToSSO:
http.Redirect(w, r, c.GetSiteURLHeader()+"/login?extra=signin_change", http.StatusFound)
default:
http.Redirect(w, r, c.GetSiteURLHeader(), http.StatusFound)
}
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
saml2 "github.com/mattermost/gosaml2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/einterfaces"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
// TestSamlCallbackIncludesSrvParameter verifies that mobile SAML callbacks
// include the 'srv' parameter for origin verification
func TestSamlCallbackIncludesSrvParameter(t *testing.T) {
// The 'srv' parameter is added to mobile callbacks to allow the client
// to verify the server origin
t.Run("srv parameter should be included in redirect URL construction", func(t *testing.T) {
// Verify the pattern: when we construct a redirect URL for mobile,
// it should include "srv" parameter with the server's site URL
siteURL := "https://mattermost.example.com"
sessionToken := "test-session-token"
csrfToken := "test-csrf-token"
// Simulate what the code does when constructing the callback
params := map[string]string{
model.SessionCookieToken: sessionToken,
model.SessionCookieCsrf: csrfToken,
"srv": siteURL,
}
// Verify all expected parameters are present
assert.Equal(t, sessionToken, params[model.SessionCookieToken])
assert.Equal(t, csrfToken, params[model.SessionCookieCsrf])
assert.Equal(t, siteURL, params["srv"])
})
t.Run("srv parameter detects server mismatch", func(t *testing.T) {
// Scenario: The srv parameter from callback doesn't match expected server
// Mobile should detect the mismatch
expectedServer := "https://server-a.example.com"
actualSrvFromCallback := "https://server-b.example.com"
// This is the check that should happen in mobile
isMismatch := expectedServer != actualSrvFromCallback
assert.True(t, isMismatch, "Should detect server mismatch")
})
t.Run("srv parameter allows legitimate login", func(t *testing.T) {
// Scenario: Normal login to legitimate server
// Server adds srv=server.com to callback
// Mobile verifies: expected == srv
expectedServer := "https://server.example.com"
actualSrvFromCallback := "https://server.example.com"
// This is the check that should happen in mobile
isLegitimate := expectedServer == actualSrvFromCallback
assert.True(t, isLegitimate, "Should allow legitimate login")
})
}
// registerFakeSamlInterface installs a mocked SamlInterface before the server is created,
// mirroring the pattern in channels/app/enterprise_test.go. Must be called before Setup(t).
func registerFakeSamlInterface(t *testing.T) *mocks.SamlInterface {
t.Helper()
fakeSaml := &mocks.SamlInterface{}
fakeSaml.On("ConfigureSP", mock.Anything).Return(nil)
app.RegisterSamlInterface(func(a *app.App) einterfaces.SamlInterface {
return fakeSaml
})
t.Cleanup(func() { app.RegisterSamlInterface(nil) })
return fakeSaml
}
func postCompleteSaml(t *testing.T, th *TestHelper, samlResponse, relayState string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{}
form.Set("SAMLResponse", samlResponse)
form.Set("RelayState", relayState)
req := httptest.NewRequest(http.MethodPost, "/login/sso/saml", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res := httptest.NewRecorder()
th.Web.MainRouter.ServeHTTP(res, req)
return res
}
// TestCompleteSamlRelayStateRejectsForgedRelayState verifies that completeSaml rejects the
// pre-fix RelayState format (plain base64(JSON), no signature - the shape of the MM-69889
// forged team_id/invite_id attack) as well as arbitrary tampered/malformed strings, since
// RelayState must now carry a valid HMAC signature produced by the server.
func TestCompleteSamlRelayStateRejectsForgedRelayState(t *testing.T) {
fakeSaml := registerFakeSamlInterface(t)
th := Setup(t)
t.Run("legacy base64-encoded relayProps blob is rejected", func(t *testing.T) {
forgedRelayProps := map[string]string{
"action": model.OAuthActionSignup,
"team_id": "forged-team-id",
}
forgedRelayState := base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(forgedRelayProps)))
res := postCompleteSaml(t, th, "dummy-encoded-xml", forgedRelayState)
assert.Equal(t, http.StatusFound, res.Code)
fakeSaml.AssertNotCalled(t, "DoLogin", mock.Anything, mock.Anything, mock.Anything)
})
t.Run("arbitrary tampered string is rejected", func(t *testing.T) {
res := postCompleteSaml(t, th, "dummy-encoded-xml", "not-a-real-relay-state")
assert.Equal(t, http.StatusFound, res.Code)
fakeSaml.AssertNotCalled(t, "DoLogin", mock.Anything, mock.Anything, mock.Anything)
})
t.Run("validly-formatted but tampered payload is rejected", func(t *testing.T) {
signed := model.SignSamlRelayState(th.App.SamlRelayStateSigningKey(), map[string]string{
"action": model.OAuthActionSignup,
"team_id": "legit-team-id",
})
payloadPart, sigPart, ok := strings.Cut(signed, ".")
require.True(t, ok)
payload, err := base64.RawURLEncoding.DecodeString(payloadPart)
require.NoError(t, err)
tampered := strings.Replace(string(payload), "legit-team-id", "forged-team-id", 1)
require.NotEqual(t, string(payload), tampered)
forgedRelayState := base64.RawURLEncoding.EncodeToString([]byte(tampered)) + "." + sigPart
res := postCompleteSaml(t, th, "dummy-encoded-xml", forgedRelayState)
assert.Equal(t, http.StatusFound, res.Code)
fakeSaml.AssertNotCalled(t, "DoLogin", mock.Anything, mock.Anything, mock.Anything)
})
}
// TestCompleteSamlRelayStateSignedRoundTrip verifies the fixed flow end-to-end: loginWithSaml
// signs relayProps into an opaque RelayState, and completeSaml verifies the signature and
// recovers the original relayProps.
func TestCompleteSamlRelayStateSignedRoundTrip(t *testing.T) {
fakeSaml := registerFakeSamlInterface(t)
th := Setup(t).InitBasic(t)
var capturedRelayState string
fakeSaml.On("BuildRequest", mock.Anything, mock.AnythingOfType("string")).
Run(func(args mock.Arguments) { capturedRelayState = args.String(1) }).
Return(&model.SamlAuthRequest{URL: "https://idp.example.com/sso"}, nil)
req := httptest.NewRequest(http.MethodGet, "/login/sso/saml?action=login&id=inviteABC", nil)
res := httptest.NewRecorder()
th.Web.MainRouter.ServeHTTP(res, req)
require.Equal(t, http.StatusFound, res.Code)
// RelayState on the wire must be payload.signature - exactly one separator, both halves
// valid base64url.
require.NotEmpty(t, capturedRelayState)
payloadPart, sigPart, ok := strings.Cut(capturedRelayState, ".")
require.True(t, ok)
assert.False(t, strings.Contains(sigPart, "."))
_, err := base64.RawURLEncoding.DecodeString(payloadPart)
assert.NoError(t, err)
_, err = base64.RawURLEncoding.DecodeString(sigPart)
assert.NoError(t, err)
var capturedRelayProps map[string]string
fakeSaml.On("DoLogin", mock.Anything, mock.Anything, mock.AnythingOfType("map[string]string")).
Run(func(args mock.Arguments) { capturedRelayProps = args.Get(2).(map[string]string) }).
Return(th.BasicUser, (*saml2.AssertionInfo)(nil), nil)
res = postCompleteSaml(t, th, "dummy-encoded-xml", capturedRelayState)
assert.Equal(t, http.StatusFound, res.Code)
assert.Equal(t, model.OAuthActionLogin, capturedRelayProps["action"])
assert.Equal(t, "inviteABC", capturedRelayProps["invite_id"])
// The internal expiry field is bookkeeping only and must not leak into relayProps.
_, hasExp := capturedRelayProps["exp"]
assert.False(t, hasExp)
// A signed RelayState is not single-use: replaying it within its expiry window succeeds
// again. Restricting the replay window to a few minutes (rather than eliminating replay
// entirely) is an intentional, low-cost defense-in-depth measure requested by the security
// team, on top of the fact that no RelayState field is trusted without independent
// revalidation at the point it's consumed (invite validity, redirect_to scheme/host, etc.).
res = postCompleteSaml(t, th, "dummy-encoded-xml", capturedRelayState)
assert.Equal(t, http.StatusFound, res.Code)
fakeSaml.AssertNumberOfCalls(t, "DoLogin", 2)
}

View File

@@ -0,0 +1,221 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"bytes"
"fmt"
"html"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"github.com/klauspost/compress/gzhttp"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
)
var robotsTxt = []byte("User-agent: *\nDisallow: /\n")
func (w *Web) InitStatic() {
if *w.srv.Config().ServiceSettings.WebserverMode != "disabled" {
if err := utils.UpdateAssetsSubpathFromConfig(w.srv.Config()); err != nil {
mlog.Error("Failed to update assets subpath from config", mlog.Err(err))
}
staticDir, _ := fileutils.FindDir(model.ClientDir)
mlog.Debug("Using client directory", mlog.String("client_dir", staticDir))
subpath, _ := utils.GetSubpathFromConfig(w.srv.Config())
staticHandler := staticFilesHandler(http.StripPrefix(path.Join(subpath, "static"), http.FileServer(http.Dir(staticDir))))
pluginHandler := staticFilesHandler(http.StripPrefix(path.Join(subpath, "static", "plugins"), http.FileServer(http.Dir(*w.srv.Config().PluginSettings.ClientDirectory))))
if *w.srv.Config().ServiceSettings.WebserverMode == "gzip" {
staticHandler = gzhttp.GzipHandler(staticHandler)
pluginHandler = gzhttp.GzipHandler(pluginHandler)
}
w.MainRouter.PathPrefix("/static/plugins/").Handler(pluginHandler)
w.MainRouter.PathPrefix("/static/").Handler(staticHandler)
w.MainRouter.Handle("/robots.txt", http.HandlerFunc(robotsHandler))
w.MainRouter.Handle("/unsupported_browser.js", http.HandlerFunc(unsupportedBrowserScriptHandler))
w.MainRouter.Handle("/{anything:.*}", w.NewStaticHandler(root)).Methods(http.MethodGet, http.MethodHead)
// When a subpath is defined, it's necessary to handle redirects without a
// trailing slash. We don't want to use StrictSlash on the w.MainRouter and affect
// all routes, just /subpath -> /subpath/.
w.MainRouter.HandleFunc("", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.URL.Path += "/"
http.Redirect(w, r, r.URL.String(), http.StatusFound)
}))
}
}
func root(c *Context, w http.ResponseWriter, r *http.Request) {
if !CheckClientCompatibility(r.UserAgent()) {
w.Header().Set("Cache-Control", "no-store")
subpath, _ := utils.GetSubpathFromConfig(c.App.Srv().Config())
data := renderUnsupportedBrowser(c.AppContext, r, subpath)
err := c.App.Srv().TemplatesContainer().Render(w, "unsupported_browser", data)
if err != nil {
c.Logger.Error("Failed to render template", mlog.Err(err))
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
return
}
if !CheckDesktopAppCompatibility(r.UserAgent(), c.App.Srv().Config().ServiceSettings.MinimumDesktopAppVersion) {
w.Header().Set("Cache-Control", "no-store")
currentVersion, ok := app.GetDesktopAppVersion(r.UserAgent())
if !ok {
currentVersion = "unknown"
}
cfg := c.App.Srv().Config()
subpath, _ := utils.GetSubpathFromConfig(cfg)
data := renderUnsupportedDesktopApp(c.AppContext, cfg, currentVersion, subpath)
err := c.App.Srv().TemplatesContainer().Render(w, "unsupported_desktop_app", data)
if err != nil {
c.Logger.Error("Failed to render template", mlog.Err(err))
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
return
}
if IsAPICall(c.App, r) {
Handle404(c.App, w, r)
return
}
w.Header().Set("Cache-Control", "no-cache, max-age=31556926, public")
staticDir, _ := fileutils.FindDir(model.ClientDir)
contents, err := os.ReadFile(filepath.Join(staticDir, "root.html"))
if err != nil {
c.Logger.Warn("Failed to read content from file",
mlog.String("file_path", filepath.Join(staticDir, "root.html")),
mlog.Err(err))
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
titleTemplate := "<title>%s</title>"
originalHTML := fmt.Sprintf(titleTemplate, html.EscapeString(model.TeamSettingsDefaultSiteName))
modifiedHTML := getOpenGraphMetaTags(c)
if originalHTML != modifiedHTML {
contents = bytes.ReplaceAll(contents, []byte(originalHTML), []byte(modifiedHTML))
}
w.Header().Set("Content-Type", "text/html")
if _, err = w.Write(contents); err != nil {
c.Logger.Warn("Failed to write content to HTTP reply", mlog.Err(err))
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func staticFilesHandler(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//wrap our ResponseWriter with our no-cache 404-handler
w = &notFoundNoCacheResponseWriter{ResponseWriter: w}
if path.Base(r.URL.Path) == "remote_entry.js" {
w.Header().Set("Cache-Control", "no-cache, max-age=31556926, public")
} else {
w.Header().Set("Cache-Control", "max-age=31556926, public")
}
// 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 strings.HasSuffix(r.URL.Path, "/") {
http.NotFound(w, r)
return
}
handler.ServeHTTP(w, r)
})
}
type notFoundNoCacheResponseWriter struct {
http.ResponseWriter
}
func (w *notFoundNoCacheResponseWriter) WriteHeader(statusCode int) {
if statusCode == http.StatusNotFound {
// we have a 404, update our cache header first then fall through
w.Header().Set("Cache-Control", "no-cache, public")
}
w.ResponseWriter.WriteHeader(statusCode)
}
func robotsHandler(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/") {
http.NotFound(w, r)
return
}
if _, err := w.Write(robotsTxt); err != nil {
mlog.Warn("Failed to write robots.txt", mlog.Err(err))
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func unsupportedBrowserScriptHandler(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/") {
http.NotFound(w, r)
return
}
templatesDir, _ := templates.GetTemplateDirectory()
http.ServeFile(w, r, filepath.Join(templatesDir, "unsupported_browser.js"))
}
func ensureTrailingSlash(s string) string {
if s == "" {
return "/"
}
if !strings.HasSuffix(s, "/") {
return s + "/"
}
return s
}
func getOpenGraphMetaTags(c *Context) string {
siteName := model.TeamSettingsDefaultSiteName
customSiteName := c.App.Srv().Config().TeamSettings.SiteName
if customSiteName != nil && *customSiteName != "" {
siteName = *customSiteName
}
siteDescription := model.TeamSettingsDefaultCustomDescriptionText
customSiteDescription := c.App.Srv().Config().TeamSettings.CustomDescriptionText
if customSiteDescription != nil && *customSiteDescription != "" {
siteDescription = *customSiteDescription
}
titleTemplate := "<title>%s</title>"
titleHTML := fmt.Sprintf(titleTemplate, html.EscapeString(siteName))
descriptionHTML := ""
if siteDescription != "" {
descriptionTemplate := "<meta property=\"og:description\" content=\"%s\" />"
descriptionHTML = fmt.Sprintf(descriptionTemplate, html.EscapeString(siteDescription))
}
return titleHTML + descriptionHTML
}

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"net/http"
"github.com/avct/uasurfer"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
)
// MattermostApp describes downloads for the Mattermost App
type MattermostApp struct {
LogoSrc string
Title string
SupportedVersionString string
Label string
Link string
InstallGuide string
InstallGuideLink string
}
// Browser describes a browser with a download link
type Browser struct {
LogoSrc string
Title string
SupportedVersionString string
Src string
GetLatestString string
}
// SystemBrowser describes a browser but includes 2 links: one to open the local browser, and one to make it default
type SystemBrowser struct {
LogoSrc string
Title string
SupportedVersionString string
LabelOpen string
LinkOpen string
LinkMakeDefault string
OrString string
MakeDefaultString string
}
func renderUnsupportedBrowser(rctx request.CTX, r *http.Request, subpath string) templates.Data {
data := templates.Data{
Props: map[string]any{
"Subpath": ensureTrailingSlash(subpath),
"DownloadAppOrUpgradeBrowserString": rctx.T("web.error.unsupported_browser.download_app_or_upgrade_browser"),
"LearnMoreString": rctx.T("web.error.unsupported_browser.learn_more"),
},
}
// User Agent info
ua := uasurfer.Parse(r.UserAgent())
isWindows := ua.OS.Platform.String() == "PlatformWindows"
isWindows10 := isWindows && ua.OS.Version.Major == 10
isMacOSX := ua.OS.Name.String() == "OSMacOSX" && ua.OS.Version.Major == 10
isSafari := ua.Browser.Name.String() == "BrowserSafari"
// Basic heading translations
if isSafari {
data.Props["NoLongerSupportString"] = rctx.T("web.error.unsupported_browser.no_longer_support_version")
} else {
data.Props["NoLongerSupportString"] = rctx.T("web.error.unsupported_browser.no_longer_support")
}
// Mattermost app version
if isWindows {
data.Props["App"] = renderMattermostAppWindows(rctx)
} else if isMacOSX {
data.Props["App"] = renderMattermostAppMac(rctx)
}
// Browsers to download
// Show a link to Safari if you're using safari and it's outdated
// Can't show on Mac all the time because there's no way to open it via URI
browsers := []Browser{renderBrowserChrome(rctx), renderBrowserFirefox(rctx)}
if isSafari {
browsers = append(browsers, renderBrowserSafari(rctx))
}
data.Props["Browsers"] = browsers
// If on Windows 10, show link to Edge
if isWindows10 {
data.Props["SystemBrowser"] = renderSystemBrowserEdge(rctx, r)
}
return data
}
func renderMattermostAppMac(rctx request.CTX) MattermostApp {
return MattermostApp{
"mac.png",
rctx.T("web.error.unsupported_browser.download_the_app"),
rctx.T("web.error.unsupported_browser.min_os_version.mac"),
rctx.T("web.error.unsupported_browser.download"),
"https://mattermost.com/pl/download-apps",
rctx.T("web.error.unsupported_browser.install_guide.mac"),
"https://docs.mattermost.com/install/desktop.html#mac-os-x-10-9",
}
}
func renderMattermostAppWindows(rctx request.CTX) MattermostApp {
return MattermostApp{
"windows.svg",
rctx.T("web.error.unsupported_browser.download_the_app"),
rctx.T("web.error.unsupported_browser.min_os_version.windows"),
rctx.T("web.error.unsupported_browser.download"),
"https://mattermost.com/pl/download-apps",
rctx.T("web.error.unsupported_browser.install_guide.windows"),
"https://docs.mattermost.com/install/desktop.html#windows-10-windows-8-1-windows-7",
}
}
func renderBrowserChrome(rctx request.CTX) Browser {
return Browser{
"chrome.svg",
rctx.T("web.error.unsupported_browser.browser_title.chrome"),
rctx.T("web.error.unsupported_browser.min_browser_version.chrome"),
"http://www.google.com/chrome",
rctx.T("web.error.unsupported_browser.browser_get_latest.chrome"),
}
}
func renderBrowserFirefox(rctx request.CTX) Browser {
return Browser{
"firefox.svg",
rctx.T("web.error.unsupported_browser.browser_title.firefox"),
rctx.T("web.error.unsupported_browser.min_browser_version.firefox"),
"https://www.mozilla.org/firefox/new/",
rctx.T("web.error.unsupported_browser.browser_get_latest.firefox"),
}
}
func renderBrowserSafari(rctx request.CTX) Browser {
return Browser{
"safari.svg",
rctx.T("web.error.unsupported_browser.browser_title.safari"),
rctx.T("web.error.unsupported_browser.min_browser_version.safari"),
"macappstore://showUpdatesPage",
rctx.T("web.error.unsupported_browser.browser_get_latest.safari"),
}
}
func renderSystemBrowserEdge(rctx request.CTX, r *http.Request) SystemBrowser {
return SystemBrowser{
"edge.svg",
rctx.T("web.error.unsupported_browser.browser_title.edge"),
rctx.T("web.error.unsupported_browser.min_browser_version.edge"),
rctx.T("web.error.unsupported_browser.open_system_browser.edge"),
"microsoft-edge:http://" + r.Host + r.RequestURI, //TODO: Can we get HTTP or HTTPS? If someone's server doesn't have a redirect this won't work
"ms-settings:defaultapps",
rctx.T("web.error.unsupported_browser.system_browser_or"),
rctx.T("web.error.unsupported_browser.system_browser_make_default"),
}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
)
func renderUnsupportedDesktopApp(rctx request.CTX, cfg *model.Config, currentVersion, subpath string) templates.Data {
return templates.Data{
Props: map[string]any{
"Subpath": ensureTrailingSlash(subpath),
"Title": rctx.T("web.error.unsupported_desktop_app.title"),
"MessageString": rctx.T("web.error.unsupported_desktop_app.message", map[string]any{
"SiteName": *cfg.TeamSettings.SiteName,
"CurrentVersion": currentVersion,
"MinimumVersion": *cfg.ServiceSettings.MinimumDesktopAppVersion,
}),
"DownloadButtonLabel": rctx.T("web.error.unsupported_desktop_app.download_button"),
"AssistanceString": rctx.T("web.error.unsupported_desktop_app.assistance"),
"FooterAboutLabel": rctx.T("web.error.unsupported_desktop_app.footer_about"),
"FooterPrivacyLabel": rctx.T("web.error.unsupported_desktop_app.footer_privacy"),
"FooterTermsLabel": rctx.T("web.error.unsupported_desktop_app.footer_terms"),
"FooterHelpLabel": rctx.T("web.error.unsupported_desktop_app.footer_help"),
"DownloadLink": *cfg.NativeAppSettings.AppDownloadLink,
"CopyrightYear": time.Now().Year(),
"SiteName": *cfg.TeamSettings.SiteName,
"AboutLink": *cfg.SupportSettings.AboutLink,
"PrivacyPolicyLink": *cfg.SupportSettings.PrivacyPolicyLink,
"TermsOfServiceLink": *cfg.SupportSettings.TermsOfServiceLink,
"HelpLink": *cfg.SupportSettings.HelpLink,
},
}
}

133
server/channels/web/web.go Normal file
View File

@@ -0,0 +1,133 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"net/http"
"path"
"strings"
"github.com/Masterminds/semver/v3"
"github.com/avct/uasurfer"
"github.com/gorilla/mux"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
type Web struct {
srv *app.Server
MainRouter *mux.Router
}
func New(srv *app.Server) *Web {
mlog.Debug("Initializing web routes")
web := &Web{
srv: srv,
MainRouter: srv.Router,
}
web.InitOAuth()
web.InitWebhooks()
web.InitSaml()
web.InitMagicLink()
web.InitStatic()
return web
}
// Due to the complexities of UA detection and the ramifications of a misdetection
// only older Safari and IE browsers throw incompatibility errors.
// Map should be of minimum required browser version.
// -1 means that the browser is not supported in any version.
var browserMinimumSupported = map[string]int{
"BrowserIE": 12,
"BrowserSafari": 12,
}
func CheckClientCompatibility(agentString string) bool {
ua := uasurfer.Parse(agentString)
if version, exist := browserMinimumSupported[ua.Browser.Name.String()]; exist && (ua.Browser.Version.Major < version || version < 0) {
return false
}
return true
}
func CheckDesktopAppCompatibility(agentString string, minVersion *string) bool {
if minVersion == nil || *minVersion == "" {
return true
}
clientVersionStr, ok := app.GetDesktopAppVersion(agentString)
if !ok {
return true
}
clientVersion, err := semver.NewVersion(clientVersionStr)
if err != nil {
return true
}
required, err := semver.StrictNewVersion(*minVersion)
if err != nil {
return true
}
return clientVersion.GreaterThanEqual(required)
}
func Handle404(a *app.App, w http.ResponseWriter, r *http.Request) {
err := model.NewAppError("Handle404", "api.context.404.app_error", nil, "", http.StatusNotFound)
ipAddress := utils.GetIPAddress(r, a.Config().ServiceSettings.TrustedProxyIPHeader)
mlog.Debug("not found handler triggered", mlog.String("path", r.URL.Path), mlog.Int("code", 404), mlog.String("ip", ipAddress))
if IsAPICall(a, r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(err.StatusCode)
err.DetailedError = "There doesn't appear to be an api call for the url='" + r.URL.Path + "'. Typo? are you missing a team_id or user_id as part of the url?"
if _, writeErr := w.Write([]byte(err.ToJSON())); writeErr != nil {
mlog.Warn("Error writing 404 response", mlog.Err(writeErr))
}
} else if *a.Config().ServiceSettings.WebserverMode == "disabled" {
http.NotFound(w, r)
} else {
utils.RenderWebAppError(a.Config(), w, r, err, a.AsymmetricSigningKey())
}
}
func IsAPICall(a *app.App, r *http.Request) bool {
subpath, _ := utils.GetSubpathFromConfig(a.Config())
return strings.HasPrefix(r.URL.Path, path.Join(subpath, "api")+"/")
}
func IsWebhookCall(a *app.App, r *http.Request) bool {
subpath, _ := utils.GetSubpathFromConfig(a.Config())
return strings.HasPrefix(r.URL.Path, path.Join(subpath, "hooks")+"/")
}
func IsOAuthAPICall(a *app.App, r *http.Request) bool {
subpath, _ := utils.GetSubpathFromConfig(a.Config())
if r.Method == "POST" && r.URL.Path == path.Join(subpath, "oauth", "authorize") {
return true
}
if r.URL.Path == path.Join(subpath, "oauth", "apps", "authorized") ||
r.URL.Path == path.Join(subpath, "oauth", "deauthorize") ||
r.URL.Path == path.Join(subpath, "oauth", "access_token") ||
r.URL.Path == path.Join(subpath, "oauth", "intune") {
return true
}
return false
}
func ReturnStatusOK(w http.ResponseWriter) {
m := make(map[string]string)
m[model.STATUS] = model.StatusOk
if _, err := w.Write([]byte(model.MapToJSON(m))); err != nil {
mlog.Warn("Error writing status OK response", mlog.Err(err))
}
}

View File

@@ -0,0 +1,492 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"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/plugin/utils"
"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/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/config"
)
var apiClient *model.Client4
var URL string
type TestHelper struct {
App *app.App
Context request.CTX
Server *app.Server
Web *Web
BasicUser *model.User
BasicChannel *model.Channel
BasicTeam *model.Team
SystemAdminUser *model.User
tempWorkspace string
IncludeCacheLayer bool
TestLogger *mlog.Logger
}
func SetupWithStoreMock(tb testing.TB) *TestHelper {
if testing.Short() {
tb.SkipNow()
}
th := setupTestHelper(tb, false, nil)
emptyMockStore := mocks.Store{}
emptyMockStore.On("Close").Return(nil)
th.App.Srv().SetStore(&emptyMockStore)
return th
}
func Setup(tb testing.TB) *TestHelper {
if testing.Short() {
tb.SkipNow()
}
store := mainHelper.GetStore()
store.DropAllTables()
return setupTestHelper(tb, true, nil)
}
func setupTestHelper(tb testing.TB, includeCacheLayer bool, options []app.Option) *TestHelper {
memoryStore := config.NewTestMemoryStore()
newConfig := memoryStore.Get().Clone()
newConfig.SqlSettings = *mainHelper.GetSQLSettings()
*newConfig.AnnouncementSettings.AdminNoticesEnabled = false
*newConfig.AnnouncementSettings.UserNoticesEnabled = false
*newConfig.PluginSettings.AutomaticPrepackagedPlugins = false
*newConfig.LogSettings.EnableSentry = false // disable error reporting during tests
*newConfig.LogSettings.ConsoleJson = false
// Check for environment variable override for console log level (useful for debugging tests)
consoleLevel := os.Getenv("MM_LOGSETTINGS_CONSOLELEVEL")
if consoleLevel == "" {
consoleLevel = mlog.LvlStdLog.Name
}
*newConfig.LogSettings.ConsoleLevel = consoleLevel
_, _, err := memoryStore.Set(newConfig)
require.NoError(tb, err)
options = append(options, app.ConfigStore(memoryStore))
if includeCacheLayer {
// Adds the cache layer to the test store
options = append(options, app.StoreOverrideWithCache(mainHelper.Store))
} else {
options = append(options, app.StoreOverride(mainHelper.Store))
}
testLogger, err := mlog.NewLogger()
require.NoError(tb, err)
logCfg, err := config.MloggerConfigFromLoggerConfig(&newConfig.LogSettings, nil, config.GetLogFileLocation)
require.NoError(tb, err)
err = testLogger.ConfigureTargets(logCfg, nil)
require.NoError(tb, err, "failed to configure test logger")
// lock logger config so server init cannot override it during testing.
testLogger.LockConfiguration()
options = append(options, app.SetLogger(testLogger))
s, err := app.NewServer(options...)
require.NoError(tb, err)
a := app.New(app.ServerConnector(s.Channels()))
prevListenAddress := *s.Config().ServiceSettings.ListenAddress
a.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ListenAddress = "localhost:0" })
err = s.Start()
require.NoError(tb, err)
a.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ListenAddress = prevListenAddress })
web := New(s)
URL = fmt.Sprintf("http://localhost:%v", s.ListenAddr.Port)
apiClient = model.NewAPIv4Client(URL)
s.Store().MarkSystemRanUnitTests()
a.UpdateConfig(func(cfg *model.Config) {
*cfg.TeamSettings.EnableOpenServer = true
})
th := &TestHelper{
App: a,
Context: request.EmptyContext(testLogger),
Server: s,
Web: web,
IncludeCacheLayer: includeCacheLayer,
TestLogger: testLogger,
}
tb.Cleanup(func() {
if th.IncludeCacheLayer {
// Clean all the caches
appErr := th.App.Srv().InvalidateAllCaches()
require.Nil(tb, appErr)
}
th.Server.Shutdown()
})
return th
}
func (th *TestHelper) InitPlugins() *TestHelper {
pluginDir := filepath.Join(th.tempWorkspace, "plugins")
webappDir := filepath.Join(th.tempWorkspace, "webapp")
th.App.InitPlugins(th.Context, pluginDir, webappDir)
return th
}
func (th *TestHelper) NewPluginAPI(manifest *model.Manifest) plugin.API {
return th.App.NewPluginAPI(th.Context, manifest)
}
func (th *TestHelper) InitBasic(tb testing.TB) *TestHelper {
tb.Helper()
var appErr *model.AppError
th.SystemAdminUser, appErr = th.App.CreateUser(th.Context, &model.User{Email: model.NewId() + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: model.NewTestPassword(), EmailVerified: true, Roles: model.SystemAdminRoleId})
require.Nil(tb, appErr)
th.BasicUser, appErr = th.App.CreateUser(th.Context, &model.User{Email: model.NewId() + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: model.NewTestPassword(), EmailVerified: true, Roles: model.SystemUserRoleId})
require.Nil(tb, appErr)
th.BasicTeam, appErr = th.App.CreateTeam(th.Context, &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: th.BasicUser.Email, Type: model.TeamOpen})
require.Nil(tb, appErr)
_, appErr = th.App.JoinUserToTeam(th.Context, th.BasicTeam, th.BasicUser, "")
require.Nil(tb, appErr)
th.BasicChannel, appErr = th.App.CreateChannel(th.Context, &model.Channel{DisplayName: "Test API Name", Name: "zz" + model.NewId() + "a", Type: model.ChannelTypeOpen, TeamId: th.BasicTeam.Id, CreatorId: th.BasicUser.Id}, true)
require.Nil(tb, appErr)
return th
}
func TestStaticFilesRequest(t *testing.T) {
th := Setup(t).InitPlugins()
pluginID := "com.mattermost.sample"
// Setup the directory directly in the plugin working path.
pluginDir := filepath.Join(*th.App.Config().PluginSettings.Directory, pluginID)
err := os.MkdirAll(pluginDir, 0777)
require.NoError(t, err)
pluginDir, err = filepath.Abs(pluginDir)
require.NoError(t, err)
// Compile the backend
backend := filepath.Join(pluginDir, "backend.exe")
pluginCode := `
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`
utils.CompileGo(t, pluginCode, backend)
// Write out the frontend
mainJS := `var x = alert();`
mainJSPath := filepath.Join(pluginDir, "main.js")
require.NoError(t, err)
err = os.WriteFile(mainJSPath, []byte(mainJS), 0777)
require.NoError(t, err)
// Write the plugin.json manifest
pluginManifest := `{"id": "com.mattermost.sample", "server": {"executable": "backend.exe"}, "webapp": {"bundle_path":"main.js"}, "settings_schema": {"settings": []}}`
err = os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(pluginManifest), 0600)
require.NoError(t, err)
// Activate the plugin
manifest, activated, reterr := th.App.GetPluginsEnvironment().Activate(pluginID)
require.NoError(t, reterr)
require.NotNil(t, manifest)
require.True(t, activated)
// Verify access to the bundle with requisite headers
req, err := http.NewRequest("GET", "/static/plugins/com.mattermost.sample/com.mattermost.sample_724ed0e2ebb2b841_bundle.js", nil)
require.NoError(t, err)
res := httptest.NewRecorder()
th.Web.MainRouter.ServeHTTP(res, req)
assert.Equal(t, http.StatusOK, res.Code)
assert.Equal(t, mainJS, res.Body.String())
assert.Equal(t, []string{"max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
// Verify cached access to the bundle with an If-Modified-Since timestamp in the future
future := time.Now().Add(24 * time.Hour)
req, err = http.NewRequest("GET", "/static/plugins/com.mattermost.sample/com.mattermost.sample_724ed0e2ebb2b841_bundle.js", nil)
require.NoError(t, err)
req.Header.Add("If-Modified-Since", future.Format(time.RFC850))
res = httptest.NewRecorder()
th.Web.MainRouter.ServeHTTP(res, req)
assert.Equal(t, http.StatusNotModified, res.Code)
assert.Empty(t, res.Body.String())
assert.Equal(t, []string{"max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
// Verify access to the bundle with an If-Modified-Since timestamp in the past
past := time.Now().Add(-24 * time.Hour)
req, err = http.NewRequest("GET", "/static/plugins/com.mattermost.sample/com.mattermost.sample_724ed0e2ebb2b841_bundle.js", nil)
require.NoError(t, err)
req.Header.Add("If-Modified-Since", past.Format(time.RFC850))
res = httptest.NewRecorder()
th.Web.MainRouter.ServeHTTP(res, req)
assert.Equal(t, http.StatusOK, res.Code)
assert.Equal(t, mainJS, res.Body.String())
assert.Equal(t, []string{"max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
// Verify handling of 404.
req, err = http.NewRequest("GET", "/static/plugins/com.mattermost.sample/404.js", nil)
require.NoError(t, err)
res = httptest.NewRecorder()
th.Web.MainRouter.ServeHTTP(res, req)
assert.Equal(t, http.StatusNotFound, res.Code)
assert.Equal(t, "404 page not found\n", res.Body.String())
assert.Equal(t, []string{"no-cache, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
}
func TestPublicFilesRequest(t *testing.T) {
th := Setup(t).InitPlugins()
pluginDir, err := os.MkdirTemp("", "")
require.NoError(t, err)
webappPluginDir, err := os.MkdirTemp("", "")
require.NoError(t, err)
defer os.RemoveAll(pluginDir)
defer os.RemoveAll(webappPluginDir)
env, err := plugin.NewEnvironment(th.NewPluginAPI, app.NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), nil)
require.NoError(t, err)
pluginID := "com.mattermost.sample"
pluginCode :=
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`
// Compile and write the plugin
backend := filepath.Join(pluginDir, pluginID, "backend.exe")
utils.CompileGo(t, pluginCode, backend)
// Write the plugin.json manifest
pluginManifest := `{"id": "com.mattermost.sample", "server": {"executable": "backend.exe"}, "settings_schema": {"settings": []}}`
err = os.WriteFile(filepath.Join(pluginDir, pluginID, "plugin.json"), []byte(pluginManifest), 0600)
require.NoError(t, err)
// Write the test public file
helloHTML := `Hello from the static files public folder for the com.mattermost.sample plugin!`
htmlFolderPath := filepath.Join(pluginDir, pluginID, "public")
err = os.MkdirAll(htmlFolderPath, os.ModePerm)
require.NoError(t, err)
htmlFilePath := filepath.Join(htmlFolderPath, "hello.html")
htmlFileErr := os.WriteFile(htmlFilePath, []byte(helloHTML), 0600)
assert.NoError(t, htmlFileErr)
nefariousHTML := `You shouldn't be able to get here!`
htmlFileErr = os.WriteFile(filepath.Join(pluginDir, pluginID, "nefarious-file-access.html"), []byte(nefariousHTML), 0600)
assert.NoError(t, htmlFileErr)
manifest, activated, reterr := env.Activate(pluginID)
require.NoError(t, reterr)
require.NotNil(t, manifest)
require.True(t, activated)
th.App.Channels().SetPluginsEnvironment(env)
req, err := http.NewRequest("GET", "/plugins/com.mattermost.sample/public/hello.html", nil)
require.NoError(t, err)
res := httptest.NewRecorder()
th.Web.MainRouter.ServeHTTP(res, req)
assert.Equal(t, helloHTML, res.Body.String())
req, err = http.NewRequest("GET", "/plugins/com.mattermost.sample/nefarious-file-access.html", nil)
require.NoError(t, err)
res = httptest.NewRecorder()
th.Web.MainRouter.ServeHTTP(res, req)
assert.Equal(t, 404, res.Code)
req, err = http.NewRequest("GET", "/plugins/com.mattermost.sample/public/../nefarious-file-access.html", nil)
require.NoError(t, err)
res = httptest.NewRecorder()
th.Web.MainRouter.ServeHTTP(res, req)
assert.Equal(t, 301, res.Code)
}
/* Test disabled for now so we don't require the client to build. Maybe re-enable after client gets moved out.
func TestStatic(t *testing.T) {
Setup()
// add a short delay to make sure the server is ready to receive requests
time.Sleep(1 * time.Second)
resp, err := http.Get(URL + "/static/root.html")
assert.NoErrorf(t, err, "got error while trying to get static files %v", err)
assert.Equalf(t, resp.StatusCode, http.StatusOK, "couldn't get static files %v", resp.StatusCode)
}
*/
func TestStaticFilesCaching(t *testing.T) {
th := Setup(t).InitPlugins()
fakeMainBundleName := "main.1234ab.js"
fakeRootHTML := `<html>
<head>
<title>Mattermost</title>
</head>
</html>`
fakeMainBundle := `module.exports = 'main';`
fakeRemoteEntry := `module.exports = 'remote';`
err := os.WriteFile("./client/root.html", []byte(fakeRootHTML), 0600)
require.NoError(t, err)
err = os.WriteFile("./client/"+fakeMainBundleName, []byte(fakeMainBundle), 0600)
require.NoError(t, err)
err = os.WriteFile("./client/remote_entry.js", []byte(fakeRemoteEntry), 0600)
require.NoError(t, err)
err = os.MkdirAll("./client/products/boards", 0777)
require.NoError(t, err)
err = os.WriteFile("./client/products/boards/remote_entry.js", []byte(fakeRemoteEntry), 0600)
require.NoError(t, err)
req, err := http.NewRequest("GET", "/", nil)
require.NoError(t, err)
res := httptest.NewRecorder()
th.Web.MainRouter.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code)
require.Equal(t, fakeRootHTML, res.Body.String())
require.Equal(t, []string{"no-cache, max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
// Checking for HEAD method as well.
req, err = http.NewRequest(http.MethodHead, "/", nil)
require.NoError(t, err)
res = httptest.NewRecorder()
th.Web.MainRouter.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code)
require.Equal(t, fakeRootHTML, res.Body.String())
require.Equal(t, []string{"no-cache, max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
req, err = http.NewRequest("GET", "/static/"+fakeMainBundleName, nil)
require.NoError(t, err)
res = httptest.NewRecorder()
th.Web.MainRouter.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code)
require.Equal(t, fakeMainBundle, res.Body.String())
require.Equal(t, []string{"max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
req, err = http.NewRequest("GET", "/static/remote_entry.js", nil)
require.NoError(t, err)
res = httptest.NewRecorder()
th.Web.MainRouter.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code)
require.Equal(t, fakeRemoteEntry, res.Body.String())
require.Equal(t, []string{"no-cache, max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
req, err = http.NewRequest("GET", "/static/products/boards/remote_entry.js", nil)
require.NoError(t, err)
res = httptest.NewRecorder()
th.Web.MainRouter.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code)
require.Equal(t, fakeRemoteEntry, res.Body.String())
require.Equal(t, []string{"no-cache, max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
}
func TestCheckClientCompatability(t *testing.T) {
//Browser Name, UA String, expected result (if the browser should fail the test false and if it should pass the true)
type uaTest struct {
Name string // Name of Browser
UserAgent string // Useragent of Browser
Result bool // Expected result (true if browser should be compatible, false if browser shouldn't be compatible)
}
var uaTestParameters = []uaTest{
{"Mozilla 40.1", "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.1", true},
{"Chrome 60", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36", true},
{"Chrome Mobile", "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Mobile Safari/537.36", true},
{"MM Classic App", "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR6.170623.013; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/61.0.3163.81 Mobile Safari/537.36 Web-Atoms-Mobile-WebView", true},
{"MM App 3.7.1", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Mattermost/3.7.1 Chrome/56.0.2924.87 Electron/1.6.11 Safari/537.36", true},
{"Franz 4.0.4", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Franz/4.0.4 Chrome/52.0.2743.82 Electron/1.3.1 Safari/537.36", true},
{"Edge 14", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.79 Safari/537.36 Edge/14.14393", true},
{"Internet Explorer 9", "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 7.1; Trident/5.0", false},
{"Internet Explorer 11", "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko", false},
{"Internet Explorer 11 2", "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; Zoom 3.6.0; rv:11.0) like Gecko", false},
{"Internet Explorer 11 (Compatibility Mode) 1", "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; .NET CLR 1.1.4322; InfoPath.3; Zoom 3.6.0)", false},
{"Internet Explorer 11 (Compatibility Mode) 2", "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; Zoom 3.6.0)", false},
{"Safari 12", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Safari/605.1.15", true},
{"Safari 11", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Safari/604.1.38", false},
{"Safari 10", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/602.4.8 (KHTML, like Gecko) Version/10.0.3 Safari/602.4.8", false},
{"Safari 9", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_3) AppleWebKit/601.4.4 (KHTML, like Gecko) Version/9.0.3 Safari/601.4.4", false},
{"Safari 8", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_4) AppleWebKit/600.7.12 (KHTML, like Gecko) Version/8.0.7 Safari/600.7.12", false},
{"Safari Mobile 12", "Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like macOS) AppleWebKit/602.1.50 (KHTML, like Gecko) Version/12.0 Mobile/14A5335d Safari/602.1.50", true},
{"Safari Mobile 9", "Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B137 Safari/601.1", false},
}
for _, browser := range uaTestParameters {
t.Run(browser.Name, func(t *testing.T) {
result := CheckClientCompatibility(browser.UserAgent)
require.Equalf(t, result, browser.Result, "user agent test failed for %s", browser.Name)
})
}
}
func TestCheckDesktopAppCompatibility(t *testing.T) {
tests := []struct {
name string
userAgent string
minVersion string
want bool
}{
{"blank min version allows all", "Mattermost/5.0.0", "", true},
{"desktop app at min version", "Mattermost/5.0.0", "5.0.0", true},
{"desktop app above min version", "Mattermost/5.1.0", "5.0.0", true},
{"desktop app below min version", "Mattermost/4.9.0", "5.0.0", false},
{"browser user agent not checked", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0", "5.0.0", true},
{"desktop app version at start of UA", "Mattermost/5.3.1 Chrome/110.0.5481.177", "5.0.0", true},
{"desktop app version at end of UA", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0.6478.127 Electron/31.2.1 Safari/537.36 Mattermost/5.9.0", "5.0.0", true},
{"desktop app old version rejected", "Mattermost/3.7.1 Chrome/56.0.2924.87 Electron/1.6.11 Safari/537.36", "5.0.0", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CheckDesktopAppCompatibility(tt.userAgent, &tt.minVersion)
require.Equalf(t, tt.want, got, "CheckDesktopAppCompatibility(%q, %q)", tt.userAgent, tt.minVersion)
})
}
}

View File

@@ -0,0 +1,147 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"encoding/json"
"io"
"mime"
"net/http"
"strings"
"github.com/gorilla/mux"
"github.com/gorilla/schema"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (w *Web) InitWebhooks() {
w.MainRouter.Handle("/hooks/commands/{id:[A-Za-z0-9]+}", w.APIHandlerTrustRequester(commandWebhook)).Methods(http.MethodPost)
w.MainRouter.Handle("/hooks/{id:[A-Za-z0-9]+}", w.APIHandlerTrustRequester(incomingWebhook)).Methods(http.MethodPost)
}
func incomingWebhook(c *Context, w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
id := params["id"]
errCtx := map[string]any{"hook_id": id}
err := r.ParseForm()
if err != nil {
c.Err = model.NewAppError("incomingWebhook", "web.incoming_webhook.parse_form.app_error", errCtx, "", http.StatusBadRequest).Wrap(err)
return
}
var appErr *model.AppError
var mediaType string
incomingWebhookPayload := &model.IncomingWebhookRequest{}
contentType := r.Header.Get("Content-Type")
// Content-Type header is optional so could be empty
if contentType != "" {
var mimeErr error
mediaType, _, mimeErr = mime.ParseMediaType(contentType)
if mimeErr != nil && mimeErr != mime.ErrInvalidMediaParameter {
c.Err = model.NewAppError("incomingWebhook", "web.incoming_webhook.media_type.app_error", errCtx, "", http.StatusBadRequest).Wrap(mimeErr)
return
}
}
defer func() {
if *c.App.Config().LogSettings.EnableWebhookDebugging {
if c.Err != nil {
fields := []mlog.Field{mlog.String("webhook_id", id), mlog.String("request_id", c.AppContext.RequestId())}
payload, err := json.Marshal(incomingWebhookPayload)
if err != nil {
fields = append(fields, mlog.NamedErr("encoding_err", err))
} else {
fields = append(fields, mlog.String("payload", payload))
}
mlog.Debug("Incoming webhook received", fields...)
}
}
}()
errCtx["media_type"] = mediaType
if mediaType == "application/x-www-form-urlencoded" {
payload := strings.NewReader(r.FormValue("payload"))
incomingWebhookPayload, appErr = decodePayload(payload)
if appErr != nil {
c.Err = model.NewAppError("incomingWebhook", "web.incoming_webhook.decode.app_error", errCtx, "", http.StatusBadRequest).Wrap(appErr)
return
}
} else if mediaType == "multipart/form-data" {
if err := r.ParseMultipartForm(0); err != nil {
c.Err = model.NewAppError("incomingWebhook", "web.incoming_webhook.parse_multipart.app_error", errCtx, "", http.StatusBadRequest).Wrap(err)
return
}
decoder := schema.NewDecoder()
err := decoder.Decode(incomingWebhookPayload, r.PostForm)
if err != nil {
c.Err = model.NewAppError("incomingWebhook", "web.incoming_webhook.decode.app_error", errCtx, "", http.StatusBadRequest).Wrap(err)
return
}
} else {
incomingWebhookPayload, appErr = decodePayload(r.Body)
if appErr != nil {
c.Err = model.NewAppError("incomingWebhook", "web.incoming_webhook.decode.app_error", errCtx, "", appErr.StatusCode).Wrap(appErr)
return
}
}
appErr = c.App.HandleIncomingWebhook(c.AppContext, id, incomingWebhookPayload)
if appErr != nil {
c.Err = model.NewAppError("incomingWebhook", "web.incoming_webhook.general.app_error", errCtx, "", appErr.StatusCode).Wrap(appErr)
return
}
w.Header().Set("Content-Type", "text/plain")
if _, err := w.Write([]byte("ok")); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
return
}
}
func commandWebhook(c *Context, w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
id := params["id"]
errCtx := map[string]any{"hook_id": id}
response, err := model.CommandResponseFromHTTPBody(r.Header.Get("Content-Type"), r.Body)
if err != nil {
c.Err = model.NewAppError("commandWebhook", "web.command_webhook.parse.app_error", errCtx, "", http.StatusBadRequest).Wrap(err)
return
}
if response != nil {
if vErr := response.IsValid(); vErr != nil {
c.Logger.Warn("Command response is not valid. Please update your integration to be compliant.", mlog.Err(vErr))
}
}
appErr := c.App.HandleCommandWebhook(c.AppContext, id, response)
if appErr != nil {
c.Err = model.NewAppError("commandWebhook", "web.command_webhook.general.app_error", errCtx, "", appErr.StatusCode).Wrap(appErr)
return
}
w.Header().Set("Content-Type", "text/plain")
if _, err := w.Write([]byte("ok")); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
return
}
}
func decodePayload(payload io.Reader) (*model.IncomingWebhookRequest, *model.AppError) {
incomingWebhookPayload, decodeError := model.IncomingWebhookRequestFromJSON(payload)
if decodeError != nil {
return nil, decodeError
}
return incomingWebhookPayload, nil
}

View File

@@ -0,0 +1,280 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"bytes"
"fmt"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestIncomingWebhook(t *testing.T) {
th := Setup(t).InitBasic(t)
if !*th.App.Config().ServiceSettings.EnableIncomingWebhooks {
_, err := http.Post(apiClient.URL+"/hooks/123", "", strings.NewReader("123"))
assert.Error(t, err, "should have errored - webhooks turned off")
return
}
hook, err := th.App.CreateIncomingWebhookForChannel(th.BasicUser.Id, th.BasicChannel, &model.IncomingWebhook{ChannelId: th.BasicChannel.Id})
require.Nil(t, err)
url := apiClient.URL + "/hooks/" + hook.Id
var tooLongTextBuilder strings.Builder
for range 8200 {
tooLongTextBuilder.WriteString("a")
}
tooLongText := tooLongTextBuilder.String()
t.Run("WebhookBasics", func(t *testing.T) {
payload := "payload={\"text\": \"test text\"}"
resp, err := http.Post(url, "application/x-www-form-urlencoded", strings.NewReader(payload))
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
refreshed, appErr := th.App.GetIncomingWebhook(hook.Id)
require.Nil(t, appErr)
require.NotZero(t, refreshed.LastUsed)
payload = "payload={\"text\": \"\"}"
resp, err = http.Post(url, "application/x-www-form-urlencoded", strings.NewReader(payload))
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, resp.StatusCode, "should have errored - no text post")
payload = "payload={\"text\": \"test text\", \"channel\": \"junk\"}"
resp, err = http.Post(url, "application/x-www-form-urlencoded", strings.NewReader(payload))
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, resp.StatusCode, "should have errored - bad channel")
payload = "payload={\"text\": \"test text\"}"
resp, err = http.Post(apiClient.URL+"/hooks/abc123", "application/x-www-form-urlencoded", strings.NewReader(payload))
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, resp.StatusCode, "should have errored - bad hook")
resp, err = http.Post(url, "application/json", strings.NewReader("{\"text\":\"this is a test\"}"))
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
text := `this is a \"test\"
that contains a newline and a tab`
resp, err = http.Post(url, "application/json", strings.NewReader("{\"text\":\""+text+"\"}"))
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp, err = http.Post(url, "application/json", strings.NewReader(fmt.Sprintf("{\"text\":\"this is a test\", \"channel\":\"%s\"}", th.BasicChannel.Name)))
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp, err = http.Post(url, "application/json", strings.NewReader(fmt.Sprintf("{\"text\":\"this is a test\", \"channel\":\"#%s\"}", th.BasicChannel.Name)))
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp, err = http.Post(url, "application/json", strings.NewReader(fmt.Sprintf("{\"text\":\"this is a test\", \"channel\":\"@%s\"}", th.BasicUser.Username)))
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp, err = http.Post(url, "application/x-www-form-urlencoded", strings.NewReader("payload={\"text\":\"this is a test\"}"))
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp, err = http.Post(url, "application/x-www-form-urlencoded", strings.NewReader("payload={\"text\":\""+text+"\"}"))
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp, err = http.Post(url, "AppLicaTion/x-www-Form-urlencoded", strings.NewReader("payload={\"text\":\""+text+"\"}"))
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp, err = http.Post(url, "application/x-www-form-urlencoded;charset=utf-8", strings.NewReader("payload={\"text\":\""+text+"\"}"))
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp, err = http.Post(url, "application/x-www-form-urlencoded; charset=utf-8", strings.NewReader("payload={\"text\":\""+text+"\"}"))
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp, err = http.Post(url, "application/x-www-form-urlencoded wrongtext", strings.NewReader("payload={\"text\":\""+text+"\"}"))
assert.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
resp, err = http.Post(url, "application/json", strings.NewReader("{\"text\":\""+tooLongText+"\"}"))
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp, err = http.Post(url, "application/x-www-form-urlencoded", strings.NewReader("{\"text\":\""+tooLongText+"\"}"))
assert.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
resp, err = http.Post(url, "application/json", strings.NewReader("payload={\"text\":\""+text+"\"}"))
assert.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
payloadMultiPart := "------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"username\"\r\n\r\nwebhook-bot\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"text\"\r\n\r\nthis is a test :tada:\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW--"
resp, err = http.Post(apiClient.URL+"/hooks/"+hook.Id, "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW", strings.NewReader(payloadMultiPart))
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp, err = http.Post(url, "mimetype/wrong", strings.NewReader("payload={\"text\":\""+text+"\"}"))
assert.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
resp, err = http.Post(url, "", strings.NewReader("{\"text\":\""+text+"\"}"))
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("WebhookAttachments", func(t *testing.T) {
attachmentPayload := `{
"text": "this is a test",
"attachments": [
{
"fallback": "Required plain-text summary of the attachment.",
"color": "#36a64f",
"pretext": "Optional text that appears above the attachment block",
"author_name": "Bobby Tables",
"author_link": "http://flickr.com/bobby/",
"author_icon": "http://flickr.com/icons/bobby.jpg",
"title": "Slack API Documentation",
"title_link": "https://api.slack.com/",
"text": "Optional text that appears within the attachment",
"fields": [
{
"title": "Priority",
"value": "High",
"short": false
}
],
"image_url": "http://my-website.com/path/to/image.jpg",
"thumb_url": "http://example.com/path/to/thumb.png"
}
]
}`
resp, err := http.Post(url, "application/json", strings.NewReader(attachmentPayload))
require.NoError(t, err)
assert.True(t, resp.StatusCode == http.StatusOK)
attachmentPayload = `{
"text": "this is a test",
"attachments": [
{
"fallback": "Required plain-text summary of the attachment.",
"color": "#36a64f",
"pretext": "Optional text that appears above the attachment block",
"author_name": "Bobby Tables",
"author_link": "http://flickr.com/bobby/",
"author_icon": "http://flickr.com/icons/bobby.jpg",
"title": "Slack API Documentation",
"title_link": "https://api.slack.com/",
"text": "` + tooLongText + `",
"fields": [
{
"title": "Priority",
"value": "High",
"short": false
}
],
"image_url": "http://my-website.com/path/to/image.jpg",
"thumb_url": "http://example.com/path/to/thumb.png"
}
]
}`
resp, err = http.Post(url, "application/json", strings.NewReader(attachmentPayload))
require.NoError(t, err)
assert.True(t, resp.StatusCode == http.StatusOK)
})
t.Run("ChannelLockedWebhook", func(t *testing.T) {
channel, err := th.App.CreateChannel(th.Context, &model.Channel{TeamId: th.BasicTeam.Id, Name: model.NewId(), DisplayName: model.NewId(), Type: model.ChannelTypeOpen, CreatorId: th.BasicUser.Id}, true)
require.Nil(t, err)
hook, err := th.App.CreateIncomingWebhookForChannel(th.BasicUser.Id, th.BasicChannel, &model.IncomingWebhook{ChannelId: th.BasicChannel.Id, ChannelLocked: true})
require.Nil(t, err)
require.NotNil(t, hook)
apiHookURL := apiClient.URL + "/hooks/" + hook.Id
payload := "payload={\"text\": \"test text\"}"
resp, err2 := http.Post(apiHookURL, "application/x-www-form-urlencoded", strings.NewReader(payload))
require.NoError(t, err2)
assert.True(t, resp.StatusCode == http.StatusOK)
resp, err2 = http.Post(apiHookURL, "application/json", strings.NewReader(fmt.Sprintf("{\"text\":\"this is a test\", \"channel\":\"%s\"}", th.BasicChannel.Name)))
require.NoError(t, err2)
assert.True(t, resp.StatusCode == http.StatusOK)
resp, err2 = http.Post(apiHookURL, "application/json", strings.NewReader(fmt.Sprintf("{\"text\":\"this is a test\", \"channel\":\"%s\"}", channel.Name)))
require.NoError(t, err2)
assert.True(t, resp.StatusCode == http.StatusForbidden)
})
t.Run("DisableWebhooks", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = false })
resp, err := http.Post(url, "application/json", strings.NewReader("{\"text\":\"this is a test\"}"))
require.NoError(t, err)
assert.True(t, resp.StatusCode == http.StatusNotImplemented)
})
}
func TestCommandWebhooks(t *testing.T) {
th := Setup(t).InitBasic(t)
cmd, appErr := th.App.CreateCommand(&model.Command{
CreatorId: th.BasicUser.Id,
TeamId: th.BasicTeam.Id,
URL: "http://nowhere.com",
Method: model.CommandMethodPost,
Trigger: "delayed"})
require.Nil(t, appErr)
args := &model.CommandArgs{
TeamId: th.BasicTeam.Id,
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
}
hook, appErr := th.App.CreateCommandWebhook(cmd.Id, args)
require.Nil(t, appErr)
resp, err := http.Post(apiClient.URL+"/hooks/commands/123123123123", "application/json", bytes.NewBufferString(`{"text":"this is a test"}`))
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode, "expected not-found for non-existent hook")
resp, err = http.Post(apiClient.URL+"/hooks/commands/"+hook.Id, "application/json", bytes.NewBufferString(`{"text":"invalid`))
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
for range 5 {
response, err2 := http.Post(apiClient.URL+"/hooks/commands/"+hook.Id, "application/json", bytes.NewBufferString(`{"text":"this is a test"}`))
require.NoError(t, err2)
require.Equal(t, http.StatusOK, response.StatusCode)
}
resp, _ = http.Post(apiClient.URL+"/hooks/commands/"+hook.Id, "application/json", bytes.NewBufferString(`{"text":"this is a test"}`))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
}