Doug Lauder
2023-03-22 17:22:27 -04:00
коммит произвёл GitHub
родитель b61c096497
Коммит c943ed6859
13276 изменённых файлов: 1695615 добавлений и 223189 удалений

755
server/channels/web/context.go Обычный файл
Просмотреть файл

@@ -0,0 +1,755 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"net/http"
"path"
"regexp"
"strings"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/app"
"github.com/mattermost/mattermost-server/v6/server/channels/app/request"
"github.com/mattermost/mattermost-server/v6/server/channels/audit"
"github.com/mattermost/mattermost-server/v6/server/channels/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/i18n"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
type Context struct {
App app.AppIface
AppContext *request.Context
Logger *mlog.Logger
Params *Params
Err *model.AppError
// This is used to track the graphQL query that's being executed,
// so that we can monitor the timings in Grafana.
GraphQLOperationName string
siteURLHeader string
}
// LogAuditRec logs an audit record using default LevelAPI.
func (c *Context) LogAuditRec(rec *audit.Record) {
c.LogAuditRecWithLevel(rec, app.LevelAPI)
}
// LogAuditRec 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 *audit.Record, 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 a audit record pre-populated with data from this context.
func (c *Context) MakeAuditRecord(event string, initialStatus string) *audit.Record {
rec := &audit.Record{
EventName: event,
Status: initialStatus,
Actor: audit.EventActor{
UserId: c.AppContext.Session().UserId,
SessionId: c.AppContext.Session().Id,
Client: c.AppContext.UserAgent(),
IpAddress: c.AppContext.IPAddress(),
},
Meta: map[string]interface{}{
audit.KeyAPIPath: c.AppContext.Path(),
audit.KeyClusterID: c.App.GetClusterId(),
},
EventData: audit.EventData{
Parameters: map[string]interface{}{},
PriorState: map[string]interface{}{},
ResultState: map[string]interface{}{},
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() {
// Must be licensed for MFA and have it configured for enforcement
if license := c.App.Channels().License(); license == nil || !*license.Features.MFA || !*c.App.Config().ServiceSettings.EnableMultifactorAuthentication || !*c.App.Config().ServiceSettings.EnforceMultifactorAuthentication {
return
}
// OAuth integrations are excepted
if c.AppContext.Session().IsOAuth {
return
}
user, err := c.App.GetUser(c.AppContext.Session().UserId)
if err != nil {
c.Err = model.NewAppError("MfaRequired", "api.context.get_user.app_error", nil, "", http.StatusUnauthorized).Wrap(err)
return
}
if user.IsGuest() && !*c.App.Config().GuestAccountsSettings.EnforceMultifactorAuthentication {
return
}
// Only required for email and ldap accounts
if user.AuthService != "" &&
user.AuthService != model.UserAuthServiceEmail &&
user.AuthService != model.UserAuthServiceLdap {
return
}
// Special case to let user get themself
subpath, _ := utils.GetSubpathFromConfig(c.App.Config())
if c.AppContext.Path() == path.Join(subpath, "/api/v4/users/me") {
return
}
// Bots are exempt
if user.IsBot {
return
}
if !user.MfaActive {
c.Err = model.NewAppError("MfaRequired", "api.context.mfa_required.app_error", nil, "", http.StatusForbidden)
return
}
}
// 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.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) 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 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 = c.App.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) 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) 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) 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) GetRemoteID(r *http.Request) string {
return r.Header.Get(model.HeaderRemoteclusterId)
}

91
server/channels/web/context_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"context"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock"
"github.com/mattermost/mattermost-server/v6/server/channels/store/storetest/mocks"
)
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)
defer th.TearDown()
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)
defer th.TearDown()
mockStore := th.App.Srv().Store().(*mocks.Store)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockUserStore.On("Get", context.Background(), "userid").Return(nil, model.NewAppError("Userstore.Get", "storeerror", nil, "store error", http.StatusInternalServerError))
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(65535, 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.SetSession(&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()
assert.Equal(t, c.Err.Id, "api.context.get_user.app_error")
}

532
server/channels/web/handlers.go Обычный файл
Просмотреть файл

@@ -0,0 +1,532 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"bytes"
"context"
"fmt"
"net/http"
"os"
"reflect"
"runtime"
"strconv"
"strings"
"time"
"github.com/mattermost/gziphandler"
"github.com/opentracing/opentracing-go"
"github.com/opentracing/opentracing-go/ext"
spanlog "github.com/opentracing/opentracing-go/log"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/app"
app_opentracing "github.com/mattermost/mattermost-server/v6/server/channels/app/opentracing"
"github.com/mattermost/mattermost-server/v6/server/channels/app/request"
"github.com/mattermost/mattermost-server/v6/server/channels/store/opentracinglayer"
"github.com/mattermost/mattermost-server/v6/server/channels/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/services/tracing"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/i18n"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
func GetHandlerName(h func(*Context, http.ResponseWriter, *http.Request)) string {
handlerName := runtime.FuncForPC(reflect.ValueOf(h).Pointer()).Name()
pos := strings.LastIndex(handlerName, ".")
if pos != -1 && len(handlerName) > pos {
handlerName = handlerName[pos+1:]
}
return handlerName
}
func (w *Web) NewHandler(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
return &Handler{
Srv: w.srv,
HandleFunc: h,
HandlerName: GetHandlerName(h),
RequireSession: false,
TrustRequester: false,
RequireMfa: false,
IsStatic: false,
IsLocal: false,
}
}
func (w *Web) NewStaticHandler(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
// Determine the CSP SHA directive needed for subpath support, if any. This value is fixed
// on server start and intentionally requires a restart to take effect.
subpath, _ := utils.GetSubpathFromConfig(w.srv.Config())
return &Handler{
Srv: w.srv,
HandleFunc: h,
HandlerName: GetHandlerName(h),
RequireSession: false,
TrustRequester: false,
RequireMfa: false,
IsStatic: true,
cspShaDirective: utils.GetSubpathScriptHash(subpath),
}
}
type Handler struct {
Srv *app.Server
HandleFunc func(*Context, http.ResponseWriter, *http.Request)
HandlerName string
RequireSession bool
RequireCloudKey bool
RequireRemoteClusterToken bool
TrustRequester bool
RequireMfa bool
IsStatic bool
IsLocal bool
DisableWhenBusy bool
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.Split(*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))
}
}
}
// Add flags for Webpack dev servers used by other products during development
if model.BuildNumber == "dev" {
boardsURL := os.Getenv("MM_BOARDS_DEV_SERVER_URL")
if boardsURL == "" {
// Focalboard runs on http://localhost:9006 by default
boardsURL = "http://localhost:9006"
}
devCSP = append(devCSP, boardsURL)
playbooksURL := os.Getenv("MM_PLAYBOOKS_DEV_SERVER_URL")
if playbooksURL == "" {
// Playbooks runs on http://localhost:9007 by default
playbooksURL = "http://localhost:9007"
}
devCSP = append(devCSP, playbooksURL)
}
if len(devCSP) == 0 {
return ""
}
return " " + strings.Join(devCSP, " ")
}
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w = newWrappedWriter(w)
now := time.Now()
appInstance := app.New(app.ServerConnector(h.Srv.Channels()))
requestID := model.NewId()
var statusCode string
defer func() {
responseLogFields := []mlog.Field{
mlog.String("method", r.Method),
mlog.String("url", r.URL.Path),
mlog.String("request_id", requestID),
}
// 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...)
}()
c := &Context{
AppContext: &request.Context{},
App: appInstance,
}
t, _ := i18n.GetTranslationsAndLocaleFromRequest(r)
c.AppContext.SetT(t)
c.AppContext.SetRequestId(requestID)
c.AppContext.SetIPAddress(utils.GetIPAddress(r, c.App.Config().ServiceSettings.TrustedProxyIPHeader))
c.AppContext.SetUserAgent(r.UserAgent())
c.AppContext.SetAcceptLanguage(r.Header.Get("Accept-Language"))
c.AppContext.SetPath(r.URL.Path)
c.AppContext.SetContext(context.Background())
c.Params = ParamsFromRequest(r)
c.Logger = c.App.Log()
if *c.App.Config().ServiceSettings.EnableOpenTracing {
span, ctx := tracing.StartRootSpanByContext(context.Background(), "web:ServeHTTP")
carrier := opentracing.HTTPHeadersCarrier(r.Header)
_ = opentracing.GlobalTracer().Inject(span.Context(), opentracing.HTTPHeaders, carrier)
ext.HTTPMethod.Set(span, r.Method)
ext.HTTPUrl.Set(span, c.AppContext.Path())
ext.PeerAddress.Set(span, c.AppContext.IPAddress())
span.SetTag("request_id", c.AppContext.RequestId())
span.SetTag("user_agent", c.AppContext.UserAgent())
defer func() {
if c.Err != nil {
span.LogFields(spanlog.Error(c.Err))
ext.HTTPStatusCode.Set(span, uint16(c.Err.StatusCode))
ext.Error.Set(span, true)
}
span.Finish()
}()
c.AppContext.SetContext(ctx)
tmpSrv := *c.App.Srv()
tmpSrv.SetStore(opentracinglayer.New(c.App.Srv().Store(), ctx))
c.App.SetServer(&tmpSrv)
c.App = app_opentracing.NewOpenTracingAppLayer(c.App, ctx)
}
// Set the max request body size to be equal to MaxFileSize.
// Ideally, non-file request bodies should be smaller than file request bodies,
// but we don't have a clean way to identify all file upload handlers.
// So to keep it simple, we clamp it to the max file size.
// We add a buffer of bytes.MinRead so that file sizes close to max file size
// do not get cut off.
r.Body = http.MaxBytesReader(w, r.Body, *c.App.Config().FileSettings.MaxFileSize+bytes.MinRead)
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")
cloudCSP := ""
if c.App.Channels().License().IsCloud() || *c.App.Config().ServiceSettings.SelfHostedPurchase {
cloudCSP = " js.stripe.com/v3"
}
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'; script-src 'self' cdn.rudderlabs.com%s%s%s",
cloudCSP,
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)
defer c.App.ReturnSessionToPool(session)
if err != nil {
c.Logger.Info("Invalid session", mlog.Err(err))
if err.StatusCode == http.StatusInternalServerError {
c.Err = err
} else if h.RequireSession {
c.RemoveSessionCookie(w, r)
c.Err = model.NewAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token="+token, http.StatusUnauthorized)
}
} else if !session.IsOAuth && tokenLocation == app.TokenLocationQueryString {
c.Err = model.NewAppError("ServeHTTP", "api.context.token_provided.app_error", nil, "token="+token, http.StatusUnauthorized)
} else {
c.AppContext.SetSession(session)
}
// Rate limit by UserID
if c.App.Srv().RateLimiter != nil && c.App.Srv().RateLimiter.UserIdRateLimit(c.AppContext.Session().UserId, w) {
return
}
h.checkCSRFToken(c, r, token, tokenLocation, session)
} else if token != "" && c.App.Channels().License().IsCloud() && tokenLocation == app.TokenLocationCloudHeader {
// Check to see if this provided token matches our CWS Token
session, err := c.App.GetCloudSession(token)
if err != nil {
c.Logger.Warn("Invalid CWS token", mlog.Err(err))
c.Err = err
} else {
c.AppContext.SetSession(session)
}
} else if token != "" && c.App.Channels().License() != nil && c.App.Channels().License().HasRemoteClusterService() && tokenLocation == app.TokenLocationRemoteClusterHeader {
// Get the remote cluster
if remoteId := c.GetRemoteID(r); remoteId == "" {
c.Logger.Warn("Missing remote cluster id") //
c.Err = model.NewAppError("ServeHTTP", "api.context.remote_id_missing.app_error", nil, "", http.StatusUnauthorized)
} else {
// Check the token is correct for the remote cluster id.
session, err := c.App.GetRemoteClusterSession(token, remoteId)
if err != nil {
c.Logger.Warn("Invalid remote cluster token", mlog.Err(err))
c.Err = err
} else {
c.AppContext.SetSession(session)
}
}
}
c.Logger = c.App.Log().With(
mlog.String("path", c.AppContext.Path()),
mlog.String("request_id", c.AppContext.RequestId()),
mlog.String("ip_addr", c.AppContext.IPAddress()),
mlog.String("user_id", c.AppContext.Session().UserId),
mlog.String("method", r.Method),
)
c.AppContext.SetLogger(c.Logger)
if c.Err == nil && h.RequireSession {
c.SessionRequired()
}
if c.Err == nil && h.RequireMfa {
c.MfaRequired()
}
if c.Err == nil && h.DisableWhenBusy && c.App.Srv().Platform().Busy.IsBusy() {
c.SetServerBusyError()
}
if c.Err == nil && h.RequireCloudKey {
c.CloudKeyRequired()
}
if c.Err == nil && h.RequireRemoteClusterToken {
c.RemoteClusterTokenRequired()
}
if c.Err == nil && h.IsLocal {
// if the connection is local, RemoteAddr shouldn't have the
// shape IP:PORT (it will be "@" in Linux, for example)
isLocalOrigin := !strings.Contains(r.RemoteAddr, ":")
if *c.App.Config().ServiceSettings.EnableLocalMode && isLocalOrigin {
c.AppContext.SetSession(&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 {
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.DetailedError = ""
}
// Sanitize all 5xx error messages in hardened mode
if *c.App.Config().ServiceSettings.ExperimentalEnableHardenedMode && c.Err.StatusCode >= 500 {
c.Err.Id = ""
c.Err.Message = "Internal Server Error"
c.Err.DetailedError = ""
c.Err.StatusCode = 500
c.Err.Where = ""
c.Err.IsOAuth = false
}
if IsAPICall(c.App, r) || IsWebhookCall(c.App, r) || IsOAuthAPICall(c.App, r) || r.Header.Get("X-Mobile-App") != "" {
w.WriteHeader(c.Err.StatusCode)
w.Write([]byte(c.Err.ToJSON()))
} else {
utils.RenderWebAppError(c.App.Config(), w, r, c.Err, c.App.AsymmetricSigningKey())
}
if c.App.Metrics() != nil {
c.App.Metrics().IncrementHTTPError()
}
}
statusCode = strconv.Itoa(w.(*responseWriterWrapper).StatusCode())
if c.App.Metrics() != nil {
c.App.Metrics().IncrementHTTPRequest()
if r.URL.Path != model.APIURLSuffix+"/websocket" {
elapsed := float64(time.Since(now)) / float64(time.Second)
var endpoint string
if strings.HasPrefix(r.URL.Path, model.APIURLSuffixV5) {
// It's a graphQL query, so use the operation name.
endpoint = c.GraphQLOperationName
} else {
endpoint = h.HandlerName
}
c.App.Metrics().ObserveAPIEndpointDuration(endpoint, r.Method, statusCode, elapsed)
}
}
}
// checkCSRFToken performs a CSRF check on the provided request with the given CSRF token. Returns whether or not
// a CSRF check occurred and whether or not it succeeded.
func (h *Handler) checkCSRFToken(c *Context, r *http.Request, token string, 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"
sid := ""
userId := ""
if session != nil {
sid = session.Id
userId = session.UserId
}
fields := []mlog.Field{
mlog.String("path", r.URL.Path),
mlog.String("ip", r.RemoteAddr),
mlog.String("session_id", sid),
mlog.String("user_id", userId),
}
if *c.App.Config().ServiceSettings.ExperimentalStrictCSRFEnforcement {
c.Logger.Warn(csrfErrorMessage, fields...)
} else {
c.Logger.Debug(csrfErrorMessage, fields...)
csrfCheckPassed = true
}
}
if !csrfCheckPassed {
c.AppContext.SetSession(&model.Session{})
c.Err = model.NewAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token="+token+" Appears to be a CSRF attempt", http.StatusUnauthorized)
}
}
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 gziphandler.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 gziphandler.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 gziphandler.GzipHandler(handler)
}
return handler
}

