Mono repo -> Master (#22553)
Combines the following repositories into one: https://github.com/mattermost/mattermost-server https://github.com/mattermost/mattermost-webapp https://github.com/mattermost/focalboard https://github.com/mattermost/mattermost-plugin-playbooks
Этот коммит содержится в:
755
server/channels/web/context.go
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
|
||||
}
|
||||
62
server/channels/web/response_writer_wrapper.go
Обычный файл
62
server/channels/web/response_writer_wrapper.go
Обычный файл
@@ -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()
|
||||
}
|
||||
}
|
||||
87
server/channels/web/response_writer_wrapper_test.go
Обычный файл
87
server/channels/web/response_writer_wrapper_test.go
Обычный файл
@@ -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
Обычный файл
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
Обычный файл
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 = ¬FoundNoCacheResponseWriter{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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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)
|
||||
}
|
||||
Ссылка в новой задаче
Block a user