887
server/channels/web/handlers_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,887 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock"
"github.com/mattermost/mattermost-server/v6/server/channels/app"
"github.com/mattermost/mattermost-server/v6/server/channels/store/storetest/mocks"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
func handlerForHTTPErrors(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err = model.NewAppError("loginWithSaml", "api.user.saml.not_available.app_error", nil, "", http.StatusFound)
}
func TestHandlerServeHTTPErrors(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
web := New(th.Server)
handler := web.NewHandler(handlerForHTTPErrors)
var flagtests = []struct {
name string
url string
mobile bool
redirect bool
}{
{"redirect on desktop non-api endpoint", "/login/sso/saml", false, true},
{"not redirect on desktop api endpoint", "/api/v4/test", false, false},
{"not redirect on mobile non-api endpoint", "/login/sso/saml", true, false},
{"not redirect on mobile api endpoint", "/api/v4/test", true, false},
}
for _, tt := range flagtests {
t.Run(tt.name, func(t *testing.T) {
request := httptest.NewRequest("GET", tt.url, nil)
if tt.mobile {
request.Header.Add("X-Mobile-App", "mattermost")
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if tt.redirect {
assert.Equal(t, response.Code, http.StatusFound)
} else {
assert.NotContains(t, response.Body.String(), "/error?message=")
}
})
}
}
func handlerForServeDefaultSecurityHeaders(c *Context, w http.ResponseWriter, r *http.Request) {
}
func TestHandlerServeDefaultSecurityHeaders(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
web := New(th.Server)
handler := web.NewHandler(handlerForServeDefaultSecurityHeaders)
paths := []string{
"/api/v4/test", // API
"/static/manifest.json", // this should always exist. Static files have their own handler
// Note that the plugin handler isn't tested, also plugins may support arbitrary functionality
}
for _, path := range paths {
request := httptest.NewRequest("GET", path, nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
// header.Get returns a "" also if the header doesn't exist so we check that there is at least
// one Permissions-Policy header and their value is "". We check with .Values() as it canonicalizes
// the key.
permissionsPolicyHeader := response.Header().Get("Permissions-Policy")
permissionsPolicyHeaderValues := response.Header().Values("Permissions-Policy")
contentTypeOptionsHeader := response.Header().Get("X-Content-Type-Options")
referrerPolicyHeader := response.Header().Get("Referrer-Policy")
assert.NotEqualf(t, 0, len(permissionsPolicyHeaderValues), "Permissions-Policy header doesn't exist")
assert.Equal(t, "", permissionsPolicyHeader, "Permissions-Policy is not empty")
assert.Equal(t, "nosniff", contentTypeOptionsHeader)
assert.Equal(t, "no-referrer", referrerPolicyHeader)
}
}
func handlerForHTTPSecureTransport(c *Context, w http.ResponseWriter, r *http.Request) {
}
func TestHandlerServeHTTPSecureTransport(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
mockStore := th.App.Srv().Store().(*mocks.Store)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(65535, 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)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", 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.UpdateConfig(func(config *model.Config) {
*config.ServiceSettings.TLSStrictTransport = true
*config.ServiceSettings.TLSStrictTransportMaxAge = 6000
})
web := New(th.Server)
handler := web.NewHandler(handlerForHTTPSecureTransport)
request := httptest.NewRequest("GET", "/api/v4/test", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
header := response.Header().Get("Strict-Transport-Security")
if header == "" {
t.Errorf("Strict-Transport-Security expected but not existent")
}
if header != "max-age=6000" {
t.Errorf("Expected max-age=6000, got %s", header)
}
th.App.UpdateConfig(func(config *model.Config) {
*config.ServiceSettings.TLSStrictTransport = false
})
request = httptest.NewRequest("GET", "/api/v4/test", nil)
response = httptest.NewRecorder()
handler.ServeHTTP(response, request)
header = response.Header().Get("Strict-Transport-Security")
if header != "" {
t.Errorf("Strict-Transport-Security header is not expected, but returned")
}
}
func handlerForCSRFToken(c *Context, w http.ResponseWriter, r *http.Request) {
}
func TestHandlerServeCSRFToken(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
session := &model.Session{
UserId: th.BasicUser.Id,
CreateAt: model.GetMillis(),
Roles: model.SystemUserRoleId,
IsOAuth: false,
}
session.GenerateCSRF()
th.App.SetSessionExpireInHours(session, 24)
session, err := th.App.CreateSession(session)
if err != nil {
t.Errorf("Expected nil, got %s", err)
}
web := New(th.Server)
handler := Handler{
Srv: web.srv,
HandleFunc: handlerForCSRFToken,
RequireSession: true,
TrustRequester: false,
RequireMfa: false,
IsStatic: false,
}
cookie := &http.Cookie{
Name: model.SessionCookieUser,
Value: th.BasicUser.Username,
}
cookie2 := &http.Cookie{
Name: model.SessionCookieToken,
Value: session.Token,
}
cookie3 := &http.Cookie{
Name: model.SessionCookieCsrf,
Value: session.GetCSRF(),
}
// CSRF Token Used - Success Expected
request := httptest.NewRequest("POST", "/api/v4/test", nil)
request.AddCookie(cookie)
request.AddCookie(cookie2)
request.AddCookie(cookie3)
request.Header.Add(model.HeaderCsrfToken, session.GetCSRF())
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != 200 {
t.Errorf("Expected status 200, got %d", response.Code)
}
// No CSRF Token Used - Failure Expected
request = httptest.NewRequest("POST", "/api/v4/test", nil)
request.AddCookie(cookie)
request.AddCookie(cookie2)
request.AddCookie(cookie3)
response = httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != 401 {
t.Errorf("Expected status 401, got %d", response.Code)
}
// Fallback Behavior Used - Success expected
// ToDo (DSchalla) 2019/01/04: Remove once legacy CSRF Handling is removed
th.App.UpdateConfig(func(config *model.Config) {
*config.ServiceSettings.ExperimentalStrictCSRFEnforcement = false
})
request = httptest.NewRequest("POST", "/api/v4/test", nil)
request.AddCookie(cookie)
request.AddCookie(cookie2)
request.AddCookie(cookie3)
request.Header.Add(model.HeaderRequestedWith, model.HeaderRequestedWithXML)
response = httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != 200 {
t.Errorf("Expected status 200, got %d", response.Code)
}
// Fallback Behavior Used with Strict Enforcement - Failure Expected
// ToDo (DSchalla) 2019/01/04: Remove once legacy CSRF Handling is removed
th.App.UpdateConfig(func(config *model.Config) {
*config.ServiceSettings.ExperimentalStrictCSRFEnforcement = true
})
response = httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != 401 {
t.Errorf("Expected status 200, got %d", response.Code)
}
// Handler with RequireSession set to false
handlerNoSession := Handler{
Srv: th.Server,
HandleFunc: handlerForCSRFToken,
RequireSession: false,
TrustRequester: false,
RequireMfa: false,
IsStatic: false,
}
// CSRF Token Used - Success Expected
request = httptest.NewRequest("POST", "/api/v4/test", nil)
request.AddCookie(cookie)
request.AddCookie(cookie2)
request.AddCookie(cookie3)
request.Header.Add(model.HeaderCsrfToken, session.GetCSRF())
response = httptest.NewRecorder()
handlerNoSession.ServeHTTP(response, request)
if response.Code != 200 {
t.Errorf("Expected status 200, got %d", response.Code)
}
// No CSRF Token Used - Failure Expected
request = httptest.NewRequest("POST", "/api/v4/test", nil)
request.AddCookie(cookie)
request.AddCookie(cookie2)
request.AddCookie(cookie3)
response = httptest.NewRecorder()
handlerNoSession.ServeHTTP(response, request)
if response.Code != 401 {
t.Errorf("Expected status 401, got %d", response.Code)
}
}
func handlerForCSPHeader(c *Context, w http.ResponseWriter, r *http.Request) {
}
func TestHandlerServeCSPHeader(t *testing.T) {
t.Run("non-static", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
web := New(th.Server)
handler := Handler{
Srv: web.srv,
HandleFunc: handlerForCSPHeader,
RequireSession: false,
TrustRequester: false,
RequireMfa: false,
IsStatic: false,
}
request := httptest.NewRequest("POST", "/api/v4/test", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
assert.Equal(t, 200, response.Code)
assert.Empty(t, response.Header()["Content-Security-Policy"])
})
t.Run("static, without subpath", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
web := New(th.Server)
handler := Handler{
Srv: web.srv,
HandleFunc: handlerForCSPHeader,
RequireSession: false,
TrustRequester: false,
RequireMfa: false,
IsStatic: true,
}
request := httptest.NewRequest("POST", "/", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
assert.Equal(t, 200, response.Code)
assert.Equal(t, []string{"frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com js.stripe.com/v3"}, response.Header()["Content-Security-Policy"])
})
t.Run("static, without subpath or SelfHostedPurchase, does not allow Stripe in CSP", func(t *testing.T) {
th := Setup(t).InitBasic()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SelfHostedPurchase = false })
defer th.TearDown()
web := New(th.Server)
handler := Handler{
Srv: web.srv,
HandleFunc: handlerForCSPHeader,
RequireSession: false,
TrustRequester: false,
RequireMfa: false,
IsStatic: true,
}
request := httptest.NewRequest("POST", "/", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
assert.Equal(t, 200, response.Code)
assert.Equal(t, []string{"frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com"}, response.Header()["Content-Security-Policy"])
})
t.Run("static, with subpath", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
mockStore := th.App.Srv().Store().(*mocks.Store)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(65535, 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)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", 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.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = *cfg.ServiceSettings.SiteURL + "/subpath"
})
web := New(th.Server)
handler := Handler{
Srv: web.srv,
HandleFunc: handlerForCSPHeader,
RequireSession: false,
TrustRequester: false,
RequireMfa: false,
IsStatic: true,
}
request := httptest.NewRequest("POST", "/", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
assert.Equal(t, 200, response.Code)
assert.Equal(t, []string{"frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com js.stripe.com/v3"}, response.Header()["Content-Security-Policy"])
// TODO: It's hard to unit test this now that the CSP directive is effectively
// decided in Setup(). Circle back to this in master once the memory store is
// merged, allowing us to mock the desired initial config to take effect in Setup().
// assert.Contains(t, response.Header()["Content-Security-Policy"], "frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com 'sha256-tPOjw+tkVs9axL78ZwGtYl975dtyPHB6LYKAO2R3gR4='")
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = *cfg.ServiceSettings.SiteURL + "/subpath2"
})
request = httptest.NewRequest("POST", "/", nil)
response = httptest.NewRecorder()
handler.ServeHTTP(response, request)
assert.Equal(t, 200, response.Code)
assert.Equal(t, []string{"frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com js.stripe.com/v3"}, response.Header()["Content-Security-Policy"])
// TODO: See above.
// assert.Contains(t, response.Header()["Content-Security-Policy"], "frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com 'sha256-tPOjw+tkVs9axL78ZwGtYl975dtyPHB6LYKAO2R3gR4='", "csp header incorrectly changed after subpath changed")
})
t.Run("dev mode", func(t *testing.T) {
th := Setup(t)
defer th.TearDown()
oldBuildNumber := model.BuildNumber
model.BuildNumber = "dev"
defer func() {
model.BuildNumber = oldBuildNumber
}()
web := New(th.Server)
handler := Handler{
Srv: web.srv,
HandleFunc: handlerForCSPHeader,
RequireSession: false,
TrustRequester: false,
RequireMfa: false,
IsStatic: true,
}
request := httptest.NewRequest("POST", "/", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
assert.Equal(t, 200, response.Code)
assert.Equal(t, []string{"frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com js.stripe.com/v3 'unsafe-eval' 'unsafe-inline' http://localhost:9006 http://localhost:9007"}, response.Header()["Content-Security-Policy"])
})
}
func TestGenerateDevCSP(t *testing.T) {
t.Run("dev mode", func(t *testing.T) {
th := Setup(t)
defer th.TearDown()
oldBuildNumber := model.BuildNumber
model.BuildNumber = "dev"
defer func() {
model.BuildNumber = oldBuildNumber
}()
c := &Context{
App: th.App,
AppContext: th.Context,
Logger: th.App.Log(),
}
devCSP := generateDevCSP(*c)
assert.Equal(t, " 'unsafe-eval' 'unsafe-inline' http://localhost:9006 http://localhost:9007", devCSP)
})
t.Run("allowed dev flags", func(t *testing.T) {
th := Setup(t)
defer th.TearDown()
oldBuildNumber := model.BuildNumber
model.BuildNumber = "0"
defer func() {
model.BuildNumber = oldBuildNumber
}()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.DeveloperFlags = "unsafe-inline=true,unsafe-eval=true"
})
c := &Context{
App: th.App,
AppContext: th.Context,
Logger: th.App.Log(),
}
devCSP := generateDevCSP(*c)
assert.Equal(t, " 'unsafe-inline' 'unsafe-eval'", devCSP)
})
t.Run("partial dev flags", func(t *testing.T) {
th := Setup(t)
defer th.TearDown()
oldBuildNumber := model.BuildNumber
model.BuildNumber = "0"
defer func() {
model.BuildNumber = oldBuildNumber
}()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.DeveloperFlags = "unsafe-inline=false,unsafe-eval=true"
})
c := &Context{
App: th.App,
AppContext: th.Context,
Logger: th.App.Log(),
}
devCSP := generateDevCSP(*c)
assert.Equal(t, " 'unsafe-eval'", devCSP)
})
t.Run("unknown dev flags", func(t *testing.T) {
th := Setup(t)
defer th.TearDown()
oldBuildNumber := model.BuildNumber
model.BuildNumber = "0"
defer func() {
model.BuildNumber = oldBuildNumber
}()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.DeveloperFlags = "unknown=true,unsafe-inline=false,unsafe-eval=true"
})
c := &Context{
App: th.App,
AppContext: th.Context,
Logger: th.App.Log(),
}
devCSP := generateDevCSP(*c)
assert.Equal(t, " 'unsafe-eval'", devCSP)
})
t.Run("empty dev flags", func(t *testing.T) {
th := Setup(t)
defer th.TearDown()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.DeveloperFlags = ""
})
logger := mlog.CreateConsoleTestLogger(false, mlog.LvlWarn)
buf := &mlog.Buffer{}
require.NoError(t, mlog.AddWriterTarget(logger, buf, false, mlog.LvlWarn))
c := &Context{
App: th.App,
AppContext: th.Context,
Logger: logger,
}
generateDevCSP(*c)
require.NoError(t, logger.Shutdown())
assert.Equal(t, "", buf.String())
})
}
func TestHandlerServeInvalidToken(t *testing.T) {
testCases := []struct {
Description string
SiteURL string
ExpectedSetCookieHeaderRegexp string
}{
{"no subpath", "http://localhost:8065", "^MMAUTHTOKEN=; Path=/"},
{"subpath", "http://localhost:8065/subpath", "^MMAUTHTOKEN=; Path=/subpath"},
}
for _, tc := range testCases {
t.Run(tc.Description, func(t *testing.T) {
th := Setup(t)
defer th.TearDown()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = tc.SiteURL
})
web := New(th.Server)
handler := Handler{
Srv: web.srv,
HandleFunc: handlerForCSRFToken,
RequireSession: true,
TrustRequester: false,
RequireMfa: false,
IsStatic: false,
}
cookie := &http.Cookie{
Name: model.SessionCookieToken,
Value: "invalid",
}
request := httptest.NewRequest("POST", "/api/v4/test", nil)
request.AddCookie(cookie)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
require.Equal(t, http.StatusUnauthorized, response.Code)
cookies := response.Header().Get("Set-Cookie")
assert.Regexp(t, tc.ExpectedSetCookieHeaderRegexp, cookies)
})
}
}
func TestCheckCSRFToken(t *testing.T) {
t.Run("should allow a POST request with a valid CSRF token header", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
h := &Handler{
RequireSession: true,
TrustRequester: false,
}
token := "token"
tokenLocation := app.TokenLocationCookie
c := &Context{
App: th.App,
AppContext: th.Context,
}
r, _ := http.NewRequest(http.MethodPost, "", nil)
r.Header.Set(model.HeaderCsrfToken, token)
session := &model.Session{
Props: map[string]string{
"csrf": token,
},
}
checked, passed := h.checkCSRFToken(c, r, token, tokenLocation, session)
assert.True(t, checked)
assert.True(t, passed)
assert.Nil(t, c.Err)
})
t.Run("should allow a POST request with an X-Requested-With header", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
h := &Handler{
RequireSession: true,
TrustRequester: false,
}
token := "token"
tokenLocation := app.TokenLocationCookie
c := &Context{
App: th.App,
Logger: th.App.Log(),
AppContext: th.Context,
}
r, _ := http.NewRequest(http.MethodPost, "", nil)
r.Header.Set(model.HeaderRequestedWith, model.HeaderRequestedWithXML)
session := &model.Session{
Props: map[string]string{
"csrf": token,
},
}
checked, passed := h.checkCSRFToken(c, r, token, tokenLocation, session)
assert.True(t, checked)
assert.True(t, passed)
assert.Nil(t, c.Err)
})
t.Run("should not allow a POST request with an X-Requested-With header with strict CSRF enforcement enabled", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
mockStore := th.App.Srv().Store().(*mocks.Store)
mockUserStore := mocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockPostStore := mocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(65535, 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)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", 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.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.ExperimentalStrictCSRFEnforcement = true
})
h := &Handler{
RequireSession: true,
TrustRequester: false,
}
token := "token"
tokenLocation := app.TokenLocationCookie
c := &Context{
App: th.App,
Logger: th.App.Log(),
AppContext: th.Context,
}
r, _ := http.NewRequest(http.MethodPost, "", nil)
r.Header.Set(model.HeaderRequestedWith, model.HeaderRequestedWithXML)
session := &model.Session{
Props: map[string]string{
"csrf": token,
},
}
checked, passed := h.checkCSRFToken(c, r, token, tokenLocation, session)
assert.True(t, checked)
assert.False(t, passed)
assert.NotNil(t, c.Err)
})
t.Run("should not allow a POST request without either header", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
h := &Handler{
RequireSession: true,
TrustRequester: false,
}
token := "token"
tokenLocation := app.TokenLocationCookie
c := &Context{
App: th.App,
AppContext: th.Context,
}
r, _ := http.NewRequest(http.MethodPost, "", nil)
session := &model.Session{
Props: map[string]string{
"csrf": token,
},
}
checked, passed := h.checkCSRFToken(c, r, token, tokenLocation, session)
assert.True(t, checked)
assert.False(t, passed)
assert.NotNil(t, c.Err)
})
t.Run("should not check GET requests", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
h := &Handler{
RequireSession: true,
TrustRequester: false,
}
token := "token"
tokenLocation := app.TokenLocationCookie
c := &Context{
App: th.App,
AppContext: th.Context,
}
r, _ := http.NewRequest(http.MethodGet, "", nil)
session := &model.Session{
Props: map[string]string{
"csrf": token,
},
}
checked, passed := h.checkCSRFToken(c, r, token, tokenLocation, session)
assert.False(t, checked)
assert.False(t, passed)
assert.Nil(t, c.Err)
})
t.Run("should not check a request passing the auth token in a header", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
h := &Handler{
RequireSession: true,
TrustRequester: false,
}
token := "token"
tokenLocation := app.TokenLocationHeader
c := &Context{
App: th.App,
AppContext: th.Context,
}
r, _ := http.NewRequest(http.MethodPost, "", nil)
session := &model.Session{
Props: map[string]string{
"csrf": token,
},
}
checked, passed := h.checkCSRFToken(c, r, token, tokenLocation, session)
assert.False(t, checked)
assert.False(t, passed)
assert.Nil(t, c.Err)
})
t.Run("should not check a request passing a nil session", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
h := &Handler{
RequireSession: false,
TrustRequester: false,
}
token := "token"
tokenLocation := app.TokenLocationCookie
c := &Context{
App: th.App,
AppContext: th.Context,
}
r, _ := http.NewRequest(http.MethodPost, "", nil)
r.Header.Set(model.HeaderCsrfToken, token)
checked, passed := h.checkCSRFToken(c, r, token, tokenLocation, nil)
assert.False(t, checked)
assert.False(t, passed)
assert.Nil(t, c.Err)
})
t.Run("should check requests for handlers that don't require a session but have one", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
h := &Handler{
RequireSession: false,
TrustRequester: false,
}
token := "token"
tokenLocation := app.TokenLocationCookie
c := &Context{
App: th.App,
AppContext: th.Context,
}
r, _ := http.NewRequest(http.MethodPost, "", nil)
r.Header.Set(model.HeaderCsrfToken, token)
session := &model.Session{
Props: map[string]string{
"csrf": token,
},
}
checked, passed := h.checkCSRFToken(c, r, token, tokenLocation, session)
assert.True(t, checked)
assert.True(t, passed)
assert.Nil(t, c.Err)
})
}

24
server/channels/web/main_test.go Обычный файл
Просмотреть файл

@@ -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/v6/server/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)
}

448
server/channels/web/oauth.go Обычный файл
Просмотреть файл

@@ -0,0 +1,448 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"encoding/json"
"html"
"net/http"
"net/url"
"path/filepath"
"strings"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/app"
"github.com/mattermost/mattermost-server/v6/server/channels/audit"
"github.com/mattermost/mattermost-server/v6/server/channels/utils"
"github.com/mattermost/mattermost-server/v6/server/channels/utils/fileutils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/i18n"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
func (w *Web) InitOAuth() {
// API version independent OAuth 2.0 as a service provider endpoints
w.MainRouter.Handle("/oauth/authorize", w.APIHandlerTrustRequester(authorizeOAuthPage)).Methods("GET")
w.MainRouter.Handle("/oauth/authorize", w.APISessionRequired(authorizeOAuthApp)).Methods("POST")
w.MainRouter.Handle("/oauth/deauthorize", w.APISessionRequired(deauthorizeOAuthApp)).Methods("POST")
w.MainRouter.Handle("/oauth/access_token", w.APIHandlerTrustRequester(getAccessToken)).Methods("POST")
// API version independent OAuth as a client endpoints
w.MainRouter.Handle("/oauth/{service:[A-Za-z0-9]+}/complete", w.APIHandler(completeOAuth)).Methods("GET")
w.MainRouter.Handle("/oauth/{service:[A-Za-z0-9]+}/login", w.APIHandler(loginWithOAuth)).Methods("GET")
w.MainRouter.Handle("/oauth/{service:[A-Za-z0-9]+}/mobile_login", w.APIHandler(mobileLoginWithOAuth)).Methods("GET")
w.MainRouter.Handle("/oauth/{service:[A-Za-z0-9]+}/signup", w.APIHandler(signupWithOAuth)).Methods("GET")
// 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("GET")
w.MainRouter.Handle("/signup/{service:[A-Za-z0-9]+}/complete", w.APIHandler(completeOAuth)).Methods("GET")
w.MainRouter.Handle("/login/{service:[A-Za-z0-9]+}/complete", w.APIHandler(completeOAuth)).Methods("GET")
w.MainRouter.Handle("/api/v4/oauth_test", w.APISessionRequired(testHandler)).Methods("GET")
}
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("authorizeOAuthApp", audit.Fail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
redirectURL, appErr := c.App.AllowOAuthAppAccessToUser(c.AppContext.Session().UserId, authRequest)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
c.LogAudit("")
w.Write([]byte(model.MapToJSON(map[string]string{"redirect": redirectURL})))
}
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
}
auditRec := c.MakeAuditRecord("deauthorizeOAuthApp", audit.Fail)
defer c.LogAuditRec(auditRec)
err := c.App.DeauthorizeOAuthAppForUser(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"),
}
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
}
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 == "" {
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
}
isAuthorized := false
if _, err := c.App.GetPreferenceByCategoryAndNameForUser(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.Session().UserId, authRequest)
if err != nil {
utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey())
return
}
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
w.Header().Set("Content-Security-Policy", "frame-ancestors 'self'")
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) {
r.ParseForm()
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")
if secret == "" {
c.Err = model.NewAppError("getAccessToken", "api.oauth.get_access_token.bad_client_secret.app_error", nil, "", http.StatusBadRequest)
return
}
redirectURI := r.FormValue("redirect_uri")
auditRec := c.MakeAuditRecord("getAccessToken", audit.Fail)
defer c.LogAuditRec(auditRec)
auditRec.AddMeta("grant_type", grantType)
auditRec.AddMeta("client_id", clientId)
c.LogAudit("attempt")
accessRsp, err := c.App.GetOAuthAccessTokenForCodeFlow(clientId, grantType, redirectURI, code, secret, refreshToken)
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
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, teamId, props, tokenUser, err := c.App.AuthorizeOAuthUser(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)
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, teamId, 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 {
err = c.App.DoLogin(c.AppContext, w, r, user, "", isMobile, false, false)
if err != nil {
err.Translate(c.AppContext.T)
mlog.Error(err.Error())
renderError(err)
return
}
// Old mobile version
if isMobile && !hasRedirectURL {
c.App.AttachSessionCookies(c.AppContext, w, r)
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(),
})
utils.RenderMobileAuthComplete(w, redirectURL)
return
} else { // For web
c.App.AttachSessionCookies(c.AppContext, w, r)
}
}
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")
if redirectURL != "" && !utils.IsValidWebAuthRedirectURL(c.App.Config(), redirectURL) {
c.Err = model.NewAppError("loginWithOAuth", "api.invalid_redirect_url", nil, "", http.StatusBadRequest)
return
}
teamId, err := c.App.GetTeamIdFromQuery(r.URL.Query())
if err != nil {
c.Err = err
return
}
authURL, err := c.App.GetOAuthLoginEndpoint(w, r, c.Params.Service, teamId, model.OAuthActionLogin, redirectURL, loginHint, false)
if err != nil {
c.Err = err
return
}
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
}
teamId, err := c.App.GetTeamIdFromQuery(r.URL.Query())
if err != nil {
c.Err = err
return
}
authURL, err := c.App.GetOAuthLoginEndpoint(w, r, c.Params.Service, teamId, model.OAuthActionMobile, redirectURL, "", true)
if err != nil {
c.Err = err
return
}
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
}
teamId, err := c.App.GetTeamIdFromQuery(r.URL.Query())
if err != nil {
c.Err = err
return
}
authURL, err := c.App.GetOAuthSignupEndpoint(w, r, c.Params.Service, teamId)
if err != nil {
c.Err = err
return
}
http.Redirect(w, r, authURL, http.StatusFound)
}
func fullyQualifiedRedirectURL(siteURLPrefix, targetURL string) string {
parsed, _ := url.Parse(targetURL)
if parsed == nil || parsed.Scheme != "" || parsed.Host != "" {
return targetURL
}
if targetURL != "" && targetURL[0] != '/' {
targetURL = "/" + targetURL
}
return siteURLPrefix + targetURL
}

849
server/channels/web/oauth_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,849 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/einterfaces"
"github.com/mattermost/mattermost-server/v6/server/channels/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/i18n"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
func TestOAuthComplete_AccessDenied(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
c := &Context{
App: th.App,
Params: &Params{
Service: "TestService",
},
}
responseWriter := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, th.App.GetSiteURL()+"/signup/TestService/complete?error=access_denied", nil)
completeOAuth(c, responseWriter, request)
response := responseWriter.Result()
assert.Equal(t, http.StatusTemporaryRedirect, response.StatusCode)
location, _ := url.Parse(response.Header.Get("Location"))
assert.Equal(t, "oauth_access_denied", location.Query().Get("type"))
assert.Equal(t, "TestService", location.Query().Get("service"))
}
func TestAuthorizeOAuthApp(t *testing.T) {
th := Setup(t).InitBasic()
th.Login(apiClient, th.SystemAdminUser)
defer th.TearDown()
enableOAuth := *th.App.Config().ServiceSettings.EnableOAuthServiceProvider
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = enableOAuth })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true })
oapp := &model.OAuthApp{
Name: GenerateTestAppName(),
Homepage: "https://nowhere.com",
Description: "test",
CallbackUrls: []string{"https://nowhere.com"},
CreatorId: th.SystemAdminUser.Id,
}
rapp, appErr := th.App.CreateOAuthApp(oapp)
require.Nil(t, appErr)
authRequest := &model.AuthorizeRequest{
ResponseType: model.AuthCodeResponseType,
ClientId: rapp.Id,
RedirectURI: rapp.CallbackUrls[0],
Scope: "",
State: "123",
}
// Test auth code flow
ruri, _, err := apiClient.AuthorizeOAuthApp(authRequest)
require.NoError(t, err)
require.NotEmpty(t, ruri, "redirect url should be set")
ru, _ := url.Parse(ruri)
require.NotNil(t, ru, "redirect url unparseable")
require.NotEmpty(t, ru.Query().Get("code"), "authorization code not returned")
require.Equal(t, ru.Query().Get("state"), authRequest.State, "returned state doesn't match")
// Test implicit flow
authRequest.ResponseType = model.ImplicitResponseType
ruri, _, err = apiClient.AuthorizeOAuthApp(authRequest)
require.NoError(t, err)
require.False(t, ruri == "", "redirect url should be set")
ru, _ = url.Parse(ruri)
require.NotNil(t, ru, "redirect url unparseable")
values, err := url.ParseQuery(ru.Fragment)
require.NoError(t, err)
assert.False(t, values.Get("access_token") == "", "access_token not returned")
assert.Equal(t, authRequest.State, values.Get("state"), "returned state doesn't match")
oldToken := apiClient.AuthToken
apiClient.AuthToken = values.Get("access_token")
_, resp, err := apiClient.AuthorizeOAuthApp(authRequest)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
apiClient.AuthToken = oldToken
authRequest.RedirectURI = ""
_, resp, err = apiClient.AuthorizeOAuthApp(authRequest)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
authRequest.RedirectURI = "http://somewhereelse.com"
_, resp, err = apiClient.AuthorizeOAuthApp(authRequest)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
authRequest.RedirectURI = rapp.CallbackUrls[0]
authRequest.ResponseType = ""
_, resp, err = apiClient.AuthorizeOAuthApp(authRequest)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
authRequest.ResponseType = model.AuthCodeResponseType
authRequest.ClientId = ""
_, resp, err = apiClient.AuthorizeOAuthApp(authRequest)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
authRequest.ClientId = model.NewId()
_, resp, err = apiClient.AuthorizeOAuthApp(authRequest)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
// test callback URI doesn't have malformed query parameters
oappWithQueryParamInCallback := &model.OAuthApp{
Name: GenerateTestAppName(),
Homepage: "https://nowhere.com",
Description: "test",
CallbackUrls: []string{"https://nowhere.com?simply=lovely"},
CreatorId: th.SystemAdminUser.Id,
}
rapp, appErr = th.App.CreateOAuthApp(oappWithQueryParamInCallback)
require.Nil(t, appErr)
authRequest = &model.AuthorizeRequest{
ResponseType: model.AuthCodeResponseType,
ClientId: rapp.Id,
RedirectURI: rapp.CallbackUrls[0],
Scope: "",
State: "123",
}
uriResponse, _, err := apiClient.AuthorizeOAuthApp(authRequest)
require.NoError(t, err)
ru, _ = url.Parse(uriResponse)
require.NotEmpty(t, uriResponse, "redirect url should be set")
require.NotNil(t, ru, "redirect url unparseable")
// require no query parameter to have "?"
require.False(t, strings.Contains(ru.RawQuery, "?"), "should not malform query parameters")
require.NotEmpty(t, ru.Query().Get("code"), "authorization code not returned")
require.Equal(t, ru.Query().Get("state"), authRequest.State, "returned state doesn't match")
}
func TestDeauthorizeOAuthApp(t *testing.T) {
th := Setup(t).InitBasic()
th.Login(apiClient, th.SystemAdminUser)
defer th.TearDown()
enableOAuth := th.App.Config().ServiceSettings.EnableOAuthServiceProvider
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableOAuthServiceProvider = enableOAuth })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true })
oapp := &model.OAuthApp{
Name: GenerateTestAppName(),
Homepage: "https://nowhere.com",
Description: "test",
CallbackUrls: []string{"https://nowhere.com"},
CreatorId: th.SystemAdminUser.Id,
}
rapp, appErr := th.App.CreateOAuthApp(oapp)
require.Nil(t, appErr)
authRequest := &model.AuthorizeRequest{
ResponseType: model.AuthCodeResponseType,
ClientId: rapp.Id,
RedirectURI: rapp.CallbackUrls[0],
Scope: "",
State: "123",
}
_, _, err := apiClient.AuthorizeOAuthApp(authRequest)
require.NoError(t, err)
_, err = apiClient.DeauthorizeOAuthApp(rapp.Id)
require.NoError(t, err)
resp, err := apiClient.DeauthorizeOAuthApp("junk")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
_, err = apiClient.DeauthorizeOAuthApp(model.NewId())
require.NoError(t, err)
th.Logout(apiClient)
resp, err = apiClient.DeauthorizeOAuthApp(rapp.Id)
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
}
func TestOAuthAccessToken(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
th := Setup(t).InitBasic()
th.Login(apiClient, th.SystemAdminUser)
defer th.TearDown()
enableOAuth := th.App.Config().ServiceSettings.EnableOAuthServiceProvider
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableOAuthServiceProvider = enableOAuth })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true })
defaultRolePermissions := th.SaveDefaultRolePermissions()
defer func() {
th.RestoreDefaultRolePermissions(defaultRolePermissions)
}()
th.AddPermissionToRole(model.PermissionManageOAuth.Id, model.TeamUserRoleId)
th.AddPermissionToRole(model.PermissionManageOAuth.Id, model.SystemUserRoleId)
oauthApp := &model.OAuthApp{
Name: "TestApp5" + model.NewId(),
Homepage: "https://nowhere.com",
Description: "test",
CallbackUrls: []string{"https://nowhere.com"},
CreatorId: th.SystemAdminUser.Id,
}
oauthApp, appErr := th.App.CreateOAuthApp(oauthApp)
require.Nil(t, appErr)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = false })
data := url.Values{"grant_type": []string{"junk"}, "client_id": []string{"12345678901234567890123456"}, "client_secret": []string{"12345678901234567890123456"}, "code": []string{"junk"}, "redirect_uri": []string{oauthApp.CallbackUrls[0]}}
_, _, err := apiClient.GetOAuthAccessToken(data)
require.Error(t, err, "should have failed - oauth providing turned off")
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true })
authRequest := &model.AuthorizeRequest{
ResponseType: model.AuthCodeResponseType,
ClientId: oauthApp.Id,
RedirectURI: oauthApp.CallbackUrls[0],
Scope: "all",
State: "123",
}
redirect, _, err := apiClient.AuthorizeOAuthApp(authRequest)
require.NoError(t, err)
rurl, _ := url.Parse(redirect)
apiClient.Logout()
data = url.Values{"grant_type": []string{"junk"}, "client_id": []string{oauthApp.Id}, "client_secret": []string{oauthApp.ClientSecret}, "code": []string{rurl.Query().Get("code")}, "redirect_uri": []string{oauthApp.CallbackUrls[0]}}
_, _, err = apiClient.GetOAuthAccessToken(data)
require.Error(t, err, "should have failed - bad grant type")
data.Set("grant_type", model.AccessTokenGrantType)
data.Set("client_id", "")
_, _, err = apiClient.GetOAuthAccessToken(data)
require.Error(t, err, "should have failed - missing client id")
data.Set("client_id", "junk")
_, _, err = apiClient.GetOAuthAccessToken(data)
require.Error(t, err, "should have failed - bad client id")
data.Set("client_id", oauthApp.Id)
data.Set("client_secret", "")
_, _, err = apiClient.GetOAuthAccessToken(data)
require.Error(t, err, "should have failed - missing client secret")
data.Set("client_secret", "junk")
_, _, err = apiClient.GetOAuthAccessToken(data)
require.Error(t, err, "should have failed - bad client secret")
data.Set("client_secret", oauthApp.ClientSecret)
data.Set("code", "")
_, _, err = apiClient.GetOAuthAccessToken(data)
require.Error(t, err, "should have failed - missing code")
data.Set("code", "junk")
_, _, err = apiClient.GetOAuthAccessToken(data)
require.Error(t, err, "should have failed - bad code")
data.Set("code", rurl.Query().Get("code"))
data.Set("redirect_uri", "junk")
_, _, err = apiClient.GetOAuthAccessToken(data)
require.Error(t, err, "should have failed - non-matching redirect uri")
// reset data for successful request
data.Set("grant_type", model.AccessTokenGrantType)
data.Set("client_id", oauthApp.Id)
data.Set("client_secret", oauthApp.ClientSecret)
data.Set("code", rurl.Query().Get("code"))
data.Set("redirect_uri", oauthApp.CallbackUrls[0])
token := ""
refreshToken := ""
rsp, _, err := apiClient.GetOAuthAccessToken(data)
require.NoError(t, err)
require.NotEmpty(t, rsp.AccessToken, "access token not returned")
require.NotEmpty(t, rsp.RefreshToken, "refresh token not returned")
token, refreshToken = rsp.AccessToken, rsp.RefreshToken
require.Equal(t, rsp.TokenType, model.AccessTokenType, "access token type incorrect")
_, err = apiClient.DoAPIGet("/oauth_test", "")
require.NoError(t, err)
apiClient.SetOAuthToken("")
_, err = apiClient.DoAPIGet("/oauth_test", "")
require.Error(t, err, "should have failed - no access token provided")
apiClient.SetOAuthToken("badtoken")
_, err = apiClient.DoAPIGet("/oauth_test", "")
require.Error(t, err, "should have failed - bad token provided")
apiClient.SetOAuthToken(token)
_, err = apiClient.DoAPIGet("/oauth_test", "")
require.NoError(t, err)
_, _, err = apiClient.GetOAuthAccessToken(data)
require.Error(t, err, "should have failed - tried to reuse auth code")
data.Set("grant_type", model.RefreshTokenGrantType)
data.Set("client_id", oauthApp.Id)
data.Set("client_secret", oauthApp.ClientSecret)
data.Set("refresh_token", "")
data.Set("redirect_uri", oauthApp.CallbackUrls[0])
data.Del("code")
_, _, err = apiClient.GetOAuthAccessToken(data)
require.Error(t, err, "Should have failed - refresh token empty")
data.Set("refresh_token", refreshToken)
rsp, _, err = apiClient.GetOAuthAccessToken(data)
require.NoError(t, err)
require.NotEmpty(t, rsp.AccessToken, "access token not returned")
require.NotEmpty(t, rsp.RefreshToken, "refresh token not returned")
require.NotEqual(t, rsp.RefreshToken, refreshToken, "refresh token did not update")
require.Equal(t, rsp.TokenType, model.AccessTokenType, "access token type incorrect")
apiClient.SetOAuthToken(rsp.AccessToken)
_, err = apiClient.DoAPIGet("/oauth_test", "")
require.NoError(t, err)
data.Set("refresh_token", rsp.RefreshToken)
rsp, _, err = apiClient.GetOAuthAccessToken(data)
require.NoError(t, err)
require.NotEmpty(t, rsp.AccessToken, "access token not returned")
require.NotEmpty(t, rsp.RefreshToken, "refresh token not returned")
require.NotEqual(t, rsp.RefreshToken, refreshToken, "refresh token did not update")
require.Equal(t, rsp.TokenType, model.AccessTokenType, "access token type incorrect")
apiClient.SetOAuthToken(rsp.AccessToken)
_, err = apiClient.DoAPIGet("/oauth_test", "")
require.NoError(t, err)
authData := &model.AuthData{ClientId: oauthApp.Id, RedirectUri: oauthApp.CallbackUrls[0], UserId: th.BasicUser.Id, Code: model.NewId(), ExpiresIn: -1}
_, err = th.App.Srv().Store().OAuth().SaveAuthData(authData)
require.NoError(t, err)
data.Set("grant_type", model.AccessTokenGrantType)
data.Set("client_id", oauthApp.Id)
data.Set("client_secret", oauthApp.ClientSecret)
data.Set("redirect_uri", oauthApp.CallbackUrls[0])
data.Set("code", authData.Code)
data.Del("refresh_token")
_, _, err = apiClient.GetOAuthAccessToken(data)
require.Error(t, err, "Should have failed - code is expired")
apiClient.ClearOAuthToken()
}
func TestMobileLoginWithOAuth(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
c := &Context{
App: th.App,
AppContext: th.Context,
Params: &Params{
Service: "gitlab",
},
}
var siteURL = "http://localhost:8065"
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = siteURL })
translationFunc := i18n.GetUserTranslations("en")
c.AppContext.SetT(translationFunc)
c.Logger = th.TestLogger
provider := &MattermostTestProvider{}
einterfaces.RegisterOAuthProvider(model.ServiceGitlab, provider)
t.Run("Should include redirect URL in the output when valid URL Scheme is passed", func(t *testing.T) {
responseWriter := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, th.App.GetSiteURL()+"/oauth/gitlab/mobile_login?redirect_to="+url.QueryEscape("randomScheme://"), nil)
mobileLoginWithOAuth(c, responseWriter, request)
assert.Contains(t, responseWriter.Body.String(), "randomScheme://")
assert.NotContains(t, responseWriter.Body.String(), siteURL)
})
t.Run("Should not include the redirect URL consisting of javascript protocol", func(t *testing.T) {
responseWriter := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, th.App.GetSiteURL()+"/oauth/gitlab/mobile_login?redirect_to="+url.QueryEscape("javascript:alert('hello')"), nil)
mobileLoginWithOAuth(c, responseWriter, request)
assert.NotContains(t, responseWriter.Body.String(), "javascript:alert('hello')")
assert.Contains(t, responseWriter.Body.String(), siteURL)
})
t.Run("Should not include the redirect URL consisting of javascript protocol in mixed case", func(t *testing.T) {
responseWriter := httptest.NewRecorder()
request, _ := http.NewRequest(http.MethodGet, th.App.GetSiteURL()+"/oauth/gitlab/mobile_login?redirect_to="+url.QueryEscape("JaVasCript:alert('hello')"), nil)
mobileLoginWithOAuth(c, responseWriter, request)
assert.NotContains(t, responseWriter.Body.String(), "JaVasCript:alert('hello')")
assert.Contains(t, responseWriter.Body.String(), siteURL)
})
}
func TestOAuthComplete(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
th := Setup(t).InitBasic()
th.Login(apiClient, th.SystemAdminUser)
defer th.TearDown()
gitLabSettingsEnable := th.App.Config().GitLabSettings.Enable
gitLabSettingsAuthEndpoint := th.App.Config().GitLabSettings.AuthEndpoint
gitLabSettingsId := th.App.Config().GitLabSettings.Id
gitLabSettingsSecret := th.App.Config().GitLabSettings.Secret
gitLabSettingsTokenEndpoint := th.App.Config().GitLabSettings.TokenEndpoint
gitLabSettingsUserAPIEndpoint := th.App.Config().GitLabSettings.UserAPIEndpoint
enableOAuthServiceProvider := th.App.Config().ServiceSettings.EnableOAuthServiceProvider
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.GitLabSettings.Enable = gitLabSettingsEnable })
th.App.UpdateConfig(func(cfg *model.Config) { cfg.GitLabSettings.AuthEndpoint = gitLabSettingsAuthEndpoint })
th.App.UpdateConfig(func(cfg *model.Config) { cfg.GitLabSettings.Id = gitLabSettingsId })
th.App.UpdateConfig(func(cfg *model.Config) { cfg.GitLabSettings.Secret = gitLabSettingsSecret })
th.App.UpdateConfig(func(cfg *model.Config) { cfg.GitLabSettings.TokenEndpoint = gitLabSettingsTokenEndpoint })
th.App.UpdateConfig(func(cfg *model.Config) { cfg.GitLabSettings.UserAPIEndpoint = gitLabSettingsUserAPIEndpoint })
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableOAuthServiceProvider = enableOAuthServiceProvider })
}()
r, err := HTTPGet(apiClient.URL+"/login/gitlab/complete?code=123", apiClient.HTTPClient, "", true)
assert.Error(t, err)
closeBody(r)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.Enable = true })
r, err = HTTPGet(apiClient.URL+"/login/gitlab/complete?code=123&state=!#$#F@#Yˆ&~ñ", apiClient.HTTPClient, "", true)
assert.Error(t, err)
closeBody(r)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.AuthEndpoint = apiClient.URL + "/oauth/authorize" })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.Id = model.NewId() })
stateProps := map[string]string{}
stateProps["action"] = model.OAuthActionLogin
stateProps["team_id"] = th.BasicTeam.Id
stateProps["redirect_to"] = *th.App.Config().GitLabSettings.AuthEndpoint
state := base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(stateProps)))
r, err = HTTPGet(apiClient.URL+"/login/gitlab/complete?code=123&state="+url.QueryEscape(state), apiClient.HTTPClient, "", true)
assert.Error(t, err)
closeBody(r)
stateProps["hash"] = utils.HashSha256(*th.App.Config().GitLabSettings.Id)
state = base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(stateProps)))
r, err = HTTPGet(apiClient.URL+"/login/gitlab/complete?code=123&state="+url.QueryEscape(state), apiClient.HTTPClient, "", true)
assert.Error(t, err)
closeBody(r)
// We are going to use mattermost as the provider emulating gitlab
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true })
defaultRolePermissions := th.SaveDefaultRolePermissions()
defer func() {
th.RestoreDefaultRolePermissions(defaultRolePermissions)
}()
th.AddPermissionToRole(model.PermissionManageOAuth.Id, model.TeamUserRoleId)
th.AddPermissionToRole(model.PermissionManageOAuth.Id, model.SystemUserRoleId)
oauthApp := &model.OAuthApp{
Name: "TestApp5" + model.NewId(),
Homepage: "https://nowhere.com",
Description: "test",
CallbackUrls: []string{
apiClient.URL + "/signup/" + model.ServiceGitlab + "/complete",
apiClient.URL + "/login/" + model.ServiceGitlab + "/complete",
},
CreatorId: th.SystemAdminUser.Id,
IsTrusted: true,
}
oauthApp, appErr := th.App.CreateOAuthApp(oauthApp)
require.Nil(t, appErr)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.Id = oauthApp.Id })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.Secret = oauthApp.ClientSecret })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.AuthEndpoint = apiClient.URL + "/oauth/authorize" })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.TokenEndpoint = apiClient.URL + "/oauth/access_token" })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.UserAPIEndpoint = apiClient.APIURL + "/users/me" })
provider := &MattermostTestProvider{}
authRequest := &model.AuthorizeRequest{
ResponseType: model.AuthCodeResponseType,
ClientId: oauthApp.Id,
RedirectURI: oauthApp.CallbackUrls[0],
Scope: "all",
State: "123",
}
redirect, _, err := apiClient.AuthorizeOAuthApp(authRequest)
require.NoError(t, err)
rurl, _ := url.Parse(redirect)
code := rurl.Query().Get("code")
stateProps["action"] = model.OAuthActionEmailToSSO
delete(stateProps, "team_id")
stateProps["redirect_to"] = *th.App.Config().GitLabSettings.AuthEndpoint
stateProps["hash"] = utils.HashSha256(*th.App.Config().GitLabSettings.Id)
stateProps["redirect_to"] = "/oauth/authorize"
state = base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(stateProps)))
r, err = HTTPGet(apiClient.URL+"/login/"+model.ServiceGitlab+"/complete?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), apiClient.HTTPClient, "", false)
if err == nil {
closeBody(r)
}
einterfaces.RegisterOAuthProvider(model.ServiceGitlab, provider)
redirect, _, err = apiClient.AuthorizeOAuthApp(authRequest)
require.NoError(t, err)
rurl, _ = url.Parse(redirect)
code = rurl.Query().Get("code")
r, err = HTTPGet(apiClient.URL+"/login/"+model.ServiceGitlab+"/complete?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), apiClient.HTTPClient, "", false)
if err == nil {
closeBody(r)
}
_, nErr := th.App.Srv().Store().User().UpdateAuthData(
th.BasicUser.Id, model.ServiceGitlab, &th.BasicUser.Email, th.BasicUser.Email, true)
require.NoError(t, nErr)
redirect, _, err = apiClient.AuthorizeOAuthApp(authRequest)
require.NoError(t, err)
rurl, _ = url.Parse(redirect)
code = rurl.Query().Get("code")
stateProps["action"] = model.OAuthActionLogin
state = base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(stateProps)))
if r, err = HTTPGet(apiClient.URL+"/login/"+model.ServiceGitlab+"/complete?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), apiClient.HTTPClient, "", false); err == nil {
closeBody(r)
}
redirect, _, err = apiClient.AuthorizeOAuthApp(authRequest)
require.NoError(t, err)
rurl, _ = url.Parse(redirect)
code = rurl.Query().Get("code")
delete(stateProps, "action")
state = base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(stateProps)))
if r, err = HTTPGet(apiClient.URL+"/login/"+model.ServiceGitlab+"/complete?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), apiClient.HTTPClient, "", false); err == nil {
closeBody(r)
}
redirect, _, err = apiClient.AuthorizeOAuthApp(authRequest)
require.NoError(t, err)
rurl, _ = url.Parse(redirect)
code = rurl.Query().Get("code")
stateProps["action"] = model.OAuthActionSignup
state = base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(stateProps)))
if r, err := HTTPGet(apiClient.URL+"/login/"+model.ServiceGitlab+"/complete?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), apiClient.HTTPClient, "", false); err == nil {
closeBody(r)
}
}
func TestOAuthComplete_ErrorMessages(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
c := &Context{
App: th.App,
AppContext: th.Context,
Params: &Params{
Service: "gitlab",
},
}
translationFunc := i18n.GetUserTranslations("en")
c.AppContext.SetT(translationFunc)
c.Logger = mlog.CreateConsoleTestLogger(true, mlog.LvlDebug)
defer c.Logger.Shutdown()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.Enable = true })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true })
provider := &MattermostTestProvider{}
einterfaces.RegisterOAuthProvider(model.ServiceGitlab, provider)
responseWriter := httptest.NewRecorder()
// Renders for web & mobile app with webview
request, _ := http.NewRequest(http.MethodGet, th.App.GetSiteURL()+"/signup/gitlab/complete?code=1234", nil)
completeOAuth(c, responseWriter, request)
assert.Contains(t, responseWriter.Body.String(), "<!-- web error message -->")
// Renders for mobile app with redirect url
stateProps := map[string]string{}
stateProps["action"] = model.OAuthActionMobile
stateProps["redirect_to"] = th.App.Config().NativeAppSettings.AppCustomURLSchemes[0]
state := base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(stateProps)))
request2, _ := http.NewRequest(http.MethodGet, th.App.GetSiteURL()+"/signup/gitlab/complete?code=1234&state="+url.QueryEscape(state), nil)
completeOAuth(c, responseWriter, request2)
assert.Contains(t, responseWriter.Body.String(), "<!-- mobile app message -->")
}
func HTTPGet(url string, httpClient *http.Client, authToken string, followRedirect bool) (*http.Response, error) {
rq, _ := http.NewRequest("GET", url, nil)
rq.Close = true
if authToken != "" {
rq.Header.Set(model.HeaderAuth, authToken)
}
if !followRedirect {
httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
}
if rp, err := httpClient.Do(rq); err != nil {
return nil, err
} else if rp.StatusCode == 304 {
return rp, nil
} else if rp.StatusCode == 307 {
return rp, nil
} else if rp.StatusCode >= 300 {
defer closeBody(rp)
return rp, model.AppErrorFromJSON(rp.Body)
} else {
return rp, nil
}
}
func closeBody(r *http.Response) {
if r != nil && r.Body != nil {
io.ReadAll(r.Body)
r.Body.Close()
}
}
type MattermostTestProvider struct {
}
func (m *MattermostTestProvider) GetUserFromJSON(data io.Reader, tokenUser *model.User) (*model.User, error) {
var user model.User
if err := json.NewDecoder(data).Decode(&user); err != nil {
return nil, err
}
user.AuthData = &user.Email
return &user, nil
}
func (m *MattermostTestProvider) GetSSOSettings(config *model.Config, service string) (*model.SSOSettings, error) {
return &config.GitLabSettings, nil
}
func (m *MattermostTestProvider) GetUserFromIdToken(token string) (*model.User, error) {
return nil, nil
}
func (m *MattermostTestProvider) IsSameUser(dbUser, oauthUser *model.User) bool {
return dbUser.AuthData == oauthUser.AuthData
}
func GenerateTestAppName() string {
return "fakeoauthapp" + model.NewRandomString(10)
}
func checkHTTPStatus(t *testing.T, resp *model.Response, expectedStatus int) {
t.Helper()
require.NotNilf(t, resp, "Unexpected nil response, expected http status:%v", expectedStatus)
require.Equalf(t, expectedStatus, resp.StatusCode, "Expected http status:%v, got %v", expectedStatus, resp.StatusCode)
}
func CheckForbiddenStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusForbidden)
}
func CheckUnauthorizedStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusUnauthorized)
}
func CheckNotFoundStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusNotFound)
}
func CheckBadRequestStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusBadRequest)
}
func (th *TestHelper) Login(client *model.Client4, user *model.User) {
session := &model.Session{
UserId: user.Id,
Roles: user.GetRawRoles(),
IsOAuth: false,
}
session, _ = th.App.CreateSession(session)
client.AuthToken = session.Token
client.AuthType = model.HeaderBearer
}
func (th *TestHelper) Logout(client *model.Client4) {
client.AuthToken = ""
}
func (th *TestHelper) SaveDefaultRolePermissions() map[string][]string {
results := make(map[string][]string)
for _, roleName := range []string{
"system_user",
"system_admin",
"team_user",
"team_admin",
"channel_user",
"channel_admin",
} {
role, err1 := th.App.GetRoleByName(context.Background(), roleName)
if err1 != nil {
panic(err1)
}
results[roleName] = role.Permissions
}
return results
}
func (th *TestHelper) RestoreDefaultRolePermissions(data map[string][]string) {
for roleName, permissions := range data {
role, err1 := th.App.GetRoleByName(context.Background(), roleName)
if err1 != nil {
panic(err1)
}
if strings.Join(role.Permissions, " ") == strings.Join(permissions, " ") {
continue
}
role.Permissions = permissions
_, err2 := th.App.UpdateRole(role)
if err2 != nil {
panic(err2)
}
}
}
// func (th *TestHelper) RemovePermissionFromRole(permission string, roleName string) {
// utils.DisableDebugLogForTest()
// role, err1 := th.App.GetRoleByName(roleName)
// if err1 != nil {
// utils.EnableDebugLogForTest()
// panic(err1)
// }
// var newPermissions []string
// for _, p := range role.Permissions {
// if p != permission {
// newPermissions = append(newPermissions, p)
// }
// }
// if strings.Join(role.Permissions, " ") == strings.Join(newPermissions, " ") {
// utils.EnableDebugLogForTest()
// return
// }
// role.Permissions = newPermissions
// _, err2 := th.App.UpdateRole(role)
// if err2 != nil {
// utils.EnableDebugLogForTest()
// panic(err2)
// }
// utils.EnableDebugLogForTest()
// }
func (th *TestHelper) AddPermissionToRole(permission string, roleName string) {
role, err1 := th.App.GetRoleByName(context.Background(), roleName)
if err1 != nil {
panic(err1)
}
for _, existingPermission := range role.Permissions {
if existingPermission == permission {
return
}
}
role.Permissions = append(role.Permissions, permission)
_, err2 := th.App.UpdateRole(role)
if err2 != nil {
panic(err2)
}
}
func TestFullyQualifiedRedirectURL(t *testing.T) {
const siteURL = "https://xxx.yyy/mm"
for target, expected := range map[string]string{
"": "https://xxx.yyy/mm",
"/": "https://xxx.yyy/mm/",
"some-path": "https://xxx.yyy/mm/some-path",
"/some-path": "https://xxx.yyy/mm/some-path",
"/some-path/": "https://xxx.yyy/mm/some-path/",
} {
t.Run(target, func(t *testing.T) {
require.Equal(t, expected, fullyQualifiedRedirectURL(siteURL, target))
})
}
}

257
server/channels/web/params.go Обычный файл
Просмотреть файл

@@ -0,0 +1,257 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"net/http"
"net/url"
"strconv"
"strings"
"github.com/gorilla/mux"
"github.com/mattermost/mattermost-server/v6/model"
)
const (
PageDefault = 0
PerPageDefault = 60
PerPageMaximum = 200
LogsPerPageDefault = 10000
LogsPerPageMaximum = 10000
LimitDefault = 60
LimitMaximum = 200
)
type Params struct {
UserId 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
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
NotAssociatedToGroup string
ExcludeDefaultChannels bool
LimitAfter int
LimitBefore int
GroupIDs string
IncludeTotalCount bool
IncludeDeleted bool
FilterAllowReference bool
FilterParentTeamPermitted bool
CategoryId string
WarnMetricId string
ExportName string
ExcludePolicyConstrained bool
GroupSource model.GroupSource
FilterHasMember string
IncludeChannelMemberCount string
// Cloud
InvoiceId string
}
func ParamsFromRequest(r *http.Request) *Params {
params := &Params{}
props := mux.Vars(r)
query := r.URL.Query()
params.UserId = props["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"]
params.PluginId = props["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.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.Scope = query.Get("scope")
if val, err := strconv.Atoi(query.Get("page")); err != nil || val < 0 {
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"))
params.PerPage = getPerPageFromQuery(query)
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.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.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.WarnMetricId = props["warn_metric_id"]
params.ExportName = props["export_name"]
params.ExcludePolicyConstrained, _ = strconv.ParseBool(query.Get("exclude_policy_constrained"))
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")
return params
}
// getPerPageFromQuery returns the PerPage value from the given query.
// This function should be removed and the support for `pageSize`
// should be dropped after v1.46 of the mobile app is no longer supported
// https://mattermost.atlassian.net/browse/MM-38131
func getPerPageFromQuery(query url.Values) int {
val, err := strconv.Atoi(query.Get("per_page"))
if err != nil {
val, err = strconv.Atoi(query.Get("pageSize"))
}
if err != nil || val < 0 {
return PerPageDefault
} else if val > PerPageMaximum {
return PerPageMaximum
}
return val
}

487
server/channels/web/params_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,487 @@
// 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/v6/model"
)
func TestGetPerPageFromQuery(t *testing.T) {
t.Run("defaults should be set", func(t *testing.T) {
query := make(url.Values)
perPage := getPerPageFromQuery(query)
require.Equal(t, PerPageDefault, perPage)
})
t.Run("per_page should take priority", func(t *testing.T) {
query := make(url.Values)
query.Add("pageSize", "100")
query.Add("per_page", "50")
perPage := getPerPageFromQuery(query)
require.Equal(t, 50, perPage)
})
t.Run("pageSize should be used only if per_page is incorrectly set", func(t *testing.T) {
query := make(url.Values)
query.Add("pageSize", "100")
query.Add("per_page", "BAD VALUE")
perPage := getPerPageFromQuery(query)
require.Equal(t, 100, perPage)
})
}
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: boolPtr(true),
IsConfigured: boolPtr(true),
NotAssociatedToTeam: "this_team",
NotAssociatedToChannel: "this_channel",
FilterAllowReference: true,
FilterParentTeamPermitted: true,
Paginate: boolPtr(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,
},
},
}
for _, testCase := range testCases {
testCase := testCase
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
}
func boolPtr(b bool) *bool {
return &b
}

Просмотреть файл

@@ -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()
}
}

Просмотреть файл

@@ -0,0 +1,87 @@
// 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"
)
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) {
w.Write([]byte{})
}}
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) {
_, _, err := w.(*responseWriterWrapper).Hijack()
assert.Error(t, err)
assert.Equal(t, "Hijacker interface not supported by the wrapped ResponseWriter", err.Error())
}}
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) {
_, _, err := w.(*responseWriterWrapper).Hijack()
assert.NoError(t, err)
}}
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) {
w.Write([]byte{})
w.(*responseWriterWrapper).Flush()
}}
handler.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.StatusCode())
}

211
server/channels/web/saml.go Обычный файл
Просмотреть файл

@@ -0,0 +1,211 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
b64 "encoding/base64"
"html"
"net/http"
"strconv"
"strings"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/audit"
"github.com/mattermost/mattermost-server/v6/server/channels/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
const maxSAMLResponseSize = 2 * 1024 * 1024 // 2MB
func (w *Web) InitSaml() {
w.MainRouter.Handle("/login/sso/saml", w.APIHandler(loginWithSaml)).Methods("GET")
w.MainRouter.Handle("/login/sso/saml", w.APIHandlerTrustRequester(completeSaml)).Methods("POST")
}
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
}
teamId, err := c.App.GetTeamIdFromQuery(r.URL.Query())
if err != nil {
c.Err = err
return
}
action := r.URL.Query().Get("action")
isMobile := action == model.OAuthActionMobile
redirectURL := html.EscapeString(r.URL.Query().Get("redirect_to"))
relayProps := map[string]string{}
relayState := ""
if action != "" {
relayProps["team_id"] = teamId
relayProps["action"] = action
if action == model.OAuthActionEmailToSSO {
relayProps["email"] = r.URL.Query().Get("email")
}
}
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
}
relayProps[model.UserAuthServiceIsMobile] = strconv.FormatBool(isMobile)
if len(relayProps) > 0 {
relayState = b64.StdEncoding.EncodeToString([]byte(model.MapToJSON(relayProps)))
}
data, err := samlInterface.BuildRequest(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 != "" {
stateStr := ""
b, err := b64.StdEncoding.DecodeString(relayState)
if err != nil {
c.Err = model.NewAppError("completeSaml", "api.user.authorize_oauth_user.invalid_state.app_error", nil, "", http.StatusFound).Wrap(err)
return
}
stateStr = string(b)
relayProps = model.MapFromJSON(strings.NewReader(stateStr))
}
auditRec := c.MakeAuditRecord("completeSaml", audit.Fail)
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)
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)
mlog.Error(err.Error())
handleError(err)
return
}
user, err := samlInterface.DoLogin(c.AppContext, encodedXML, relayProps)
if err != nil {
c.LogAudit("fail")
mlog.Error(err.Error())
handleError(err)
return
}
if err = c.App.CheckUserAllAuthenticationCriteria(user, ""); err != nil {
mlog.Error(err.Error())
handleError(err)
return
}
switch action {
case model.OAuthActionSignup:
if teamId := relayProps["team_id"]; teamId != "" {
if err = c.App.AddUserToTeamByTeamId(c.AppContext, teamId, user); err != nil {
c.LogErrorByCode(err)
break
}
c.App.AddDirectChannels(c.AppContext, teamId, user)
}
case model.OAuthActionEmailToSSO:
if err = c.App.RevokeAllSessions(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))
}
})
}
auditRec.AddMeta("obtained_user_id", user.Id)
c.LogAuditWithUserId(user.Id, "obtained user")
err = c.App.DoLogin(c.AppContext, w, r, user, "", isMobile, false, true)
if err != nil {
mlog.Error(err.Error())
handleError(err)
return
}
auditRec.Success()
c.LogAuditWithUserId(user.Id, "success")
c.App.AttachSessionCookies(c.AppContext, w, r)
if hasRedirectURL {
if isMobile {
// Mobile clients with redirect url support
redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{
model.SessionCookieToken: c.AppContext.Session().Token,
model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(),
})
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)
}
}

174
server/channels/web/static.go Обычный файл
Просмотреть файл

@@ -0,0 +1,174 @@
// 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/mattermost/gziphandler"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/utils"
"github.com/mattermost/mattermost-server/v6/server/channels/utils/fileutils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
"github.com/mattermost/mattermost-server/v6/server/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("clientDir", 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 = gziphandler.GzipHandler(staticHandler)
pluginHandler = gziphandler.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("GET")
// 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")
data := renderUnsupportedBrowser(c.AppContext, r)
c.App.Srv().TemplatesContainer().Render(w, "unsupported_browser", data)
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 {
http.Error(w, err.Error(), http.StatusNotFound)
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")
w.Write(contents)
}
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
}
w.Write(robotsTxt)
}
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 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
}

160
server/channels/web/unsupported_browser.go Обычный файл
Просмотреть файл

@@ -0,0 +1,160 @@
// 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/v6/server/channels/app/request"
"github.com/mattermost/mattermost-server/v6/server/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(ctx *request.Context, r *http.Request) templates.Data {
data := templates.Data{
Props: map[string]any{
"DownloadAppOrUpgradeBrowserString": ctx.T("web.error.unsupported_browser.download_app_or_upgrade_browser"),
"LearnMoreString": ctx.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"] = ctx.T("web.error.unsupported_browser.no_longer_support_version")
} else {
data.Props["NoLongerSupportString"] = ctx.T("web.error.unsupported_browser.no_longer_support")
}
// Mattermost app version
if isWindows {
data.Props["App"] = renderMattermostAppWindows(ctx)
} else if isMacOSX {
data.Props["App"] = renderMattermostAppMac(ctx)
}
// 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(ctx), renderBrowserFirefox(ctx)}
if isSafari {
browsers = append(browsers, renderBrowserSafari(ctx))
}
data.Props["Browsers"] = browsers
// If on Windows 10, show link to Edge
if isWindows10 {
data.Props["SystemBrowser"] = renderSystemBrowserEdge(ctx, r)
}
return data
}
func renderMattermostAppMac(ctx *request.Context) MattermostApp {
return MattermostApp{
"/static/images/browser-icons/mac.png",
ctx.T("web.error.unsupported_browser.download_the_app"),
ctx.T("web.error.unsupported_browser.min_os_version.mac"),
ctx.T("web.error.unsupported_browser.download"),
"https://mattermost.com/download/#mattermostApps",
ctx.T("web.error.unsupported_browser.install_guide.mac"),
"https://docs.mattermost.com/install/desktop.html#mac-os-x-10-9",
}
}
func renderMattermostAppWindows(ctx *request.Context) MattermostApp {
return MattermostApp{
"/static/images/browser-icons/windows.svg",
ctx.T("web.error.unsupported_browser.download_the_app"),
ctx.T("web.error.unsupported_browser.min_os_version.windows"),
ctx.T("web.error.unsupported_browser.download"),
"https://mattermost.com/download/#mattermostApps",
ctx.T("web.error.unsupported_browser.install_guide.windows"),
"https://docs.mattermost.com/install/desktop.html#windows-10-windows-8-1-windows-7",
}
}
func renderBrowserChrome(ctx *request.Context) Browser {
return Browser{
"/static/images/browser-icons/chrome.svg",
ctx.T("web.error.unsupported_browser.browser_title.chrome"),
ctx.T("web.error.unsupported_browser.min_browser_version.chrome"),
"http://www.google.com/chrome",
ctx.T("web.error.unsupported_browser.browser_get_latest.chrome"),
}
}
func renderBrowserFirefox(ctx *request.Context) Browser {
return Browser{
"/static/images/browser-icons/firefox.svg",
ctx.T("web.error.unsupported_browser.browser_title.firefox"),
ctx.T("web.error.unsupported_browser.min_browser_version.firefox"),
"https://www.mozilla.org/firefox/new/",
ctx.T("web.error.unsupported_browser.browser_get_latest.firefox"),
}
}
func renderBrowserSafari(ctx *request.Context) Browser {
return Browser{
"/static/images/browser-icons/safari.svg",
ctx.T("web.error.unsupported_browser.browser_title.safari"),
ctx.T("web.error.unsupported_browser.min_browser_version.safari"),
"macappstore://showUpdatesPage",
ctx.T("web.error.unsupported_browser.browser_get_latest.safari"),
}
}
func renderSystemBrowserEdge(ctx *request.Context, r *http.Request) SystemBrowser {
return SystemBrowser{
"/static/images/browser-icons/edge.svg",
ctx.T("web.error.unsupported_browser.browser_title.edge"),
ctx.T("web.error.unsupported_browser.min_browser_version.edge"),
ctx.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",
ctx.T("web.error.unsupported_browser.system_browser_or"),
ctx.T("web.error.unsupported_browser.system_browser_make_default"),
}
}

107
server/channels/web/web.go Обычный файл
Просмотреть файл

@@ -0,0 +1,107 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"net/http"
"path"
"strings"
"github.com/avct/uasurfer"
"github.com/gorilla/mux"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/app"
"github.com/mattermost/mattermost-server/v6/server/channels/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
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.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 Handle404(a app.AppIface, 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?"
w.Write([]byte(err.ToJSON()))
} 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.AppIface, r *http.Request) bool {
subpath, _ := utils.GetSubpathFromConfig(a.Config())
return strings.HasPrefix(r.URL.Path, path.Join(subpath, "api")+"/")
}
func IsWebhookCall(a app.AppIface, r *http.Request) bool {
subpath, _ := utils.GetSubpathFromConfig(a.Config())
return strings.HasPrefix(r.URL.Path, path.Join(subpath, "hooks")+"/")
}
func IsOAuthAPICall(a app.AppIface, 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") {
return true
}
return false
}
func ReturnStatusOK(w http.ResponseWriter) {
m := make(map[string]string)
m[model.STATUS] = model.StatusOk
w.Write([]byte(model.MapToJSON(m)))
}

484
server/channels/web/web_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,484 @@
// 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"
"os/exec"
"path"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin"
"github.com/mattermost/mattermost-server/v6/server/channels/app"
"github.com/mattermost/mattermost-server/v6/server/channels/app/request"
"github.com/mattermost/mattermost-server/v6/server/channels/store/localcachelayer"
"github.com/mattermost/mattermost-server/v6/server/channels/store/storetest/mocks"
"github.com/mattermost/mattermost-server/v6/server/channels/utils"
"github.com/mattermost/mattermost-server/v6/server/config"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
var apiClient *model.Client4
var URL string
type TestHelper struct {
App app.AppIface
Context *request.Context
Server *app.Server
Web *Web
BasicUser *model.User
BasicChannel *model.Channel
BasicTeam *model.Team
SystemAdminUser *model.User
tempWorkspace string
IncludeCacheLayer bool
TestLogger *mlog.Logger
boardsProductEnvValue string
playbooksDisableEnvValue string
}
func SetupWithStoreMock(tb testing.TB) *TestHelper {
if testing.Short() {
tb.SkipNow()
}
th := setupTestHelper(tb, false)
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)
}
func setupTestHelper(tb testing.TB, includeCacheLayer bool) *TestHelper {
memoryStore := config.NewTestMemoryStore()
newConfig := memoryStore.Get().Clone()
*newConfig.AnnouncementSettings.AdminNoticesEnabled = false
*newConfig.AnnouncementSettings.UserNoticesEnabled = false
*newConfig.PluginSettings.AutomaticPrepackagedPlugins = false
// disable Boards through the feature flag
boardsProductEnvValue := os.Getenv("MM_FEATUREFLAGS_BoardsProduct")
os.Unsetenv("MM_FEATUREFLAGS_BoardsProduct")
newConfig.FeatureFlags.BoardsProduct = false
// disable Playbooks (temporarily) as it causes many more mocked methods to get
// called, and cannot receieve a mocked database.
playbooksDisableEnvValue := os.Getenv("MM_DISABLE_PLAYBOOKS")
os.Setenv("MM_DISABLE_PLAYBOOKS", "true")
memoryStore.Set(newConfig)
var options []app.Option
options = append(options, app.ConfigStore(memoryStore))
options = append(options, app.StoreOverride(mainHelper.Store))
testLogger, _ := mlog.NewLogger()
logCfg, _ := config.MloggerConfigFromLoggerConfig(&newConfig.LogSettings, nil, config.GetLogFileLocation)
if errCfg := testLogger.ConfigureTargets(logCfg, nil); errCfg != nil {
panic("failed to configure test logger: " + errCfg.Error())
}
// lock logger config so server init cannot override it during testing.
testLogger.LockConfiguration()
options = append(options, app.SetLogger(testLogger))
s, err := app.NewServer(options...)
if err != nil {
panic(err)
}
if includeCacheLayer {
// Adds the cache layer to the test store
var st localcachelayer.LocalCacheStore
st, err = localcachelayer.NewLocalCacheLayer(s.Store(), s.GetMetrics(), s.Platform().Cluster(), s.Platform().CacheProvider())
if err != nil {
panic(err)
}
s.SetStore(st)
}
a := app.New(app.ServerConnector(s.Channels()))
prevListenAddress := *s.Config().ServiceSettings.ListenAddress
a.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ListenAddress = ":0" })
serverErr := s.Start()
if serverErr != nil {
panic(serverErr)
}
a.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ListenAddress = prevListenAddress })
// Disable strict password requirements for test
a.UpdateConfig(func(cfg *model.Config) {
*cfg.PasswordSettings.MinimumLength = 5
*cfg.PasswordSettings.Lowercase = false
*cfg.PasswordSettings.Uppercase = false
*cfg.PasswordSettings.Symbol = false
*cfg.PasswordSettings.Number = false
})
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,
boardsProductEnvValue: boardsProductEnvValue,
playbooksDisableEnvValue: playbooksDisableEnvValue,
}
th.Context.SetLogger(testLogger)
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() *TestHelper {
th.SystemAdminUser, _ = th.App.CreateUser(th.Context, &model.User{Email: model.NewId() + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "passwd1", EmailVerified: true, Roles: model.SystemAdminRoleId})
user, _ := th.App.CreateUser(th.Context, &model.User{Email: model.NewId() + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "passwd1", EmailVerified: true, Roles: model.SystemUserRoleId})
team, _ := th.App.CreateTeam(th.Context, &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: user.Email, Type: model.TeamOpen})
th.App.JoinUserToTeam(th.Context, team, user, "")
channel, _ := th.App.CreateChannel(th.Context, &model.Channel{DisplayName: "Test API Name", Name: "zz" + model.NewId() + "a", Type: model.ChannelTypeOpen, TeamId: team.Id, CreatorId: user.Id}, true)
th.BasicUser = user
th.BasicChannel = channel
th.BasicTeam = team
return th
}
func (th *TestHelper) TearDown() {
// reset board and playbooks product setting to original
if th.boardsProductEnvValue != "" {
os.Setenv("MM_FEATUREFLAGS_BoardsProduct", th.boardsProductEnvValue)
}
if th.playbooksDisableEnvValue != "" {
os.Setenv("MM_DISABLE_PLAYBOOKS", th.playbooksDisableEnvValue)
} else {
os.Unsetenv("MM_DISABLE_PLAYBOOKS")
}
if th.IncludeCacheLayer {
// Clean all the caches
th.App.Srv().InvalidateAllCaches()
}
th.Server.Shutdown()
}
func TestStaticFilesRequest(t *testing.T) {
th := Setup(t).InitPlugins()
defer th.TearDown()
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/v6/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": []}}`
os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(pluginManifest), 0600)
// 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, _ := http.NewRequest("GET", "/static/plugins/com.mattermost.sample/com.mattermost.sample_724ed0e2ebb2b841_bundle.js", nil)
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, _ = http.NewRequest("GET", "/static/plugins/com.mattermost.sample/com.mattermost.sample_724ed0e2ebb2b841_bundle.js", nil)
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, _ = http.NewRequest("GET", "/static/plugins/com.mattermost.sample/com.mattermost.sample_724ed0e2ebb2b841_bundle.js", nil)
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, _ = http.NewRequest("GET", "/static/plugins/com.mattermost.sample/404.js", nil)
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()
defer th.TearDown()
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, false, th.App.Log(), nil)
require.NoError(t, err)
pluginID := "com.mattermost.sample"
pluginCode :=
`
package main
import (
"github.com/mattermost/mattermost-server/v6/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": []}}`
os.WriteFile(filepath.Join(pluginDir, pluginID, "plugin.json"), []byte(pluginManifest), 0600)
// 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")
os.MkdirAll(htmlFolderPath, os.ModePerm)
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, _ := http.NewRequest("GET", "/plugins/com.mattermost.sample/public/hello.html", nil)
res := httptest.NewRecorder()
th.Web.MainRouter.ServeHTTP(res, req)
assert.Equal(t, helloHTML, res.Body.String())
req, _ = http.NewRequest("GET", "/plugins/com.mattermost.sample/nefarious-file-access.html", nil)
res = httptest.NewRecorder()
th.Web.MainRouter.ServeHTTP(res, req)
assert.Equal(t, 404, res.Code)
req, _ = http.NewRequest("GET", "/plugins/com.mattermost.sample/public/../nefarious-file-access.html", nil)
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()
defer th.TearDown()
wd, _ := os.Getwd()
cmd := exec.Command("ls", path.Join(wd, "client", "plugins"))
cmd.Stdout = os.Stdout
cmd.Run()
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, _ := http.NewRequest("GET", "/", nil)
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, _ = http.NewRequest("GET", "/static/"+fakeMainBundleName, nil)
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, _ = http.NewRequest("GET", "/static/remote_entry.js", nil)
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, _ = http.NewRequest("GET", "/static/products/boards/remote_entry.js", nil)
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)
})
}
}

135
server/channels/web/webhook.go Обычный файл
Просмотреть файл

@@ -0,0 +1,135 @@
// 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/v6/model"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
func (w *Web) InitWebhooks() {
w.MainRouter.Handle("/hooks/commands/{id:[A-Za-z0-9]+}", w.APIHandlerTrustRequester(commandWebhook)).Methods("POST")
w.MainRouter.Handle("/hooks/{id:[A-Za-z0-9]+}", w.APIHandlerTrustRequester(incomingWebhook)).Methods("POST")
}
func incomingWebhook(c *Context, w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
id := params["id"]
r.ParseForm()
var err *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",
"api.webhook.incoming.error",
nil,
"webhook_id="+id+", error: "+mimeErr.Error(),
http.StatusBadRequest,
)
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", string(payload)))
}
mlog.Debug("Incoming webhook received", fields...)
}
}
}()
if mediaType == "application/x-www-form-urlencoded" {
payload := strings.NewReader(r.FormValue("payload"))
incomingWebhookPayload, err = decodePayload(payload)
if err != nil {
c.Err = err
return
}
} else if mediaType == "multipart/form-data" {
r.ParseMultipartForm(0)
decoder := schema.NewDecoder()
err := decoder.Decode(incomingWebhookPayload, r.PostForm)
if err != nil {
c.Err = model.NewAppError("incomingWebhook",
"api.webhook.incoming.error",
nil,
"webhook_id="+id+", error: "+err.Error(),
http.StatusBadRequest,
)
return
}
} else {
incomingWebhookPayload, err = decodePayload(r.Body)
if err != nil {
c.Err = err
return
}
}
err = c.App.HandleIncomingWebhook(c.AppContext, id, incomingWebhookPayload)
if err != nil {
c.Err = err
return
}
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("ok"))
}
func commandWebhook(c *Context, w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
id := params["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", nil, "", http.StatusBadRequest).Wrap(err)
return
}
appErr := c.App.HandleCommandWebhook(c.AppContext, id, response)
if appErr != nil {
c.Err = appErr
return
}
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("ok"))
}
func decodePayload(payload io.Reader) (*model.IncomingWebhookRequest, *model.AppError) {
incomingWebhookPayload, decodeError := model.IncomingWebhookRequestFromJSON(payload)
if decodeError != nil {
return nil, decodeError
}
return incomingWebhookPayload, nil
}

277
server/channels/web/webhook_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,277 @@
// 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/v6/model"
)
func TestIncomingWebhook(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
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
tooLongText := ""
for i := 0; i < 8200; i++ {
tooLongText += "a"
}
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)
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()
defer th.TearDown()
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 i := 0; i < 5; i++ {
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)
}