snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
883
server/channels/web/context.go
Normal file
883
server/channels/web/context.go
Normal file
@@ -0,0 +1,883 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/i18n"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||
)
|
||||
|
||||
type Context struct {
|
||||
App *app.App
|
||||
AppContext request.CTX
|
||||
Logger *mlog.Logger
|
||||
Params *Params
|
||||
Err *model.AppError
|
||||
siteURLHeader string
|
||||
}
|
||||
|
||||
// LogAuditRec logs an audit record using default LevelAPI.
|
||||
func (c *Context) LogAuditRec(rec *model.AuditRecord) {
|
||||
// finish populating the context data, in case the session wasn't available during MakeAuditRecord
|
||||
// (e.g., api4/user.go login)
|
||||
if rec.Actor.UserId == "" {
|
||||
rec.Actor.UserId = c.AppContext.Session().UserId
|
||||
}
|
||||
if rec.Actor.SessionId == "" {
|
||||
rec.Actor.SessionId = c.AppContext.Session().Id
|
||||
}
|
||||
if _, exists := rec.Meta[model.SessionPropUserAccessTokenId]; !exists && c.AppContext.Session().IsUserAccessToken() {
|
||||
rec.Meta[model.SessionPropUserAccessTokenId] = c.AppContext.Session().Props[model.SessionPropUserAccessTokenId]
|
||||
}
|
||||
|
||||
c.LogAuditRecWithLevel(rec, app.LevelAPI)
|
||||
}
|
||||
|
||||
// LogAuditRecWithLevel logs an audit record using specified Level.
|
||||
// If the context is flagged with a permissions error then `level`
|
||||
// is ignored and the audit record is emitted with `LevelPerms`.
|
||||
func (c *Context) LogAuditRecWithLevel(rec *model.AuditRecord, level mlog.Level) {
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
if c.Err != nil {
|
||||
rec.AddErrorCode(c.Err.StatusCode)
|
||||
rec.AddErrorDesc(c.Err.Error())
|
||||
if c.Err.Id == "api.context.permissions.app_error" {
|
||||
level = app.LevelPerms
|
||||
}
|
||||
rec.Fail()
|
||||
}
|
||||
c.App.Srv().Audit.LogRecord(level, *rec)
|
||||
}
|
||||
|
||||
// MakeAuditRecord creates an audit record pre-populated with data from this context.
|
||||
func (c *Context) MakeAuditRecord(event string, initialStatus string) *model.AuditRecord {
|
||||
meta := map[string]any{
|
||||
model.AuditKeyAPIPath: c.AppContext.Path(),
|
||||
model.AuditKeyClusterID: c.App.GetClusterId(),
|
||||
}
|
||||
if c.AppContext.Session().IsUserAccessToken() {
|
||||
meta[model.SessionPropUserAccessTokenId] = c.AppContext.Session().Props[model.SessionPropUserAccessTokenId]
|
||||
}
|
||||
|
||||
rec := &model.AuditRecord{
|
||||
EventName: event,
|
||||
Status: initialStatus,
|
||||
Actor: model.AuditEventActor{
|
||||
UserId: c.AppContext.Session().UserId,
|
||||
SessionId: c.AppContext.Session().Id,
|
||||
Client: c.AppContext.UserAgent(),
|
||||
IpAddress: c.AppContext.IPAddress(),
|
||||
XForwardedFor: c.AppContext.XForwardedFor(),
|
||||
},
|
||||
Meta: meta,
|
||||
EventData: model.AuditEventData{
|
||||
Parameters: map[string]any{},
|
||||
PriorState: map[string]any{},
|
||||
ResultState: map[string]any{},
|
||||
ObjectType: "",
|
||||
},
|
||||
}
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
func (c *Context) LogAudit(extraInfo string) {
|
||||
audit := &model.Audit{UserId: c.AppContext.Session().UserId, IpAddress: c.AppContext.IPAddress(), Action: c.AppContext.Path(), ExtraInfo: extraInfo, SessionId: c.AppContext.Session().Id}
|
||||
if err := c.App.Srv().Store().Audit().Save(audit); err != nil {
|
||||
appErr := model.NewAppError("LogAudit", "app.audit.save.saving.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
c.LogErrorByCode(appErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Context) LogAuditWithUserId(userId, extraInfo string) {
|
||||
if c.AppContext.Session().UserId != "" {
|
||||
extraInfo = strings.TrimSpace(extraInfo + " session_user=" + c.AppContext.Session().UserId)
|
||||
}
|
||||
|
||||
audit := &model.Audit{UserId: userId, IpAddress: c.AppContext.IPAddress(), Action: c.AppContext.Path(), ExtraInfo: extraInfo, SessionId: c.AppContext.Session().Id}
|
||||
if err := c.App.Srv().Store().Audit().Save(audit); err != nil {
|
||||
appErr := model.NewAppError("LogAuditWithUserId", "app.audit.save.saving.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
c.LogErrorByCode(appErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Context) LogErrorByCode(err *model.AppError) {
|
||||
code := err.StatusCode
|
||||
msg := err.SystemMessage(i18n.TDefault)
|
||||
fields := []mlog.Field{
|
||||
mlog.String("err_where", err.Where),
|
||||
mlog.Int("http_code", err.StatusCode),
|
||||
mlog.String("error", err.Error()),
|
||||
}
|
||||
switch {
|
||||
case (code >= http.StatusBadRequest && code < http.StatusInternalServerError) ||
|
||||
err.Id == "web.check_browser_compatibility.app_error":
|
||||
c.Logger.Debug(msg, fields...)
|
||||
case code == http.StatusNotImplemented:
|
||||
c.Logger.Info(msg, fields...)
|
||||
default:
|
||||
c.Logger.Error(msg, fields...)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Context) IsSystemAdmin() bool {
|
||||
return c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
||||
}
|
||||
|
||||
func (c *Context) SessionRequired() {
|
||||
if !*c.App.Config().ServiceSettings.EnableUserAccessTokens &&
|
||||
c.AppContext.Session().Props[model.SessionPropType] == model.SessionTypeUserAccessToken &&
|
||||
c.AppContext.Session().Props[model.SessionPropIsBot] != model.SessionPropIsBotValue {
|
||||
c.Err = model.NewAppError("", "api.context.session_expired.app_error", nil, "UserAccessToken", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if c.AppContext.Session().UserId == "" {
|
||||
c.Err = model.NewAppError("", "api.context.session_expired.app_error", nil, "UserRequired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Context) CloudKeyRequired() {
|
||||
if license := c.App.Channels().License(); license == nil || !license.IsCloud() || c.AppContext.Session().Props[model.SessionPropType] != model.SessionTypeCloudKey {
|
||||
c.Err = model.NewAppError("", "api.context.session_expired.app_error", nil, "TokenRequired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Context) RemoteClusterTokenRequired() {
|
||||
if license := c.App.Channels().License(); license == nil || !license.HasRemoteClusterService() || c.AppContext.Session().Props[model.SessionPropType] != model.SessionTypeRemoteclusterToken {
|
||||
c.Err = model.NewAppError("", "api.context.session_expired.app_error", nil, "TokenRequired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Context) MfaRequired(method string) {
|
||||
if appErr := c.App.MFARequired(c.AppContext, method); appErr != nil {
|
||||
c.Err = appErr
|
||||
}
|
||||
}
|
||||
|
||||
// ExtendSessionExpiryIfNeeded will update Session.ExpiresAt based on session lengths in config.
|
||||
// Session cookies will be resent to the client with updated max age.
|
||||
func (c *Context) ExtendSessionExpiryIfNeeded(w http.ResponseWriter, r *http.Request) {
|
||||
if ok := c.App.ExtendSessionExpiryIfNeeded(c.AppContext, c.AppContext.Session()); ok {
|
||||
c.App.AttachSessionCookies(c.AppContext, w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Context) RemoveSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
subpath, _ := utils.GetSubpathFromConfig(c.App.Config())
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: model.SessionCookieToken,
|
||||
Value: "",
|
||||
Path: subpath,
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
}
|
||||
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
|
||||
func (c *Context) SetInvalidParam(parameter string) {
|
||||
c.Err = NewInvalidParamError(parameter)
|
||||
}
|
||||
|
||||
func (c *Context) SetInvalidParamWithDetails(parameter string, details string) {
|
||||
c.Err = NewInvalidParamDetailedError(parameter, details)
|
||||
}
|
||||
|
||||
func (c *Context) SetInvalidParamWithErr(parameter string, err error) {
|
||||
c.Err = NewInvalidParamError(parameter).Wrap(err)
|
||||
}
|
||||
|
||||
func (c *Context) SetInvalidURLParam(parameter string) {
|
||||
c.Err = NewInvalidURLParamError(parameter)
|
||||
}
|
||||
|
||||
func (c *Context) SetServerBusyError() {
|
||||
c.Err = NewServerBusyError()
|
||||
}
|
||||
|
||||
func (c *Context) SetInvalidRemoteIdError(id string) {
|
||||
c.Err = NewInvalidRemoteIdError(id)
|
||||
}
|
||||
|
||||
func (c *Context) SetInvalidRemoteClusterTokenError() {
|
||||
c.Err = NewInvalidRemoteClusterTokenError()
|
||||
}
|
||||
|
||||
func (c *Context) SetJSONEncodingError(err error) {
|
||||
c.Err = NewJSONEncodingError(err)
|
||||
}
|
||||
|
||||
func (c *Context) SetCommandNotFoundError() {
|
||||
c.Err = model.NewAppError("GetCommand", "store.sql_command.save.get.app_error", nil, "", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func (c *Context) HandleEtag(etag string, routeName string, w http.ResponseWriter, r *http.Request) bool {
|
||||
metrics := c.App.Metrics()
|
||||
if et := r.Header.Get(model.HeaderEtagClient); etag != "" {
|
||||
if et == etag {
|
||||
w.Header().Set(model.HeaderEtagServer, etag)
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
if metrics != nil {
|
||||
metrics.IncrementEtagHitCounter(routeName)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if metrics != nil {
|
||||
metrics.IncrementEtagMissCounter(routeName)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func NewInvalidParamDetailedError(parameter string, details string) *model.AppError {
|
||||
err := model.NewAppError("Context", "api.context.invalid_body_param.app_error", map[string]any{"Name": parameter}, details, http.StatusBadRequest)
|
||||
return err
|
||||
}
|
||||
func NewInvalidParamError(parameter string) *model.AppError {
|
||||
err := model.NewAppError("Context", "api.context.invalid_body_param.app_error", map[string]any{"Name": parameter}, "", http.StatusBadRequest)
|
||||
return err
|
||||
}
|
||||
|
||||
func NewInvalidURLParamError(parameter string) *model.AppError {
|
||||
err := model.NewAppError("Context", "api.context.invalid_url_param.app_error", map[string]any{"Name": parameter}, "", http.StatusBadRequest)
|
||||
return err
|
||||
}
|
||||
|
||||
func NewServerBusyError() *model.AppError {
|
||||
err := model.NewAppError("Context", "api.context.server_busy.app_error", nil, "", http.StatusServiceUnavailable)
|
||||
return err
|
||||
}
|
||||
|
||||
func NewInvalidRemoteIdError(parameter string) *model.AppError {
|
||||
err := model.NewAppError("Context", "api.context.remote_id_invalid.app_error", map[string]any{"RemoteId": parameter}, "", http.StatusBadRequest)
|
||||
return err
|
||||
}
|
||||
|
||||
func NewInvalidRemoteClusterTokenError() *model.AppError {
|
||||
err := model.NewAppError("Context", "api.context.remote_id_invalid.app_error", nil, "", http.StatusUnauthorized)
|
||||
return err
|
||||
}
|
||||
|
||||
func NewJSONEncodingError(err error) *model.AppError {
|
||||
appErr := model.NewAppError("Context", "api.context.json_encoding.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
return appErr
|
||||
}
|
||||
|
||||
func (c *Context) SetPermissionError(permissions ...*model.Permission) {
|
||||
c.Err = model.MakePermissionError(c.AppContext.Session(), permissions)
|
||||
}
|
||||
|
||||
func (c *Context) SetSiteURLHeader(url string) {
|
||||
c.siteURLHeader = strings.TrimRight(url, "/")
|
||||
}
|
||||
|
||||
func (c *Context) GetSiteURLHeader() string {
|
||||
return c.siteURLHeader
|
||||
}
|
||||
|
||||
func (c *Context) RequireUserId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if c.Params.UserId == model.Me {
|
||||
c.Params.UserId = c.AppContext.Session().UserId
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.UserId) {
|
||||
c.SetInvalidURLParam("user_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireOtherUserId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.OtherUserId) {
|
||||
c.SetInvalidURLParam("other_user_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireTeamId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.TeamId) {
|
||||
c.SetInvalidURLParam("team_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireCategoryId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidCategoryId(c.Params.CategoryId) {
|
||||
c.SetInvalidURLParam("category_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireInviteId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if c.Params.InviteId == "" {
|
||||
c.SetInvalidURLParam("invite_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireTokenId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.TokenId) {
|
||||
c.SetInvalidURLParam("token_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireThreadId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.ThreadId) {
|
||||
c.SetInvalidURLParam("thread_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireTimestamp() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if c.Params.Timestamp == 0 {
|
||||
c.SetInvalidURLParam("timestamp")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireChannelId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.ChannelId) {
|
||||
c.SetInvalidURLParam("channel_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireUsername() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidUsername(c.Params.Username) {
|
||||
c.SetInvalidParam("username")
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequirePostId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.PostId) {
|
||||
c.SetInvalidURLParam("post_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequirePolicyId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.PolicyId) {
|
||||
c.SetInvalidURLParam("policy_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireAppId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.AppId) {
|
||||
c.SetInvalidURLParam("app_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireOutgoingOAuthConnectionId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.OutgoingOAuthConnectionID) {
|
||||
c.SetInvalidURLParam("outgoing_oauth_connection_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireFileId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.FileId) {
|
||||
c.SetInvalidURLParam("file_id")
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireUploadId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.UploadId) {
|
||||
c.SetInvalidURLParam("upload_id")
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireFilename() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if c.Params.Filename == "" {
|
||||
c.SetInvalidURLParam("filename")
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequirePluginId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if c.Params.PluginId == "" {
|
||||
c.SetInvalidURLParam("plugin_id")
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireReportId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.ReportId) {
|
||||
c.SetInvalidURLParam("report_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireEmojiId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.EmojiId) {
|
||||
c.SetInvalidURLParam("emoji_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireTeamName() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidTeamName(c.Params.TeamName) {
|
||||
c.SetInvalidURLParam("team_name")
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireChannelName() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidChannelIdentifier(c.Params.ChannelName) {
|
||||
c.SetInvalidURLParam("channel_name")
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) SanitizeEmail() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
c.Params.Email = strings.ToLower(c.Params.Email)
|
||||
if !model.IsValidEmail(c.Params.Email) {
|
||||
c.SetInvalidURLParam("email")
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireCategory() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidAlphaNumHyphenUnderscore(c.Params.Category, true) {
|
||||
c.SetInvalidURLParam("category")
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireService() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if c.Params.Service == "" {
|
||||
c.SetInvalidURLParam("service")
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequirePreferenceName() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidAlphaNumHyphenUnderscore(c.Params.PreferenceName, true) {
|
||||
c.SetInvalidURLParam("preference_name")
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireEmojiName() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
validName := regexp.MustCompile(`^[a-zA-Z0-9\-\+_]+$`)
|
||||
|
||||
if c.Params.EmojiName == "" || len(c.Params.EmojiName) > model.EmojiNameMaxLength || !validName.MatchString(c.Params.EmojiName) {
|
||||
c.SetInvalidURLParam("emoji_name")
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireHookId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.HookId) {
|
||||
c.SetInvalidURLParam("hook_id")
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireCommandId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.CommandId) {
|
||||
c.SetInvalidURLParam("command_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireJobId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.JobId) {
|
||||
c.SetInvalidURLParam("job_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireJobType() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if c.Params.JobType == "" || len(c.Params.JobType) > 32 {
|
||||
c.SetInvalidURLParam("job_type")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireRoleId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.RoleId) {
|
||||
c.SetInvalidURLParam("role_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireFieldId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.FieldId) {
|
||||
c.SetInvalidURLParam("field_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireGroupName() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidPropertyGroupName(c.Params.GroupName) {
|
||||
c.SetInvalidURLParam("group_name")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireObjectType() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidPropertyFieldObjectType(c.Params.ObjectType) {
|
||||
c.SetInvalidURLParam("object_type")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireTargetId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.TargetId) {
|
||||
c.SetInvalidURLParam("target_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireSchemeId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.SchemeId) {
|
||||
c.SetInvalidURLParam("scheme_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireRoleName() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidRoleName(c.Params.RoleName) {
|
||||
c.SetInvalidURLParam("role_name")
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireGroupId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.GroupId) {
|
||||
c.SetInvalidURLParam("group_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireRemoteId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if c.Params.RemoteId == "" {
|
||||
c.SetInvalidURLParam("remote_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireSyncableId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.SyncableId) {
|
||||
c.SetInvalidURLParam("syncable_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireSyncableType() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if c.Params.SyncableType != model.GroupSyncableTypeTeam && c.Params.SyncableType != model.GroupSyncableTypeChannel {
|
||||
c.SetInvalidURLParam("syncable_type")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireBotUserId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.BotUserId) {
|
||||
c.SetInvalidURLParam("bot_user_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireInvoiceId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if len(c.Params.InvoiceId) != 27 && c.Params.InvoiceId != model.UpcomingInvoice {
|
||||
c.SetInvalidURLParam("invoice_id")
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireContentReviewerId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.ContentReviewerId) {
|
||||
c.SetInvalidURLParam("content_reviewer_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireRecapId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.RecapId) {
|
||||
c.SetInvalidURLParam("recap_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireScheduledRecapId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.ScheduledRecapId) {
|
||||
c.SetInvalidURLParam("scheduled_recap_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequireViewId() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !model.IsValidId(c.Params.ViewId) {
|
||||
c.SetInvalidURLParam("view_id")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequirePermissionToManageSecureConnections() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) {
|
||||
c.SetPermissionError(model.PermissionManageSecureConnections)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequirePermissionToManageSharedChannels() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSharedChannels) {
|
||||
c.SetPermissionError(model.PermissionManageSharedChannels)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) RequirePermissionToManageSecureConnectionsOrSharedChannels() *Context {
|
||||
if c.Err != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
if !c.App.SessionHasPermissionToAny(*c.AppContext.Session(), []*model.Permission{
|
||||
model.PermissionManageSecureConnections,
|
||||
model.PermissionManageSharedChannels,
|
||||
}) {
|
||||
c.SetPermissionError(model.PermissionManageSecureConnections, model.PermissionManageSharedChannels)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) GetRemoteID(r *http.Request) string {
|
||||
return r.Header.Get(model.HeaderRemoteclusterId)
|
||||
}
|
||||
181
server/channels/web/context_test.go
Normal file
181
server/channels/web/context_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
|
||||
)
|
||||
|
||||
func TestMakeAuditRecord(t *testing.T) {
|
||||
t.Run("PAT session includes token ID in meta", func(t *testing.T) {
|
||||
th := SetupWithStoreMock(t)
|
||||
|
||||
tokenID := model.NewId()
|
||||
th.Context = th.Context.WithSession(&model.Session{
|
||||
Id: model.NewId(),
|
||||
UserId: model.NewId(),
|
||||
Props: model.StringMap{
|
||||
model.SessionPropType: model.SessionTypeUserAccessToken,
|
||||
model.SessionPropUserAccessTokenId: tokenID,
|
||||
},
|
||||
})
|
||||
|
||||
c := &Context{App: th.App, AppContext: th.Context}
|
||||
rec := c.MakeAuditRecord("test_event", model.AuditStatusAttempt)
|
||||
|
||||
require.NotNil(t, rec)
|
||||
assert.Equal(t, tokenID, rec.Meta[model.SessionPropUserAccessTokenId])
|
||||
})
|
||||
|
||||
t.Run("non-PAT session omits token ID from meta", func(t *testing.T) {
|
||||
th := SetupWithStoreMock(t)
|
||||
|
||||
th.Context = th.Context.WithSession(&model.Session{
|
||||
Id: model.NewId(),
|
||||
UserId: model.NewId(),
|
||||
})
|
||||
|
||||
c := &Context{App: th.App, AppContext: th.Context}
|
||||
rec := c.MakeAuditRecord("test_event", model.AuditStatusAttempt)
|
||||
|
||||
require.NotNil(t, rec)
|
||||
_, exists := rec.Meta[model.SessionPropUserAccessTokenId]
|
||||
assert.False(t, exists, "token ID key should be absent from Meta for non-PAT sessions")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogAuditRec(t *testing.T) {
|
||||
t.Run("late-fills token ID for PAT session", func(t *testing.T) {
|
||||
th := SetupWithStoreMock(t)
|
||||
|
||||
tokenID := model.NewId()
|
||||
th.Context = th.Context.WithSession(&model.Session{
|
||||
Id: model.NewId(),
|
||||
UserId: model.NewId(),
|
||||
Props: model.StringMap{
|
||||
model.SessionPropType: model.SessionTypeUserAccessToken,
|
||||
model.SessionPropUserAccessTokenId: tokenID,
|
||||
},
|
||||
})
|
||||
|
||||
c := &Context{App: th.App, AppContext: th.Context}
|
||||
|
||||
// Simulate a record created before the session was established (e.g., login flow)
|
||||
rec := &model.AuditRecord{
|
||||
EventName: "test_event",
|
||||
Status: model.AuditStatusAttempt,
|
||||
Actor: model.AuditEventActor{},
|
||||
Meta: map[string]any{},
|
||||
EventData: model.AuditEventData{},
|
||||
}
|
||||
|
||||
c.LogAuditRec(rec)
|
||||
|
||||
assert.Equal(t, tokenID, rec.Meta[model.SessionPropUserAccessTokenId])
|
||||
})
|
||||
|
||||
t.Run("does not add token ID for non-PAT session", func(t *testing.T) {
|
||||
th := SetupWithStoreMock(t)
|
||||
|
||||
th.Context = th.Context.WithSession(&model.Session{
|
||||
Id: model.NewId(),
|
||||
UserId: model.NewId(),
|
||||
})
|
||||
|
||||
c := &Context{App: th.App, AppContext: th.Context}
|
||||
|
||||
rec := &model.AuditRecord{
|
||||
EventName: "test_event",
|
||||
Status: model.AuditStatusAttempt,
|
||||
Actor: model.AuditEventActor{},
|
||||
Meta: map[string]any{},
|
||||
EventData: model.AuditEventData{},
|
||||
}
|
||||
|
||||
c.LogAuditRec(rec)
|
||||
|
||||
_, exists := rec.Meta[model.SessionPropUserAccessTokenId]
|
||||
assert.False(t, exists, "token ID key should be absent from Meta for non-PAT sessions")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequireHookId(t *testing.T) {
|
||||
c := &Context{}
|
||||
t.Run("WhenHookIdIsValid", func(t *testing.T) {
|
||||
c.Params = &Params{HookId: "abcdefghijklmnopqrstuvwxyz"}
|
||||
c.RequireHookId()
|
||||
|
||||
require.Nil(t, c.Err, "Hook Id is Valid. Should not have set error in context")
|
||||
})
|
||||
|
||||
t.Run("WhenHookIdIsInvalid", func(t *testing.T) {
|
||||
c.Params = &Params{HookId: "abc"}
|
||||
c.RequireHookId()
|
||||
|
||||
require.NotNil(t, c.Err, "Should have set Error in context")
|
||||
require.Equal(t, http.StatusBadRequest, c.Err.StatusCode, "Should have set status as 400")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCloudKeyRequired(t *testing.T) {
|
||||
th := SetupWithStoreMock(t)
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
|
||||
|
||||
c := &Context{
|
||||
App: th.App,
|
||||
AppContext: th.Context,
|
||||
}
|
||||
|
||||
c.CloudKeyRequired()
|
||||
|
||||
assert.Equal(t, c.Err.Id, "api.context.session_expired.app_error")
|
||||
}
|
||||
|
||||
func TestMfaRequired(t *testing.T) {
|
||||
th := SetupWithStoreMock(t)
|
||||
|
||||
mockStore := th.App.Srv().Store().(*mocks.Store)
|
||||
mockUserStore := mocks.UserStore{}
|
||||
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
|
||||
mockUserStore.On("Get", mock.Anything, "userid").Return(nil, model.NewAppError("Userstore.Get", "storeerror", nil, "store error", http.StatusInternalServerError))
|
||||
mockPostStore := mocks.PostStore{}
|
||||
mockPostStore.On("GetMaxPostSize").Return(model.PostMessageMaxBytesV2, nil)
|
||||
mockSystemStore := mocks.SystemStore{}
|
||||
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
|
||||
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
|
||||
|
||||
mockStore.On("User").Return(&mockUserStore)
|
||||
mockStore.On("Post").Return(&mockPostStore)
|
||||
mockStore.On("System").Return(&mockSystemStore)
|
||||
mockStore.On("GetDBSchemaVersion").Return(1, nil)
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("mfa"))
|
||||
|
||||
th.Context = th.Context.WithSession(&model.Session{Id: "abc", UserId: "userid"})
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.AnnouncementSettings.UserNoticesEnabled = false
|
||||
*cfg.AnnouncementSettings.AdminNoticesEnabled = false
|
||||
*cfg.ServiceSettings.EnableMultifactorAuthentication = true
|
||||
*cfg.ServiceSettings.EnforceMultifactorAuthentication = true
|
||||
})
|
||||
|
||||
c := &Context{
|
||||
App: th.App,
|
||||
AppContext: th.Context,
|
||||
}
|
||||
|
||||
c.MfaRequired(http.MethodGet)
|
||||
|
||||
assert.Equal(t, c.Err.Id, "api.context.get_user.app_error")
|
||||
}
|
||||
602
server/channels/web/handlers.go
Normal file
602
server/channels/web/handlers.go
Normal file
@@ -0,0 +1,602 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/compress/gzhttp"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/i18n"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||
)
|
||||
|
||||
// tokenDigest returns a stable, non-reversible identifier for a token, usable for correlation.
|
||||
func tokenDigest(token string) string {
|
||||
if token == "" {
|
||||
return "<none>"
|
||||
}
|
||||
|
||||
return utils.HashSha256(token)[:16]
|
||||
}
|
||||
|
||||
func GetHandlerName(h func(*Context, http.ResponseWriter, *http.Request)) string {
|
||||
handlerName := runtime.FuncForPC(reflect.ValueOf(h).Pointer()).Name()
|
||||
pos := strings.LastIndex(handlerName, ".")
|
||||
if pos != -1 && len(handlerName) > pos {
|
||||
handlerName = handlerName[pos+1:]
|
||||
}
|
||||
return handlerName
|
||||
}
|
||||
|
||||
func (w *Web) NewHandler(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
|
||||
return &Handler{
|
||||
Srv: w.srv,
|
||||
HandleFunc: h,
|
||||
HandlerName: GetHandlerName(h),
|
||||
RequireSession: false,
|
||||
TrustRequester: false,
|
||||
RequireMfa: false,
|
||||
IsStatic: false,
|
||||
IsLocal: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Web) NewStaticHandler(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
|
||||
// Determine the CSP SHA directives needed for the inline scripts injected into root.html.
|
||||
// These values are fixed on server start and intentionally require a restart to take effect.
|
||||
cfg := w.srv.Config()
|
||||
subpath, _ := utils.GetSubpathFromConfig(cfg)
|
||||
|
||||
return &Handler{
|
||||
Srv: w.srv,
|
||||
HandleFunc: h,
|
||||
HandlerName: GetHandlerName(h),
|
||||
RequireSession: false,
|
||||
TrustRequester: false,
|
||||
RequireMfa: false,
|
||||
IsStatic: true,
|
||||
|
||||
cspShaDirective: utils.GetStaticScriptHashes(subpath),
|
||||
}
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
Srv *app.Server
|
||||
HandleFunc func(*Context, http.ResponseWriter, *http.Request)
|
||||
HandlerName string
|
||||
RequireSession bool
|
||||
RequireCloudKey bool
|
||||
RequireRemoteClusterToken bool
|
||||
TrustRequester bool
|
||||
RequireMfa bool
|
||||
IsStatic bool
|
||||
IsLocal bool
|
||||
DisableWhenBusy bool
|
||||
FileAPI bool
|
||||
|
||||
cspShaDirective string
|
||||
}
|
||||
|
||||
func generateDevCSP(c Context) string {
|
||||
var devCSP []string
|
||||
|
||||
// Add unsafe-eval to the content security policy for faster source maps in development mode
|
||||
if model.BuildNumber == "dev" {
|
||||
devCSP = append(devCSP, "'unsafe-eval'")
|
||||
}
|
||||
|
||||
// Add unsafe-inline to unlock extensions like React & Redux DevTools in Firefox
|
||||
// see https://github.com/reduxjs/redux-devtools/issues/380
|
||||
if model.BuildNumber == "dev" {
|
||||
devCSP = append(devCSP, "'unsafe-inline'")
|
||||
}
|
||||
|
||||
// Add supported flags for debugging during development, even if not on a dev build.
|
||||
if *c.App.Config().ServiceSettings.DeveloperFlags != "" {
|
||||
for devFlagKVStr := range strings.SplitSeq(*c.App.Config().ServiceSettings.DeveloperFlags, ",") {
|
||||
devFlagKVSplit := strings.SplitN(devFlagKVStr, "=", 2)
|
||||
if len(devFlagKVSplit) != 2 {
|
||||
c.Logger.Warn("Unable to parse developer flag", mlog.String("developer_flag", devFlagKVStr))
|
||||
continue
|
||||
}
|
||||
devFlagKey := devFlagKVSplit[0]
|
||||
devFlagValue := devFlagKVSplit[1]
|
||||
|
||||
// Ignore disabled keys
|
||||
if devFlagValue != "true" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Honour only supported keys
|
||||
switch devFlagKey {
|
||||
case "unsafe-eval", "unsafe-inline":
|
||||
if model.BuildNumber == "dev" {
|
||||
// These flags are added automatically for dev builds
|
||||
continue
|
||||
}
|
||||
|
||||
devCSP = append(devCSP, "'"+devFlagKey+"'")
|
||||
default:
|
||||
c.Logger.Warn("Unrecognized developer flag", mlog.String("developer_flag", devFlagKVStr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(devCSP) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return " " + strings.Join(devCSP, " ")
|
||||
}
|
||||
|
||||
func (h Handler) basicSecurityChecks(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
maxURLCharacters := *c.App.Config().ServiceSettings.MaximumURLLength
|
||||
if len(r.RequestURI) > maxURLCharacters {
|
||||
c.Err = model.NewAppError("basicSecurityChecks", "basic_security_check.url.too_long_error", nil, "", http.StatusRequestURITooLong)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w = newWrappedWriter(w)
|
||||
now := time.Now()
|
||||
|
||||
appInstance := app.New(app.ServerConnector(h.Srv.Channels()))
|
||||
|
||||
c := &Context{
|
||||
AppContext: &request.Context{},
|
||||
App: appInstance,
|
||||
}
|
||||
|
||||
requestID := model.NewId()
|
||||
var rateLimitExceeded bool
|
||||
defer func() {
|
||||
responseLogFields := []mlog.Field{
|
||||
mlog.String("method", r.Method),
|
||||
mlog.String("url", r.URL.Path),
|
||||
mlog.String("request_id", requestID),
|
||||
}
|
||||
// if there is a valid session and userID then include the user_id
|
||||
if c.AppContext.Session() != nil && c.AppContext.Session().UserId != "" {
|
||||
responseLogFields = append(responseLogFields, mlog.String("user_id", c.AppContext.Session().UserId))
|
||||
if c.AppContext.Session().IsUserAccessToken() {
|
||||
responseLogFields = append(responseLogFields, mlog.String("user_access_token_id", c.AppContext.Session().Props[model.SessionPropUserAccessTokenId]))
|
||||
}
|
||||
}
|
||||
|
||||
statusCode := strconv.Itoa(w.(*responseWriterWrapper).StatusCode())
|
||||
|
||||
// Websockets are returning status code 0 to requests after closing the socket
|
||||
if statusCode != "0" {
|
||||
responseLogFields = append(responseLogFields, mlog.String("status_code", statusCode))
|
||||
}
|
||||
mlog.Debug("Received HTTP request", responseLogFields...)
|
||||
|
||||
if !rateLimitExceeded {
|
||||
h.recordMetrics(c, r, now, statusCode)
|
||||
}
|
||||
}()
|
||||
|
||||
t, _ := i18n.GetTranslationsAndLocaleFromRequest(r)
|
||||
c.AppContext = request.NewContext(
|
||||
context.Background(),
|
||||
requestID,
|
||||
utils.GetIPAddress(r, c.App.Config().ServiceSettings.TrustedProxyIPHeader),
|
||||
r.Header.Get("X-Forwarded-For"),
|
||||
r.URL.Path,
|
||||
r.UserAgent(),
|
||||
r.Header.Get("Accept-Language"),
|
||||
t,
|
||||
)
|
||||
|
||||
if connectionId := r.Header.Get(model.ConnectionId); connectionId != "" {
|
||||
c.AppContext = c.AppContext.WithConnectionId(connectionId)
|
||||
}
|
||||
|
||||
c.Params = ParamsFromRequest(r)
|
||||
c.Logger = c.App.Log()
|
||||
|
||||
h.basicSecurityChecks(c, w, r)
|
||||
if c.Err != nil {
|
||||
h.handleContextError(c, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var maxBytes int64
|
||||
if h.FileAPI {
|
||||
// We add a buffer of bytes.MinRead so that file sizes close to max file size
|
||||
// do not get cut off.
|
||||
maxBytes = *c.App.Config().FileSettings.MaxFileSize + bytes.MinRead
|
||||
} else {
|
||||
maxBytes = *c.App.Config().ServiceSettings.MaximumPayloadSizeBytes + bytes.MinRead
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||
|
||||
subpath, _ := utils.GetSubpathFromConfig(c.App.Config())
|
||||
siteURLHeader := app.GetProtocol(r) + "://" + r.Host + subpath
|
||||
if c.App.Channels().License().IsCloud() {
|
||||
siteURLHeader = *c.App.Config().ServiceSettings.SiteURL + subpath
|
||||
}
|
||||
c.SetSiteURLHeader(siteURLHeader)
|
||||
|
||||
w.Header().Set(model.HeaderRequestId, c.AppContext.RequestId())
|
||||
w.Header().Set(model.HeaderVersionId, fmt.Sprintf("%v.%v.%v.%v", model.CurrentVersion, model.BuildNumber, c.App.ClientConfigHash(), c.App.Channels().License() != nil))
|
||||
|
||||
if *c.App.Config().ServiceSettings.TLSStrictTransport {
|
||||
w.Header().Set("Strict-Transport-Security", fmt.Sprintf("max-age=%d", *c.App.Config().ServiceSettings.TLSStrictTransportMaxAge))
|
||||
}
|
||||
|
||||
// Hardcoded sensible default values for these security headers. Feel free to override in proxy or ingress
|
||||
w.Header().Set("Permissions-Policy", "")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
|
||||
if h.IsStatic {
|
||||
// Instruct the browser not to display us in an iframe unless is the same origin for anti-clickjacking
|
||||
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
||||
|
||||
devCSP := generateDevCSP(*c)
|
||||
|
||||
// Set content security policy. This is also specified in the root.html of the webapp in a meta tag.
|
||||
w.Header().Set("Content-Security-Policy", fmt.Sprintf(
|
||||
"frame-ancestors 'self' %s; script-src 'self'%s%s",
|
||||
*c.App.Config().ServiceSettings.FrameAncestors,
|
||||
h.cspShaDirective,
|
||||
devCSP,
|
||||
))
|
||||
} else {
|
||||
// All api response bodies will be JSON formatted by default
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if r.Method == "GET" {
|
||||
w.Header().Set("Expires", "0")
|
||||
}
|
||||
}
|
||||
|
||||
token, tokenLocation := app.ParseAuthTokenFromRequest(r)
|
||||
|
||||
if token != "" && tokenLocation != app.TokenLocationCloudHeader && tokenLocation != app.TokenLocationRemoteClusterHeader {
|
||||
session, err := c.App.GetSession(token)
|
||||
|
||||
if err != nil {
|
||||
c.Logger.Info("Invalid session", mlog.String("error", strings.ReplaceAll(err.Error(), token, tokenDigest(token))))
|
||||
if err.StatusCode == http.StatusInternalServerError {
|
||||
c.Err = err
|
||||
} else if h.RequireSession {
|
||||
c.RemoveSessionCookie(w, r)
|
||||
c.Err = model.NewAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token_sha256="+tokenDigest(token), http.StatusUnauthorized)
|
||||
}
|
||||
} else if !session.IsOAuth && tokenLocation == app.TokenLocationQueryString {
|
||||
c.Err = model.NewAppError("ServeHTTP", "api.context.token_provided.app_error", nil, "token_sha256="+tokenDigest(token), http.StatusUnauthorized)
|
||||
} else {
|
||||
c.AppContext = c.AppContext.WithSession(session)
|
||||
}
|
||||
|
||||
// Rate limit by UserID
|
||||
if c.App.Srv().RateLimiter != nil {
|
||||
rateLimitExceeded = c.App.Srv().RateLimiter.UserIdRateLimit(r.Context(), c.AppContext.Session().UserId, w)
|
||||
if rateLimitExceeded {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
csrfChecked, csrfPassed := h.checkCSRFToken(c, r, tokenLocation, session)
|
||||
if csrfChecked && !csrfPassed {
|
||||
c.AppContext = c.AppContext.WithSession(&model.Session{})
|
||||
c.RemoveSessionCookie(w, r)
|
||||
c.Err = model.NewAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token_sha256="+tokenDigest(token)+" Appears to be a CSRF attempt", http.StatusUnauthorized)
|
||||
}
|
||||
} else if token != "" && c.App.Channels().License().IsCloud() && tokenLocation == app.TokenLocationCloudHeader {
|
||||
// Check to see if this provided token matches our CWS Token
|
||||
session, err := c.App.GetCloudSession(token)
|
||||
if err != nil {
|
||||
c.Logger.Warn("Invalid CWS token", mlog.String("error", strings.ReplaceAll(err.Error(), token, tokenDigest(token))))
|
||||
c.Err = err
|
||||
} else {
|
||||
c.AppContext = c.AppContext.WithSession(session)
|
||||
}
|
||||
} else if token != "" && c.App.Channels().License() != nil && c.App.Channels().License().HasRemoteClusterService() && tokenLocation == app.TokenLocationRemoteClusterHeader {
|
||||
// Get the remote cluster
|
||||
if remoteId := c.GetRemoteID(r); remoteId == "" {
|
||||
c.Logger.Warn("Missing remote cluster id") //
|
||||
c.Err = model.NewAppError("ServeHTTP", "api.context.remote_id_missing.app_error", nil, "", http.StatusUnauthorized)
|
||||
} else {
|
||||
// Check the token is correct for the remote cluster id.
|
||||
session, err := c.App.GetRemoteClusterSession(token, remoteId)
|
||||
if err != nil {
|
||||
c.Logger.Warn("Invalid remote cluster token", mlog.String("error", strings.ReplaceAll(err.Error(), token, tokenDigest(token))))
|
||||
c.Err = err
|
||||
} else {
|
||||
c.AppContext = c.AppContext.WithSession(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loggerFields := []mlog.Field{
|
||||
mlog.String("path", c.AppContext.Path()),
|
||||
mlog.String("request_id", c.AppContext.RequestId()),
|
||||
mlog.String("ip_addr", c.AppContext.IPAddress()),
|
||||
mlog.String("user_id", c.AppContext.Session().UserId),
|
||||
mlog.String("method", r.Method),
|
||||
}
|
||||
if c.AppContext.Session().IsUserAccessToken() {
|
||||
loggerFields = append(loggerFields, mlog.String("user_access_token_id", c.AppContext.Session().Props[model.SessionPropUserAccessTokenId]))
|
||||
}
|
||||
c.Logger = c.App.Log().With(loggerFields...)
|
||||
c.AppContext = c.AppContext.WithLogger(c.Logger)
|
||||
c.App.ProcessSessionAttributesRequest(c.AppContext, r)
|
||||
|
||||
if c.Err == nil && h.RequireSession {
|
||||
c.SessionRequired()
|
||||
}
|
||||
|
||||
if c.Err == nil && h.RequireMfa {
|
||||
c.MfaRequired(r.Method)
|
||||
}
|
||||
|
||||
if c.Err == nil && h.DisableWhenBusy && c.App.Srv().Platform().Busy.IsBusy() {
|
||||
c.SetServerBusyError()
|
||||
}
|
||||
|
||||
if c.Err == nil && h.RequireCloudKey {
|
||||
c.CloudKeyRequired()
|
||||
}
|
||||
|
||||
if c.Err == nil && h.RequireRemoteClusterToken {
|
||||
c.RemoteClusterTokenRequired()
|
||||
}
|
||||
|
||||
if c.Err == nil && h.IsLocal {
|
||||
// if the connection is local, RemoteAddr shouldn't have the
|
||||
// shape IP:PORT (it will be "@" in Linux, for example)
|
||||
isLocalOrigin := !strings.Contains(r.RemoteAddr, ":")
|
||||
if *c.App.Config().ServiceSettings.EnableLocalMode && isLocalOrigin {
|
||||
c.AppContext = c.AppContext.WithSession(&model.Session{Local: true})
|
||||
} else if !isLocalOrigin {
|
||||
c.Err = model.NewAppError("", "api.context.local_origin_required.app_error", nil, "LocalOriginRequired", http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
if c.Err == nil {
|
||||
h.HandleFunc(c, w, r)
|
||||
}
|
||||
|
||||
// Handle errors that have occurred
|
||||
if c.Err != nil {
|
||||
h.handleContextError(c, w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h Handler) recordMetrics(c *Context, r *http.Request, now time.Time, statusCode string) {
|
||||
if c.App.Metrics() != nil {
|
||||
c.App.Metrics().IncrementHTTPRequest()
|
||||
|
||||
if r.URL.Path != model.APIURLSuffix+"/websocket" {
|
||||
elapsed := float64(time.Since(now)) / float64(time.Second)
|
||||
|
||||
pageLoadContext := r.Header.Get("X-Page-Load-Context")
|
||||
if pageLoadContext != "page_load" && pageLoadContext != "reconnect" {
|
||||
pageLoadContext = ""
|
||||
}
|
||||
|
||||
c.App.Metrics().ObserveAPIEndpointDuration(h.HandlerName, r.Method, statusCode, string(GetOriginClient(r)), pageLoadContext, elapsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h Handler) handleContextError(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if c.Err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// We're handling payload limit error here because it needs to be handled globally.
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
// check if error is a MaxBytesError error, which occurs when you read more bytes from buffer than configured
|
||||
if ok := errors.As(c.Err, &maxBytesErr); ok {
|
||||
// replace the context error with this error if so,
|
||||
newErr := model.NewAppError(c.Err.Where, "api.context.request_body_too_large.app_error", nil, "Use the setting `MaximumPayloadSizeBytes` in Mattermost config to configure allowed payload limit. Learn more about this setting in Mattermost docs at https://docs.mattermost.com/configure/environment-configuration-settings.html#maximum-payload-size", http.StatusRequestEntityTooLarge)
|
||||
c.Err = newErr
|
||||
}
|
||||
|
||||
// Detect and fix AppError with missing StatusCode to prevent panics
|
||||
if c.Err.StatusCode == 0 {
|
||||
c.Logger.Error("AppError with zero StatusCode detected",
|
||||
mlog.String("error_id", c.Err.Id),
|
||||
mlog.String("error_message", c.Err.Message),
|
||||
mlog.String("error_where", c.Err.Where),
|
||||
mlog.String("request_path", r.URL.Path),
|
||||
mlog.String("request_method", r.Method),
|
||||
mlog.String("detailed_error", c.Err.DetailedError),
|
||||
)
|
||||
c.Err.StatusCode = http.StatusInternalServerError
|
||||
}
|
||||
|
||||
c.Err.RequestId = c.AppContext.RequestId()
|
||||
c.LogErrorByCode(c.Err)
|
||||
// The locale translation needs to happen after we have logged it.
|
||||
// We don't want the server logs to be translated as per user locale.
|
||||
c.Err.Translate(c.AppContext.T)
|
||||
|
||||
c.Err.Where = r.URL.Path
|
||||
|
||||
// Block out detailed error when not in developer mode
|
||||
if !*c.App.Config().ServiceSettings.EnableDeveloper {
|
||||
c.Err.WipeDetailed()
|
||||
}
|
||||
|
||||
// Sanitize all 5xx error messages in hardened mode
|
||||
if *c.App.Config().ServiceSettings.EnableHardenedMode && c.Err.StatusCode >= 500 {
|
||||
c.Err.Id = ""
|
||||
c.Err.Message = "Internal Server Error"
|
||||
c.Err.WipeDetailed()
|
||||
// Props is always exposed to clients, so scrub it along with the rest of the sanitized 5xx.
|
||||
c.Err.Props = nil
|
||||
c.Err.StatusCode = 500
|
||||
c.Err.Where = ""
|
||||
}
|
||||
|
||||
if IsAPICall(c.App, r) || IsWebhookCall(c.App, r) || IsOAuthAPICall(c.App, r) || r.Header.Get("X-Mobile-App") != "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(c.Err.StatusCode)
|
||||
if _, err := w.Write([]byte(c.Err.ToJSON())); err != nil {
|
||||
c.Logger.Warn("Failed to write error response", mlog.Err(err))
|
||||
}
|
||||
} else {
|
||||
utils.RenderWebAppError(c.App.Config(), w, r, c.Err, c.App.AsymmetricSigningKey())
|
||||
}
|
||||
|
||||
if c.App.Metrics() != nil {
|
||||
c.App.Metrics().IncrementHTTPError()
|
||||
}
|
||||
}
|
||||
|
||||
type OriginClient string
|
||||
|
||||
const (
|
||||
OriginClientUnknown OriginClient = "unknown"
|
||||
OriginClientWeb OriginClient = "web"
|
||||
OriginClientMobile OriginClient = "mobile"
|
||||
OriginClientDesktop OriginClient = "desktop"
|
||||
)
|
||||
|
||||
// GetOriginClient returns the device from which the provided request was issued. The algorithm roughly looks like:
|
||||
// - If the URL contains the query mobilev2=true, then it's mobile
|
||||
// - If the first field of the user agent starts with either "rnbeta" or "Mattermost", then it's mobile
|
||||
// - If the last field of the user agent starts with "Mattermost", then it's desktop
|
||||
// - Otherwise, it's web
|
||||
func GetOriginClient(r *http.Request) OriginClient {
|
||||
userAgent := r.Header.Get("User-Agent")
|
||||
fields := strings.Fields(userAgent)
|
||||
if len(fields) < 1 {
|
||||
return OriginClientUnknown
|
||||
}
|
||||
|
||||
// Is mobile post v2?
|
||||
queryParam := r.URL.Query().Get("mobilev2")
|
||||
if queryParam == "true" {
|
||||
return OriginClientMobile
|
||||
}
|
||||
|
||||
// Is mobile pre v2?
|
||||
clientAgent := fields[0]
|
||||
if strings.HasPrefix(clientAgent, "rnbeta") || strings.HasPrefix(clientAgent, "Mattermost") {
|
||||
return OriginClientMobile
|
||||
}
|
||||
|
||||
// Is desktop?
|
||||
if strings.HasPrefix(fields[len(fields)-1], "Mattermost") {
|
||||
return OriginClientDesktop
|
||||
}
|
||||
|
||||
// Default to web
|
||||
return OriginClientWeb
|
||||
}
|
||||
|
||||
// checkCSRFToken performs a CSRF check on the provided request with the given CSRF token. Returns whether
|
||||
// a CSRF check occurred and whether it succeeded.
|
||||
func (h *Handler) checkCSRFToken(c *Context, r *http.Request, tokenLocation app.TokenLocation, session *model.Session) (checked bool, passed bool) {
|
||||
csrfCheckNeeded := session != nil && c.Err == nil && tokenLocation == app.TokenLocationCookie && !h.TrustRequester && r.Method != "GET"
|
||||
csrfCheckPassed := false
|
||||
|
||||
if csrfCheckNeeded {
|
||||
csrfHeader := r.Header.Get(model.HeaderCsrfToken)
|
||||
|
||||
if csrfHeader == session.GetCSRF() {
|
||||
csrfCheckPassed = true
|
||||
} else if r.Header.Get(model.HeaderRequestedWith) == model.HeaderRequestedWithXML {
|
||||
// ToDo(DSchalla) 2019/01/04: Remove after deprecation period and only allow CSRF Header (MM-13657)
|
||||
csrfErrorMessage := "CSRF Header check failed for request - Please upgrade your web application or custom app to set a CSRF Header"
|
||||
|
||||
fields := []mlog.Field{
|
||||
mlog.String("path", r.URL.Path),
|
||||
mlog.String("ip", r.RemoteAddr),
|
||||
mlog.String("session_id", session.Id),
|
||||
mlog.String("user_id", session.UserId),
|
||||
}
|
||||
|
||||
if *c.App.Config().ServiceSettings.ExperimentalStrictCSRFEnforcement {
|
||||
c.Logger.Warn(csrfErrorMessage, fields...)
|
||||
} else {
|
||||
c.Logger.Debug(csrfErrorMessage, fields...)
|
||||
csrfCheckPassed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return csrfCheckNeeded, csrfCheckPassed
|
||||
}
|
||||
|
||||
// APIHandler provides a handler for API endpoints which do not require the user to be logged in order for access to be
|
||||
// granted.
|
||||
func (w *Web) APIHandler(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
|
||||
handler := &Handler{
|
||||
Srv: w.srv,
|
||||
HandleFunc: h,
|
||||
HandlerName: GetHandlerName(h),
|
||||
RequireSession: false,
|
||||
TrustRequester: false,
|
||||
RequireMfa: false,
|
||||
IsStatic: false,
|
||||
IsLocal: false,
|
||||
}
|
||||
if *w.srv.Config().ServiceSettings.WebserverMode == "gzip" {
|
||||
return gzhttp.GzipHandler(handler)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
// APIHandlerTrustRequester provides a handler for API endpoints which do not require the user to be logged in and are
|
||||
// allowed to be requested directly rather than via javascript/XMLHttpRequest, such as site branding images or the
|
||||
// websocket.
|
||||
func (w *Web) APIHandlerTrustRequester(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
|
||||
handler := &Handler{
|
||||
Srv: w.srv,
|
||||
HandleFunc: h,
|
||||
HandlerName: GetHandlerName(h),
|
||||
RequireSession: false,
|
||||
TrustRequester: true,
|
||||
RequireMfa: false,
|
||||
IsStatic: false,
|
||||
IsLocal: false,
|
||||
}
|
||||
if *w.srv.Config().ServiceSettings.WebserverMode == "gzip" {
|
||||
return gzhttp.GzipHandler(handler)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
// APISessionRequired provides a handler for API endpoints which require the user to be logged in in order for access to
|
||||
// be granted.
|
||||
func (w *Web) APISessionRequired(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler {
|
||||
handler := &Handler{
|
||||
Srv: w.srv,
|
||||
HandleFunc: h,
|
||||
HandlerName: GetHandlerName(h),
|
||||
RequireSession: true,
|
||||
TrustRequester: false,
|
||||
RequireMfa: true,
|
||||
IsStatic: false,
|
||||
IsLocal: false,
|
||||
}
|
||||
if *w.srv.Config().ServiceSettings.WebserverMode == "gzip" {
|
||||
return gzhttp.GzipHandler(handler)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
1408
server/channels/web/handlers_test.go
Normal file
1408
server/channels/web/handlers_test.go
Normal file
File diff suppressed because it is too large
Load Diff
93
server/channels/web/magic_link.go
Normal file
93
server/channels/web/magic_link.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"html"
|
||||
"net/http"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||
)
|
||||
|
||||
func (w *Web) InitMagicLink() {
|
||||
w.MainRouter.Handle("/login/one_time_link", w.APIHandler(loginWithMagicLinkToken)).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
func loginWithMagicLinkToken(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventLogin, model.AuditStatusFail)
|
||||
auditRec.AddMeta("login_method", "guest_magic_link")
|
||||
defer c.LogAuditRec(auditRec)
|
||||
|
||||
if !*c.App.Config().GuestAccountsSettings.EnableGuestMagicLink {
|
||||
http.Redirect(w, r, c.GetSiteURLHeader()+"/login?extra=login_error&message="+html.EscapeString("Magic link is disabled"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if c.AppContext.Session() != nil && c.AppContext.Session().UserId != "" {
|
||||
http.Redirect(w, r, c.GetSiteURLHeader()+"/error?type=magic_link_already_logged_in", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := r.URL.Query().Get("t")
|
||||
if tokenString == "" {
|
||||
c.Err = model.NewAppError("loginWithMagicLinkToken", "api.user.guest_magic_link.missing_token.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limit by IP to prevent brute force attacks on tokens
|
||||
if c.App.Srv().RateLimiter != nil {
|
||||
rateLimitKey := c.App.Srv().RateLimiter.GenerateKey(r)
|
||||
if c.App.Srv().RateLimiter.RateLimitWriter(r.Context(), rateLimitKey, w) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
redirectURL := html.EscapeString(r.URL.Query().Get("redirect_to"))
|
||||
|
||||
user, err := c.App.AuthenticateUserForGuestMagicLink(c.AppContext, tokenString)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, c.GetSiteURLHeader()+"/login?extra=login_error&message="+html.EscapeString(err.Message), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.AddMeta("user_id", user.Id)
|
||||
c.LogAuditWithUserId(user.Id, "attempt - guest_magic_link")
|
||||
|
||||
// Check user authentication criteria
|
||||
if authErr := c.App.CheckUserAllAuthenticationCriteria(c.AppContext, user, ""); authErr != nil {
|
||||
c.LogAuditWithUserId(user.Id, "failure - guest_magic_link")
|
||||
utils.RenderWebAppError(c.App.Config(), w, r, authErr, c.App.AsymmetricSigningKey())
|
||||
return
|
||||
}
|
||||
|
||||
// Create session and log user in
|
||||
session, err := c.App.DoLogin(c.AppContext, w, r, user, model.LoginOptions{
|
||||
IsSaml: true,
|
||||
})
|
||||
if err != nil {
|
||||
utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey())
|
||||
return
|
||||
}
|
||||
|
||||
c.AppContext = c.AppContext.WithSession(session)
|
||||
|
||||
// Mark login as successful in audit log
|
||||
auditRec.Success()
|
||||
c.LogAuditWithUserId(user.Id, "success - guest_magic_link")
|
||||
|
||||
// Attach session cookies and redirect
|
||||
c.App.AttachSessionCookies(c.AppContext, w, r)
|
||||
|
||||
// Determine redirect URL
|
||||
if redirectURL == "" {
|
||||
// No redirect specified - go to root and let webapp route to appropriate location
|
||||
redirectURL = c.GetSiteURLHeader()
|
||||
} else {
|
||||
// Validate and make redirect URL fully qualified
|
||||
redirectURL = fullyQualifiedRedirectURL(c.GetSiteURLHeader(), redirectURL, c.App.Config().NativeAppSettings.AppCustomURLSchemes)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
}
|
||||
24
server/channels/web/main_test.go
Normal file
24
server/channels/web/main_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/testlib"
|
||||
)
|
||||
|
||||
var mainHelper *testlib.MainHelper
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
var options = testlib.HelperOptions{
|
||||
EnableStore: true,
|
||||
EnableResources: true,
|
||||
}
|
||||
|
||||
mainHelper = testlib.NewMainHelperWithOptions(&options)
|
||||
defer mainHelper.Close()
|
||||
|
||||
mainHelper.Main(m)
|
||||
}
|
||||
715
server/channels/web/oauth.go
Normal file
715
server/channels/web/oauth.go
Normal file
@@ -0,0 +1,715 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/i18n"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
|
||||
)
|
||||
|
||||
const (
|
||||
callbackHost = "callback"
|
||||
)
|
||||
|
||||
func (w *Web) InitOAuth() {
|
||||
// OAuth 2.0 Authorization Server Metadata endpoint (RFC 8414)
|
||||
// Match the exact path and any path with additional segments after it
|
||||
w.MainRouter.PathPrefix(model.OAuthMetadataEndpoint).Handler(w.APIHandlerTrustRequester(getAuthorizationServerMetadata)).Methods(http.MethodGet)
|
||||
|
||||
// API version independent OAuth 2.0 as a service provider endpoints
|
||||
w.MainRouter.Handle(model.OAuthAuthorizeEndpoint, w.APIHandlerTrustRequester(authorizeOAuthPage)).Methods(http.MethodGet)
|
||||
w.MainRouter.Handle(model.OAuthAuthorizeEndpoint, w.APISessionRequired(authorizeOAuthApp)).Methods(http.MethodPost)
|
||||
w.MainRouter.Handle(model.OAuthDeauthorizeEndpoint, w.APISessionRequired(deauthorizeOAuthApp)).Methods(http.MethodPost)
|
||||
w.MainRouter.Handle(model.OAuthAccessTokenEndpoint, w.APIHandlerTrustRequester(getAccessToken)).Methods(http.MethodPost)
|
||||
|
||||
// API version independent OAuth as a client endpoints
|
||||
w.MainRouter.Handle("/oauth/{service:[A-Za-z0-9]+}/complete", w.APIHandler(completeOAuth)).Methods(http.MethodGet)
|
||||
w.MainRouter.Handle("/oauth/{service:[A-Za-z0-9]+}/login", w.APIHandler(loginWithOAuth)).Methods(http.MethodGet)
|
||||
w.MainRouter.Handle("/oauth/{service:[A-Za-z0-9]+}/mobile_login", w.APIHandler(mobileLoginWithOAuth)).Methods(http.MethodGet)
|
||||
w.MainRouter.Handle("/oauth/{service:[A-Za-z0-9]+}/signup", w.APIHandler(signupWithOAuth)).Methods(http.MethodGet)
|
||||
|
||||
// Intune MAM authentication endpoint
|
||||
w.MainRouter.Handle("/oauth/intune", w.APIHandler(loginByIntune)).Methods(http.MethodPost)
|
||||
|
||||
// Old endpoints for backwards compatibility, needed to not break SSO for any old setups
|
||||
w.MainRouter.Handle("/api/v3/oauth/{service:[A-Za-z0-9]+}/complete", w.APIHandler(completeOAuth)).Methods(http.MethodGet)
|
||||
w.MainRouter.Handle("/signup/{service:[A-Za-z0-9]+}/complete", w.APIHandler(completeOAuth)).Methods(http.MethodGet)
|
||||
w.MainRouter.Handle("/login/{service:[A-Za-z0-9]+}/complete", w.APIHandler(completeOAuth)).Methods(http.MethodGet)
|
||||
w.MainRouter.Handle("/api/v4/oauth_test", w.APISessionRequired(testHandler)).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
func testHandler(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
|
||||
func authorizeOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
var authRequest *model.AuthorizeRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&authRequest)
|
||||
if err != nil || authRequest == nil {
|
||||
c.SetInvalidParamWithErr("authorize_request", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := authRequest.IsValid(); err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
if c.AppContext.Session().IsOAuth {
|
||||
c.SetPermissionError(model.PermissionEditOtherUsers)
|
||||
c.Err.DetailedError += ", attempted access by oauth app"
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventAuthorizeOAuthApp, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
c.LogAudit("attempt")
|
||||
|
||||
redirectURL, appErr := c.App.AllowOAuthAppAccessToUser(c.AppContext, c.AppContext.Session().UserId, authRequest)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
c.LogAudit("success")
|
||||
|
||||
_, err = w.Write([]byte(model.MapToJSON(map[string]string{"redirect": redirectURL})))
|
||||
if err != nil {
|
||||
c.Logger.Warn("Error writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func deauthorizeOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
requestData := model.MapFromJSON(r.Body)
|
||||
clientId := requestData["client_id"]
|
||||
|
||||
if !model.IsValidId(clientId) {
|
||||
c.SetInvalidParam("client_id")
|
||||
return
|
||||
}
|
||||
|
||||
if c.AppContext.Session().IsOAuth {
|
||||
c.SetPermissionError(model.PermissionEditOtherUsers)
|
||||
c.Err.DetailedError += ", attempted access by oauth app"
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventDeauthorizeOAuthApp, model.AuditStatusFail)
|
||||
auditRec.AddMeta("client_id", clientId)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
|
||||
err := c.App.DeauthorizeOAuthAppForUser(c.AppContext, c.AppContext.Session().UserId, clientId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
c.LogAudit("success")
|
||||
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
|
||||
func authorizeOAuthPage(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !*c.App.Config().ServiceSettings.EnableOAuthServiceProvider {
|
||||
err := model.NewAppError("authorizeOAuth", "api.oauth.authorize_oauth.disabled.app_error", nil, "", http.StatusNotImplemented)
|
||||
utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey())
|
||||
return
|
||||
}
|
||||
|
||||
authRequest := &model.AuthorizeRequest{
|
||||
ResponseType: r.URL.Query().Get("response_type"),
|
||||
ClientId: r.URL.Query().Get("client_id"),
|
||||
RedirectURI: r.URL.Query().Get("redirect_uri"),
|
||||
Scope: r.URL.Query().Get("scope"),
|
||||
State: r.URL.Query().Get("state"),
|
||||
CodeChallenge: r.URL.Query().Get("code_challenge"),
|
||||
CodeChallengeMethod: r.URL.Query().Get("code_challenge_method"),
|
||||
Resource: r.URL.Query().Get("resource"),
|
||||
}
|
||||
|
||||
loginHint := r.URL.Query().Get("login_hint")
|
||||
|
||||
if err := authRequest.IsValid(); err != nil {
|
||||
utils.RenderWebError(c.App.Config(), w, r, err.StatusCode,
|
||||
url.Values{
|
||||
"type": []string{"oauth_invalid_param"},
|
||||
"message": []string{err.Message},
|
||||
}, c.App.AsymmetricSigningKey())
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventAuthorizeOAuthPage, model.AuditStatusFail)
|
||||
auditRec.AddMeta("client_id", authRequest.ClientId)
|
||||
auditRec.AddMeta("scope", authRequest.Scope)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
|
||||
oauthApp, err := c.App.GetOAuthApp(authRequest.ClientId)
|
||||
if err != nil {
|
||||
utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey())
|
||||
return
|
||||
}
|
||||
|
||||
// here we should check if the user is logged in
|
||||
if c.AppContext.Session().UserId == "" {
|
||||
auditRec.Success()
|
||||
c.LogAudit("success")
|
||||
|
||||
if loginHint == model.UserAuthServiceSaml {
|
||||
http.Redirect(w, r, c.GetSiteURLHeader()+"/login/sso/saml?redirect_to="+url.QueryEscape(r.RequestURI), http.StatusFound)
|
||||
} else {
|
||||
http.Redirect(w, r, c.GetSiteURLHeader()+"/login?redirect_to="+url.QueryEscape(r.RequestURI), http.StatusFound)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !oauthApp.IsValidRedirectURL(authRequest.RedirectURI) {
|
||||
err := model.NewAppError("authorizeOAuthPage", "api.oauth.allow_oauth.redirect_callback.app_error", nil, "", http.StatusBadRequest)
|
||||
utils.RenderWebError(c.App.Config(), w, r, err.StatusCode,
|
||||
url.Values{
|
||||
"type": []string{"oauth_invalid_redirect_url"},
|
||||
"message": []string{i18n.T("api.oauth.allow_oauth.redirect_callback.app_error")},
|
||||
}, c.App.AsymmetricSigningKey())
|
||||
return
|
||||
}
|
||||
|
||||
// Validate PKCE requirements for public clients using authorization code flow
|
||||
// Implicit flow doesn't require PKCE as it doesn't use code exchange
|
||||
if oauthApp.IsPublicClient() && authRequest.ResponseType == model.AuthCodeResponseType && authRequest.CodeChallenge == "" {
|
||||
err := model.NewAppError("authorizeOAuthPage", "api.oauth.allow_oauth.pkce_required_public.app_error", nil, "", http.StatusBadRequest)
|
||||
utils.RenderWebError(c.App.Config(), w, r, err.StatusCode,
|
||||
url.Values{
|
||||
"type": []string{"oauth_pkce_required"},
|
||||
"message": []string{"PKCE is required for public clients using authorization code flow"},
|
||||
}, c.App.AsymmetricSigningKey())
|
||||
return
|
||||
}
|
||||
|
||||
isAuthorized := false
|
||||
|
||||
if _, err := c.App.GetPreferenceByCategoryAndNameForUser(c.AppContext, c.AppContext.Session().UserId, model.PreferenceCategoryAuthorizedOAuthApp, authRequest.ClientId); err == nil {
|
||||
// when we support scopes we should check if the scopes match
|
||||
isAuthorized = true
|
||||
}
|
||||
|
||||
// Automatically allow if the app is trusted
|
||||
if oauthApp.IsTrusted || isAuthorized {
|
||||
redirectURL, err := c.App.AllowOAuthAppAccessToUser(c.AppContext, c.AppContext.Session().UserId, authRequest)
|
||||
if err != nil {
|
||||
utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey())
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
c.LogAudit("success")
|
||||
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
c.LogAudit("success")
|
||||
|
||||
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
||||
w.Header().Set("Content-Security-Policy", fmt.Sprintf("frame-ancestors 'self' %s", *c.App.Config().ServiceSettings.FrameAncestors))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, max-age=31556926")
|
||||
|
||||
staticDir, _ := fileutils.FindDir(model.ClientDir)
|
||||
http.ServeFile(w, r, filepath.Join(staticDir, "root.html"))
|
||||
}
|
||||
|
||||
func getAccessToken(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
c.Err = model.NewAppError("getAccessToken", "api.oauth.get_access_token.bad_request.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.FormValue("code")
|
||||
refreshToken := r.FormValue("refresh_token")
|
||||
|
||||
grantType := r.FormValue("grant_type")
|
||||
switch grantType {
|
||||
case model.AccessTokenGrantType:
|
||||
if code == "" {
|
||||
c.Err = model.NewAppError("getAccessToken", "api.oauth.get_access_token.missing_code.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
case model.RefreshTokenGrantType:
|
||||
if refreshToken == "" {
|
||||
c.Err = model.NewAppError("getAccessToken", "api.oauth.get_access_token.missing_refresh_token.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
default:
|
||||
c.Err = model.NewAppError("getAccessToken", "api.oauth.get_access_token.bad_grant.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
clientId := r.FormValue("client_id")
|
||||
if !model.IsValidId(clientId) {
|
||||
c.Err = model.NewAppError("getAccessToken", "api.oauth.get_access_token.bad_client_id.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
secret := r.FormValue("client_secret")
|
||||
codeVerifier := r.FormValue("code_verifier")
|
||||
|
||||
// Authentication validation will be done at app layer based on client type
|
||||
// For public clients: client_secret should be empty, code_verifier required
|
||||
// For confidential clients: client_secret required, code_verifier optional but enforced if used
|
||||
|
||||
redirectURI := r.FormValue("redirect_uri")
|
||||
resource := r.FormValue("resource")
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventGetAccessToken, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
auditRec.AddMeta("grant_type", grantType)
|
||||
auditRec.AddMeta("client_id", clientId)
|
||||
c.LogAudit("attempt")
|
||||
|
||||
accessRsp, err := c.App.GetOAuthAccessTokenForCodeFlow(c.AppContext, clientId, grantType, redirectURI, code, secret, refreshToken, codeVerifier, resource)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
|
||||
auditRec.Success()
|
||||
c.LogAudit("success")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(accessRsp); err != nil {
|
||||
c.Logger.Warn("Error writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func completeOAuth(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireService()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
service := c.Params.Service
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventCompleteOAuth, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "service", service)
|
||||
|
||||
oauthError := r.URL.Query().Get("error")
|
||||
if oauthError == "access_denied" {
|
||||
utils.RenderWebError(c.App.Config(), w, r, http.StatusTemporaryRedirect, url.Values{
|
||||
"type": []string{"oauth_access_denied"},
|
||||
"service": []string{strings.Title(service)},
|
||||
}, c.App.AsymmetricSigningKey())
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
utils.RenderWebError(c.App.Config(), w, r, http.StatusTemporaryRedirect, url.Values{
|
||||
"type": []string{"oauth_missing_code"},
|
||||
"service": []string{strings.Title(service)},
|
||||
}, c.App.AsymmetricSigningKey())
|
||||
return
|
||||
}
|
||||
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
uri := c.GetSiteURLHeader() + "/signup/" + service + "/complete"
|
||||
|
||||
body, props, tokenUser, err := c.App.AuthorizeOAuthUser(c.AppContext, w, r, service, code, state, uri)
|
||||
|
||||
action := ""
|
||||
hasRedirectURL := false
|
||||
isMobile := false
|
||||
redirectURL := ""
|
||||
if props != nil {
|
||||
action = props["action"]
|
||||
isMobile = action == model.OAuthActionMobile
|
||||
if val, ok := props["redirect_to"]; ok {
|
||||
redirectURL = val
|
||||
hasRedirectURL = redirectURL != ""
|
||||
}
|
||||
}
|
||||
redirectURL = fullyQualifiedRedirectURL(c.GetSiteURLHeader(), redirectURL, c.App.Config().NativeAppSettings.AppCustomURLSchemes)
|
||||
|
||||
renderError := func(err *model.AppError) {
|
||||
if isMobile && hasRedirectURL {
|
||||
utils.RenderMobileError(c.App.Config(), w, err, redirectURL)
|
||||
} else {
|
||||
utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey())
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
err.Translate(c.AppContext.T)
|
||||
c.LogErrorByCode(err)
|
||||
renderError(err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := c.App.CompleteOAuth(c.AppContext, service, body, props, tokenUser)
|
||||
if err != nil {
|
||||
err.Translate(c.AppContext.T)
|
||||
c.LogErrorByCode(err)
|
||||
renderError(err)
|
||||
return
|
||||
}
|
||||
|
||||
if action == model.OAuthActionEmailToSSO {
|
||||
redirectURL = c.GetSiteURLHeader() + "/login?extra=signin_change"
|
||||
} else if action == model.OAuthActionSSOToEmail {
|
||||
redirectURL = app.GetProtocol(r) + "://" + r.Host + "/claim?email=" + url.QueryEscape(props["email"])
|
||||
} else {
|
||||
desktopToken := ""
|
||||
if val, ok := props["desktop_token"]; ok {
|
||||
desktopToken = val
|
||||
}
|
||||
|
||||
// If it's a desktop login we generate a token and redirect to another endpoint to handle session creation
|
||||
if desktopToken != "" {
|
||||
serverToken, serverTokenErr := c.App.GenerateAndSaveDesktopToken(time.Now().Unix(), user)
|
||||
if serverTokenErr != nil {
|
||||
serverTokenErr.Translate(c.AppContext.T)
|
||||
c.LogErrorByCode(serverTokenErr)
|
||||
renderError(serverTokenErr)
|
||||
return
|
||||
}
|
||||
|
||||
queryString := map[string]string{
|
||||
"client_token": desktopToken,
|
||||
"server_token": *serverToken,
|
||||
}
|
||||
if val, ok := props["redirect_to"]; ok {
|
||||
queryString["redirect_to"] = val
|
||||
}
|
||||
if strings.HasPrefix(desktopToken, "dev-") {
|
||||
queryString["isDesktopDev"] = "true"
|
||||
}
|
||||
|
||||
redirectURL = utils.AppendQueryParamsToURL(c.GetSiteURLHeader()+"/login/desktop", queryString)
|
||||
|
||||
auditRec.Success()
|
||||
auditRec.Actor.UserId = user.Id
|
||||
c.LogAuditWithUserId(user.Id, "authenticated")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
isOAuthUser := user.IsOAuthUser()
|
||||
|
||||
session, err := c.App.DoLogin(c.AppContext, w, r, user, model.LoginOptions{
|
||||
IsMobile: isMobile,
|
||||
IsOAuthUser: isOAuthUser,
|
||||
})
|
||||
if err != nil {
|
||||
err.Translate(c.AppContext.T)
|
||||
c.Logger.Error(err.Error())
|
||||
renderError(err)
|
||||
return
|
||||
}
|
||||
c.AppContext = c.AppContext.WithSession(session)
|
||||
|
||||
// Old mobile version
|
||||
if isMobile && !hasRedirectURL {
|
||||
c.App.AttachSessionCookies(c.AppContext, w, r)
|
||||
|
||||
auditRec.Success()
|
||||
c.LogAudit("success")
|
||||
|
||||
return
|
||||
} else
|
||||
// New mobile version
|
||||
if isMobile && hasRedirectURL {
|
||||
redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{
|
||||
model.SessionCookieToken: c.AppContext.Session().Token,
|
||||
model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(),
|
||||
"srv": c.App.GetSiteURL(), // Server URL for mobile client verification
|
||||
})
|
||||
utils.RenderMobileAuthComplete(w, redirectURL)
|
||||
|
||||
auditRec.Success()
|
||||
c.LogAudit("success")
|
||||
|
||||
return
|
||||
}
|
||||
// For web
|
||||
c.App.AttachSessionCookies(c.AppContext, w, r)
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
c.LogAudit("success")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func loginWithOAuth(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireService()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
loginHint := r.URL.Query().Get("login_hint")
|
||||
redirectURL := r.URL.Query().Get("redirect_to")
|
||||
desktopToken := r.URL.Query().Get("desktop_token")
|
||||
|
||||
if redirectURL != "" {
|
||||
if err := utils.ValidateWebAuthRedirectUrl(c.App.Config(), redirectURL); err != nil {
|
||||
c.Err = model.NewAppError("loginWithOAuth", "api.invalid_redirect_url", nil, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventLoginWithOAuth, model.AuditStatusFail)
|
||||
auditRec.AddMeta("service", c.Params.Service)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
|
||||
// Get invite token or ID instead of team_id
|
||||
tokenID := r.URL.Query().Get("t")
|
||||
inviteId := r.URL.Query().Get("id")
|
||||
|
||||
authURL, err := c.App.GetOAuthLoginEndpoint(c.AppContext, w, r, c.Params.Service, model.OAuthActionLogin, redirectURL, loginHint, false, desktopToken, tokenID, inviteId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
c.LogAudit("success")
|
||||
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func mobileLoginWithOAuth(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireService()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL := html.EscapeString(r.URL.Query().Get("redirect_to"))
|
||||
|
||||
if redirectURL != "" && !utils.IsValidMobileAuthRedirectURL(c.App.Config(), redirectURL) {
|
||||
err := model.NewAppError("mobileLoginWithOAuth", "api.invalid_custom_url_scheme", nil, "", http.StatusBadRequest)
|
||||
utils.RenderMobileError(c.App.Config(), w, err, redirectURL)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventMobileLoginWithOAuth, model.AuditStatusFail)
|
||||
auditRec.AddMeta("service", c.Params.Service)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
|
||||
// Get invite token or ID instead of team_id
|
||||
tokenID := r.URL.Query().Get("t")
|
||||
inviteId := r.URL.Query().Get("id")
|
||||
|
||||
authURL, err := c.App.GetOAuthLoginEndpoint(c.AppContext, w, r, c.Params.Service, model.OAuthActionMobile, redirectURL, "", true, "", tokenID, inviteId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
c.LogAudit("success")
|
||||
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func signupWithOAuth(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireService()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !*c.App.Config().TeamSettings.EnableUserCreation {
|
||||
utils.RenderWebError(c.App.Config(), w, r, http.StatusBadRequest, url.Values{
|
||||
"message": []string{i18n.T("api.oauth.singup_with_oauth.disabled.app_error")},
|
||||
}, c.App.AsymmetricSigningKey())
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventSignupWithOAuth, model.AuditStatusFail)
|
||||
auditRec.AddMeta("service", c.Params.Service)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
|
||||
// Get invite token or ID instead of team_id
|
||||
tokenID := r.URL.Query().Get("t")
|
||||
inviteId := r.URL.Query().Get("id")
|
||||
|
||||
desktopToken := r.URL.Query().Get("desktop_token")
|
||||
|
||||
authURL, err := c.App.GetOAuthSignupEndpoint(c.AppContext, w, r, c.Params.Service, desktopToken, tokenID, inviteId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
c.LogAudit("success")
|
||||
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// pathWithinPrefix reports whether target is at or under prefix, comparing on
|
||||
// path segments so /mmfoo is not treated as under /mm. An empty or "/" prefix
|
||||
// (root SiteURL) matches the root and any absolute path.
|
||||
func pathWithinPrefix(target, prefix string) bool {
|
||||
target = path.Clean(target)
|
||||
prefix = path.Clean(prefix)
|
||||
if prefix == "." || prefix == "/" {
|
||||
return target == "." || strings.HasPrefix(target, "/")
|
||||
}
|
||||
return target == prefix || strings.HasPrefix(target, prefix+"/")
|
||||
}
|
||||
|
||||
func fullyQualifiedRedirectURL(siteURLPrefix, targetURL string, otherValidSchemes []string) string {
|
||||
parsed, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return siteURLPrefix
|
||||
}
|
||||
prefixParsed, err := url.Parse(siteURLPrefix)
|
||||
if err != nil {
|
||||
return siteURLPrefix
|
||||
}
|
||||
// mobile access
|
||||
if slices.Contains(otherValidSchemes, fmt.Sprintf("%v://", parsed.Scheme)) &&
|
||||
parsed.Host == callbackHost &&
|
||||
parsed.Path == "" &&
|
||||
parsed.RawQuery == "" &&
|
||||
parsed.Fragment == "" {
|
||||
return targetURL
|
||||
}
|
||||
// Check if the targetURL is valid and within the siteURLPrefix, excluding native app schemes like mmauth://
|
||||
sameScheme := parsed.Scheme == prefixParsed.Scheme
|
||||
sameHost := parsed.Host == prefixParsed.Host
|
||||
safePath := pathWithinPrefix(parsed.Path, prefixParsed.Path)
|
||||
|
||||
if sameScheme && sameHost && safePath {
|
||||
return targetURL
|
||||
} else if parsed.Scheme != "" || parsed.Host != "" {
|
||||
return siteURLPrefix
|
||||
}
|
||||
|
||||
// For relative URLs, normalize and join with siteURLPrefix
|
||||
if targetURL != "" && targetURL[0] != '/' {
|
||||
targetURL = "/" + targetURL
|
||||
}
|
||||
|
||||
// Check for path traversal
|
||||
joinedURL, err := url.JoinPath(siteURLPrefix, targetURL)
|
||||
if err != nil {
|
||||
return siteURLPrefix
|
||||
}
|
||||
unescapedURL, err := url.PathUnescape(joinedURL)
|
||||
if err != nil {
|
||||
return siteURLPrefix
|
||||
}
|
||||
parsed, err = url.Parse(unescapedURL)
|
||||
if err != nil {
|
||||
return siteURLPrefix
|
||||
}
|
||||
|
||||
if !pathWithinPrefix(parsed.Path, prefixParsed.Path) {
|
||||
return siteURLPrefix
|
||||
}
|
||||
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func getAuthorizationServerMetadata(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !*c.App.Config().ServiceSettings.EnableOAuthServiceProvider {
|
||||
c.Err = model.NewAppError("getAuthorizationServerMetadata", "api.oauth.authorization_server_metadata.disabled.app_error", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
metadata, err := c.App.GetAuthorizationServerMetadata(c.AppContext)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(metadata); err != nil {
|
||||
c.Logger.Warn("Error writing authorization server metadata response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
// loginByIntune handles authentication using Microsoft Intune MAM with Entra ID tokens
|
||||
func loginByIntune(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
var req model.IntuneLoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
c.SetInvalidParamWithErr("request_body", err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.AccessToken == "" {
|
||||
c.SetInvalidParam("access_token")
|
||||
return
|
||||
}
|
||||
|
||||
if req.VoIPDeviceId != "" && !model.IsValidVoIPDeviceId(req.VoIPDeviceId) {
|
||||
c.SetInvalidParam("voip_device_id")
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord("login_by_intune", model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
c.LogAudit("attempt")
|
||||
|
||||
// Authenticate user via Intune MAM
|
||||
user, err := c.App.LoginByIntune(c.AppContext, req.AccessToken)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.AddMeta("obtained_user_id", user.Id)
|
||||
c.LogAuditWithUserId(user.Id, "obtained user")
|
||||
|
||||
model.AddEventParameterToAuditRec(auditRec, "device_id", req.DeviceId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "voip_device_id", req.VoIPDeviceId)
|
||||
|
||||
session, err := c.App.DoLogin(c.AppContext, w, r, user, model.LoginOptions{
|
||||
DeviceId: req.DeviceId,
|
||||
VoIPDeviceId: req.VoIPDeviceId,
|
||||
IsOAuthUser: true,
|
||||
})
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
c.AppContext = c.AppContext.WithSession(session)
|
||||
|
||||
c.App.AttachSessionCookies(c.AppContext, w, r)
|
||||
|
||||
auditRec.Success()
|
||||
c.LogAuditWithUserId(user.Id, "success")
|
||||
|
||||
user.Sanitize(map[string]bool{})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(user); err != nil {
|
||||
c.Logger.Warn("Failed to encode user response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
1264
server/channels/web/oauth_test.go
Normal file
1264
server/channels/web/oauth_test.go
Normal file
File diff suppressed because it is too large
Load Diff
331
server/channels/web/params.go
Normal file
331
server/channels/web/params.go
Normal file
@@ -0,0 +1,331 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
const (
|
||||
PageDefault = 0
|
||||
PerPageDefault = 60
|
||||
PerPageMaximum = 200
|
||||
LogsPerPageDefault = 10000
|
||||
LogsPerPageMaximum = 10000
|
||||
LimitDefault = 60
|
||||
LimitMaximum = 200
|
||||
)
|
||||
|
||||
type Params struct {
|
||||
UserId string
|
||||
OtherUserId string
|
||||
TeamId string
|
||||
InviteId string
|
||||
TokenId string
|
||||
ThreadId string
|
||||
Timestamp int64
|
||||
TimeRange string
|
||||
ChannelId string
|
||||
PostId string
|
||||
PolicyId string
|
||||
FileId string
|
||||
Filename string
|
||||
UploadId string
|
||||
PluginId string
|
||||
CommandId string
|
||||
HookId string
|
||||
ReportId string
|
||||
EmojiId string
|
||||
AppId string
|
||||
Email string
|
||||
Username string
|
||||
TeamName string
|
||||
ChannelName string
|
||||
PreferenceName string
|
||||
EmojiName string
|
||||
Category string
|
||||
Service string
|
||||
JobId string
|
||||
JobType string
|
||||
RecapId string
|
||||
ScheduledRecapId string
|
||||
ActionId string
|
||||
RoleId string
|
||||
RoleName string
|
||||
SchemeId string
|
||||
Scope string
|
||||
GroupId string
|
||||
Page int
|
||||
PerPage int
|
||||
LogsPerPage int
|
||||
Permanent bool
|
||||
RemoteId string
|
||||
SyncableId string
|
||||
SyncableType model.GroupSyncableType
|
||||
BotUserId string
|
||||
Q string
|
||||
IsLinked *bool
|
||||
IsConfigured *bool
|
||||
NotAssociatedToTeam string
|
||||
NotAssociatedToChannel string
|
||||
Paginate *bool
|
||||
IncludeMemberCount bool
|
||||
IncludeMemberIDs bool
|
||||
NotAssociatedToGroup string
|
||||
ExcludeDefaultChannels bool
|
||||
LimitAfter int
|
||||
LimitBefore int
|
||||
GroupIDs string
|
||||
IncludeTotalCount bool
|
||||
IncludeDeleted bool
|
||||
FilterAllowReference bool
|
||||
FilterArchived bool
|
||||
FilterParentTeamPermitted bool
|
||||
CategoryId string
|
||||
ExportName string
|
||||
ImportName string
|
||||
ExcludePolicyConstrained bool
|
||||
ForDirectory bool
|
||||
GroupSource model.GroupSource
|
||||
FilterHasMember string
|
||||
IncludeChannelMemberCount string
|
||||
OutgoingOAuthConnectionID string
|
||||
ExcludeOffline bool
|
||||
InChannel string
|
||||
NotInChannel string
|
||||
Topic string
|
||||
CreatorId string
|
||||
OnlyConfirmed bool
|
||||
OnlyPlugins bool
|
||||
IncludeUnconfirmed bool
|
||||
ExcludeConfirmed bool
|
||||
ExcludePlugins bool
|
||||
ExcludeHome bool
|
||||
ExcludeRemote bool
|
||||
AccessControlPolicyEnforced bool
|
||||
ExcludeAccessControlPolicyEnforced bool
|
||||
ContentReviewerId string
|
||||
|
||||
//Bookmarks
|
||||
ChannelBookmarkId string
|
||||
BookmarksSince int64
|
||||
|
||||
// Views
|
||||
ViewId string
|
||||
|
||||
// Cloud
|
||||
InvoiceId string
|
||||
|
||||
// Custom Profile Attributes
|
||||
FieldId string
|
||||
|
||||
// Properties
|
||||
GroupName string
|
||||
ObjectType string
|
||||
TargetId string
|
||||
|
||||
// Channel join requests
|
||||
RequestId string
|
||||
}
|
||||
|
||||
var getChannelMembersForUserRegex = regexp.MustCompile("/api/v4/users/[A-Za-z0-9]{26}/channel_members")
|
||||
|
||||
func ParamsFromRequest(r *http.Request) *Params {
|
||||
params := &Params{}
|
||||
|
||||
props := mux.Vars(r)
|
||||
query := r.URL.Query()
|
||||
|
||||
params.UserId = props["user_id"]
|
||||
params.OtherUserId = props["other_user_id"]
|
||||
params.TeamId = props["team_id"]
|
||||
params.CategoryId = props["category_id"]
|
||||
params.InviteId = props["invite_id"]
|
||||
params.TokenId = props["token_id"]
|
||||
params.ThreadId = props["thread_id"]
|
||||
|
||||
if val, ok := props["channel_id"]; ok {
|
||||
params.ChannelId = val
|
||||
} else {
|
||||
params.ChannelId = query.Get("channel_id")
|
||||
}
|
||||
|
||||
params.PostId = props["post_id"]
|
||||
params.PolicyId = props["policy_id"]
|
||||
params.FileId = props["file_id"]
|
||||
params.Filename = query.Get("filename")
|
||||
params.UploadId = props["upload_id"]
|
||||
if val, ok := props["plugin_id"]; ok {
|
||||
params.PluginId = val
|
||||
} else {
|
||||
params.PluginId = query.Get("plugin_id")
|
||||
}
|
||||
params.CommandId = props["command_id"]
|
||||
params.HookId = props["hook_id"]
|
||||
params.ReportId = props["report_id"]
|
||||
params.EmojiId = props["emoji_id"]
|
||||
params.AppId = props["app_id"]
|
||||
params.Email = props["email"]
|
||||
params.Username = props["username"]
|
||||
params.TeamName = strings.ToLower(props["team_name"])
|
||||
params.ChannelName = strings.ToLower(props["channel_name"])
|
||||
params.Category = props["category"]
|
||||
params.Service = props["service"]
|
||||
params.PreferenceName = props["preference_name"]
|
||||
params.EmojiName = props["emoji_name"]
|
||||
params.JobId = props["job_id"]
|
||||
params.JobType = props["job_type"]
|
||||
params.RecapId = props["recap_id"]
|
||||
params.ScheduledRecapId = props["scheduled_recap_id"]
|
||||
params.ActionId = props["action_id"]
|
||||
params.RoleId = props["role_id"]
|
||||
params.RoleName = props["role_name"]
|
||||
params.SchemeId = props["scheme_id"]
|
||||
params.GroupId = props["group_id"]
|
||||
params.RemoteId = props["remote_id"]
|
||||
params.InvoiceId = props["invoice_id"]
|
||||
params.OutgoingOAuthConnectionID = props["outgoing_oauth_connection_id"]
|
||||
params.ExcludeOffline, _ = strconv.ParseBool(query.Get("exclude_offline"))
|
||||
params.InChannel = query.Get("in_channel")
|
||||
params.NotInChannel = query.Get("not_in_channel")
|
||||
params.Topic = query.Get("topic")
|
||||
params.CreatorId = query.Get("creator_id")
|
||||
params.OnlyConfirmed, _ = strconv.ParseBool(query.Get("only_confirmed"))
|
||||
params.OnlyPlugins, _ = strconv.ParseBool(query.Get("only_plugins"))
|
||||
params.IncludeUnconfirmed, _ = strconv.ParseBool(query.Get("include_unconfirmed"))
|
||||
params.ExcludeConfirmed, _ = strconv.ParseBool(query.Get("exclude_confirmed"))
|
||||
params.ExcludePlugins, _ = strconv.ParseBool(query.Get("exclude_plugins"))
|
||||
params.ExcludeHome, _ = strconv.ParseBool(query.Get("exclude_home"))
|
||||
params.ExcludeRemote, _ = strconv.ParseBool(query.Get("exclude_remote"))
|
||||
params.ChannelBookmarkId = props["bookmark_id"]
|
||||
params.ViewId = props["view_id"]
|
||||
params.FieldId = props["field_id"]
|
||||
params.GroupName = props["group_name"]
|
||||
params.ObjectType = props["object_type"]
|
||||
params.TargetId = props["target_id"]
|
||||
params.RequestId = props["request_id"]
|
||||
params.Scope = query.Get("scope")
|
||||
|
||||
if val, err := strconv.Atoi(query.Get("page")); err != nil || (val < 0 && params.UserId == "" && !getChannelMembersForUserRegex.MatchString(r.URL.Path)) {
|
||||
// We don't want to apply this logic for the getChannelMembersForUser API handler
|
||||
// because that API allows page=-1 to switch to streaming mode.
|
||||
params.Page = PageDefault
|
||||
} else {
|
||||
params.Page = val
|
||||
}
|
||||
|
||||
if val, err := strconv.ParseInt(props["timestamp"], 10, 64); err != nil || val < 0 {
|
||||
params.Timestamp = 0
|
||||
} else {
|
||||
params.Timestamp = val
|
||||
}
|
||||
|
||||
params.TimeRange = query.Get("time_range")
|
||||
params.Permanent, _ = strconv.ParseBool(query.Get("permanent"))
|
||||
|
||||
val, err := strconv.Atoi(query.Get("per_page"))
|
||||
if err != nil || val < 0 {
|
||||
params.PerPage = PerPageDefault
|
||||
} else if val > PerPageMaximum {
|
||||
params.PerPage = PerPageMaximum
|
||||
} else {
|
||||
params.PerPage = val
|
||||
}
|
||||
|
||||
if val, err := strconv.Atoi(query.Get("logs_per_page")); err != nil || val < 0 {
|
||||
params.LogsPerPage = LogsPerPageDefault
|
||||
} else if val > LogsPerPageMaximum {
|
||||
params.LogsPerPage = LogsPerPageMaximum
|
||||
} else {
|
||||
params.LogsPerPage = val
|
||||
}
|
||||
|
||||
if val, err := strconv.Atoi(query.Get("limit_after")); err != nil || val < 0 {
|
||||
params.LimitAfter = LimitDefault
|
||||
} else if val > LimitMaximum {
|
||||
params.LimitAfter = LimitMaximum
|
||||
} else {
|
||||
params.LimitAfter = val
|
||||
}
|
||||
|
||||
if val, err := strconv.Atoi(query.Get("limit_before")); err != nil || val < 0 {
|
||||
params.LimitBefore = LimitDefault
|
||||
} else if val > LimitMaximum {
|
||||
params.LimitBefore = LimitMaximum
|
||||
} else {
|
||||
params.LimitBefore = val
|
||||
}
|
||||
|
||||
params.SyncableId = props["syncable_id"]
|
||||
|
||||
switch props["syncable_type"] {
|
||||
case "teams":
|
||||
params.SyncableType = model.GroupSyncableTypeTeam
|
||||
case "channels":
|
||||
params.SyncableType = model.GroupSyncableTypeChannel
|
||||
}
|
||||
|
||||
params.BotUserId = props["bot_user_id"]
|
||||
params.Q = query.Get("q")
|
||||
|
||||
if val, err := strconv.ParseBool(query.Get("is_linked")); err == nil {
|
||||
params.IsLinked = &val
|
||||
}
|
||||
|
||||
if val, err := strconv.ParseBool(query.Get("is_configured")); err == nil {
|
||||
params.IsConfigured = &val
|
||||
}
|
||||
|
||||
params.NotAssociatedToTeam = query.Get("not_associated_to_team")
|
||||
params.NotAssociatedToChannel = query.Get("not_associated_to_channel")
|
||||
params.FilterAllowReference, _ = strconv.ParseBool(query.Get("filter_allow_reference"))
|
||||
params.FilterArchived, _ = strconv.ParseBool(query.Get("filter_archived"))
|
||||
params.FilterParentTeamPermitted, _ = strconv.ParseBool(query.Get("filter_parent_team_permitted"))
|
||||
params.IncludeChannelMemberCount = query.Get("include_channel_member_count")
|
||||
|
||||
if val, err := strconv.ParseBool(query.Get("paginate")); err == nil {
|
||||
params.Paginate = &val
|
||||
}
|
||||
|
||||
params.IncludeMemberCount, _ = strconv.ParseBool(query.Get("include_member_count"))
|
||||
params.IncludeMemberIDs, _ = strconv.ParseBool(query.Get("include_member_ids"))
|
||||
params.NotAssociatedToGroup = query.Get("not_associated_to_group")
|
||||
params.ExcludeDefaultChannels, _ = strconv.ParseBool(query.Get("exclude_default_channels"))
|
||||
params.GroupIDs = query.Get("group_ids")
|
||||
params.IncludeTotalCount, _ = strconv.ParseBool(query.Get("include_total_count"))
|
||||
params.IncludeDeleted, _ = strconv.ParseBool(query.Get("include_deleted"))
|
||||
params.ExportName = props["export_name"]
|
||||
params.ImportName = props["import_name"]
|
||||
params.ExcludePolicyConstrained, _ = strconv.ParseBool(query.Get("exclude_policy_constrained"))
|
||||
params.ForDirectory, _ = strconv.ParseBool(query.Get("for_directory"))
|
||||
params.AccessControlPolicyEnforced, _ = strconv.ParseBool(query.Get("access_control_policy_enforced"))
|
||||
params.ExcludeAccessControlPolicyEnforced, _ = strconv.ParseBool(query.Get("exclude_access_control_policy_enforced"))
|
||||
params.ContentReviewerId = props["content_reviewer_id"]
|
||||
|
||||
if val := query.Get("group_source"); val != "" {
|
||||
switch val {
|
||||
case "custom":
|
||||
params.GroupSource = model.GroupSourceCustom
|
||||
default:
|
||||
params.GroupSource = model.GroupSourceLdap
|
||||
}
|
||||
}
|
||||
|
||||
params.FilterHasMember = query.Get("filter_has_member")
|
||||
|
||||
if val, err := strconv.ParseInt(query.Get("bookmarks_since"), 10, 64); err != nil || val < 0 {
|
||||
params.BookmarksSince = 0
|
||||
} else {
|
||||
params.BookmarksSince = val
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
498
server/channels/web/params_test.go
Normal file
498
server/channels/web/params_test.go
Normal file
@@ -0,0 +1,498 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
func TestParamsFromRequest(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Description string
|
||||
URL *url.URL
|
||||
Vars map[string]string
|
||||
Params *Params
|
||||
}{
|
||||
{
|
||||
"empty params",
|
||||
mustURL("/"),
|
||||
nil,
|
||||
&Params{
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"query params",
|
||||
mustURL("/page?" +
|
||||
"channel_id=abc123&" +
|
||||
"filename=file.ext&" +
|
||||
"page=42&" +
|
||||
"time_range=then-till-now&" +
|
||||
"permanent=1&" +
|
||||
"logs_per_page=5&" +
|
||||
"limit_after=6&" +
|
||||
"limit_before=7&" +
|
||||
"q=picard&" +
|
||||
"is_linked=t&" +
|
||||
"is_configured=TRUE&" +
|
||||
"not_associated_to_team=this_team&" +
|
||||
"not_associated_to_channel=this_channel&" +
|
||||
"filter_allow_reference=true&" +
|
||||
"filter_parent_team_permitted=True&" +
|
||||
"paginate=T&" +
|
||||
"include_member_count=1&" +
|
||||
"not_associated_to_group=test&" +
|
||||
"exclude_default_channels=1&" +
|
||||
"group_ids=hello,world&" +
|
||||
"include_total_count=T&" +
|
||||
"include_deleted=True&" +
|
||||
"exclude_policy_constrained=TRUE&" +
|
||||
"filter_has_member=xyz"),
|
||||
nil,
|
||||
&Params{
|
||||
ChannelId: "abc123",
|
||||
Filename: "file.ext",
|
||||
Page: 42,
|
||||
TimeRange: "then-till-now",
|
||||
PerPage: PerPageDefault,
|
||||
Permanent: true,
|
||||
LogsPerPage: 5,
|
||||
LimitAfter: 6,
|
||||
LimitBefore: 7,
|
||||
Q: "picard",
|
||||
IsLinked: new(true),
|
||||
IsConfigured: new(true),
|
||||
NotAssociatedToTeam: "this_team",
|
||||
NotAssociatedToChannel: "this_channel",
|
||||
FilterAllowReference: true,
|
||||
FilterParentTeamPermitted: true,
|
||||
Paginate: new(true),
|
||||
IncludeMemberCount: true,
|
||||
NotAssociatedToGroup: "test",
|
||||
ExcludeDefaultChannels: true,
|
||||
GroupIDs: "hello,world",
|
||||
IncludeTotalCount: true,
|
||||
IncludeDeleted: true,
|
||||
ExcludePolicyConstrained: true,
|
||||
FilterHasMember: "xyz",
|
||||
},
|
||||
},
|
||||
{
|
||||
"page invalid",
|
||||
mustURL("?page=hello"),
|
||||
nil,
|
||||
&Params{
|
||||
Page: PageDefault,
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"page negative",
|
||||
mustURL("?page=-1"),
|
||||
nil,
|
||||
&Params{
|
||||
Page: PageDefault,
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"per page valid",
|
||||
mustURL("?per_page=123"),
|
||||
nil,
|
||||
&Params{
|
||||
PerPage: 123,
|
||||
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"per page too small",
|
||||
mustURL("?per_page=-100"),
|
||||
nil,
|
||||
&Params{
|
||||
PerPage: PerPageDefault,
|
||||
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"per page too big",
|
||||
mustURL("?per_page=100000"),
|
||||
nil,
|
||||
&Params{
|
||||
PerPage: PerPageMaximum,
|
||||
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"logs per page valid",
|
||||
mustURL("?logs_per_page=512"),
|
||||
nil,
|
||||
&Params{
|
||||
LogsPerPage: 512,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"logs per page invalid",
|
||||
mustURL("?logs_per_page=logs"),
|
||||
nil,
|
||||
&Params{
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"logs per page too small",
|
||||
mustURL("?logs_per_page=-512"),
|
||||
nil,
|
||||
&Params{
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"logs per page too big",
|
||||
mustURL("?logs_per_page=99999999"),
|
||||
nil,
|
||||
&Params{
|
||||
LogsPerPage: LogsPerPageMaximum,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"limit before valid",
|
||||
mustURL("?limit_before=100"),
|
||||
nil,
|
||||
&Params{
|
||||
LimitBefore: 100,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"limit before invalid",
|
||||
mustURL("?limit_before=limit"),
|
||||
nil,
|
||||
&Params{
|
||||
LimitBefore: LimitDefault,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"limit before too small",
|
||||
mustURL("?limit_before=-100"),
|
||||
nil,
|
||||
&Params{
|
||||
LimitBefore: LimitDefault,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"limit before too big",
|
||||
mustURL("?limit_before=99999"),
|
||||
nil,
|
||||
&Params{
|
||||
LimitBefore: LimitMaximum,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"limit after valid",
|
||||
mustURL("?limit_after=100"),
|
||||
nil,
|
||||
&Params{
|
||||
LimitAfter: 100,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"limit after invalid",
|
||||
mustURL("?limit_after=limit"),
|
||||
nil,
|
||||
&Params{
|
||||
LimitAfter: LimitDefault,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"limit after too small",
|
||||
mustURL("?limit_aftere=-100"),
|
||||
nil,
|
||||
&Params{
|
||||
LimitAfter: LimitDefault,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"limit after too big",
|
||||
mustURL("?limit_after=99999"),
|
||||
nil,
|
||||
&Params{
|
||||
LimitAfter: LimitMaximum,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"group source custom",
|
||||
mustURL("?group_source=custom"),
|
||||
nil,
|
||||
&Params{
|
||||
GroupSource: model.GroupSourceCustom,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"group source LDAP",
|
||||
mustURL("?group_source=ldap"),
|
||||
nil,
|
||||
&Params{
|
||||
GroupSource: model.GroupSourceLdap,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"group source other",
|
||||
mustURL("?group_source=aabbcc"),
|
||||
nil,
|
||||
&Params{
|
||||
GroupSource: model.GroupSourceLdap,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"group source empty",
|
||||
mustURL("?group_souce="),
|
||||
nil,
|
||||
&Params{
|
||||
GroupSource: "",
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"timestamp valid",
|
||||
mustURL("/"),
|
||||
map[string]string{
|
||||
"timestamp": "1234567",
|
||||
},
|
||||
&Params{
|
||||
Timestamp: 1234567,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"timestamp valid",
|
||||
mustURL("/"),
|
||||
map[string]string{
|
||||
"timestamp": "yes",
|
||||
},
|
||||
&Params{
|
||||
Timestamp: 0,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"timestamp too small",
|
||||
mustURL("/"),
|
||||
map[string]string{
|
||||
"timestamp": "-1234567",
|
||||
},
|
||||
&Params{
|
||||
Timestamp: 0,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"syncable type teams",
|
||||
mustURL("/"),
|
||||
map[string]string{
|
||||
"syncable_type": "teams",
|
||||
},
|
||||
&Params{
|
||||
SyncableType: model.GroupSyncableTypeTeam,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"syncable type channels",
|
||||
mustURL("/"),
|
||||
map[string]string{
|
||||
"syncable_type": "channels",
|
||||
},
|
||||
&Params{
|
||||
SyncableType: model.GroupSyncableTypeChannel,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"syncable type other",
|
||||
mustURL("/"),
|
||||
map[string]string{
|
||||
"syncable_type": "unknownvalue",
|
||||
},
|
||||
&Params{
|
||||
SyncableType: "",
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
LimitAfter: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"include channel bookmarks",
|
||||
mustURL("/?include_bookmarks=true"),
|
||||
nil,
|
||||
&Params{
|
||||
BookmarksSince: 0,
|
||||
|
||||
LimitAfter: LimitDefault,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"include channel bookmarks with negative bookmark since",
|
||||
mustURL("/?include_bookmarks=true&bookmarks_since=-1"),
|
||||
nil,
|
||||
&Params{
|
||||
BookmarksSince: 0,
|
||||
|
||||
LimitAfter: LimitDefault,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
{
|
||||
"include channel bookmarks with bookmark since",
|
||||
mustURL("/?include_bookmarks=true&bookmarks_since=123456789"),
|
||||
nil,
|
||||
&Params{
|
||||
BookmarksSince: 123456789,
|
||||
|
||||
LimitAfter: LimitDefault,
|
||||
|
||||
PerPage: PerPageDefault,
|
||||
LogsPerPage: LogsPerPageDefault,
|
||||
LimitBefore: LimitDefault,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.Description, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := &http.Request{URL: testCase.URL}
|
||||
r = mux.SetURLVars(r, testCase.Vars)
|
||||
params := ParamsFromRequest(r)
|
||||
require.Equal(t, testCase.Params, params)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustURL(u string) *url.URL {
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
62
server/channels/web/response_writer_wrapper.go
Normal file
62
server/channels/web/response_writer_wrapper.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type responseWriterWrapper struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
statusCodeWritten bool
|
||||
hijacker http.Hijacker
|
||||
flusher http.Flusher
|
||||
}
|
||||
|
||||
func newWrappedWriter(original http.ResponseWriter) *responseWriterWrapper {
|
||||
hijacker, _ := original.(http.Hijacker)
|
||||
flusher, _ := original.(http.Flusher)
|
||||
return &responseWriterWrapper{
|
||||
ResponseWriter: original,
|
||||
statusCodeWritten: false,
|
||||
hijacker: hijacker,
|
||||
flusher: flusher,
|
||||
}
|
||||
}
|
||||
|
||||
func (rw *responseWriterWrapper) StatusCode() int {
|
||||
return rw.statusCode
|
||||
}
|
||||
|
||||
func (rw *responseWriterWrapper) WriteHeader(statusCode int) {
|
||||
rw.statusCode = statusCode
|
||||
rw.statusCodeWritten = true
|
||||
rw.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (rw *responseWriterWrapper) Write(data []byte) (int, error) {
|
||||
if !rw.statusCodeWritten {
|
||||
rw.statusCode = http.StatusOK
|
||||
}
|
||||
return rw.ResponseWriter.Write(data)
|
||||
}
|
||||
|
||||
// Using as embedded makes the ResponseWrite be stored as interface and that way
|
||||
// it loses the access to the implementation for Hijack or Flush
|
||||
func (rw *responseWriterWrapper) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
if rw.hijacker == nil {
|
||||
return nil, nil, errors.New("Hijacker interface not supported by the wrapped ResponseWriter")
|
||||
}
|
||||
return rw.hijacker.Hijack()
|
||||
}
|
||||
|
||||
func (rw *responseWriterWrapper) Flush() {
|
||||
if rw.flusher != nil {
|
||||
rw.flusher.Flush()
|
||||
}
|
||||
}
|
||||
96
server/channels/web/response_writer_wrapper_test.go
Normal file
96
server/channels/web/response_writer_wrapper_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type TestHandler struct {
|
||||
TestFunc func(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (h *TestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.TestFunc(w, r)
|
||||
}
|
||||
|
||||
type responseRecorderHijack struct {
|
||||
httptest.ResponseRecorder
|
||||
}
|
||||
|
||||
func (r *responseRecorderHijack) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
r.WriteHeader(http.StatusOK)
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func newResponseWithHijack(original *httptest.ResponseRecorder) *responseRecorderHijack {
|
||||
return &responseRecorderHijack{*original}
|
||||
}
|
||||
|
||||
func TestStatusCodeIsAccessible(t *testing.T) {
|
||||
resp := newWrappedWriter(httptest.NewRecorder())
|
||||
req := httptest.NewRequest("GET", "/api/v4/test", nil)
|
||||
handler := TestHandler{func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}}
|
||||
handler.ServeHTTP(resp, req)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode())
|
||||
}
|
||||
|
||||
func TestStatusCodeShouldBe200IfNotHeaderWritten(t *testing.T) {
|
||||
resp := newWrappedWriter(httptest.NewRecorder())
|
||||
req := httptest.NewRequest("GET", "/api/v4/test", nil)
|
||||
handler := TestHandler{func(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := w.Write([]byte{})
|
||||
require.NoError(t, err, "Failed to write response")
|
||||
require.Equal(t, 0, n, "Expected to write 0 bytes")
|
||||
}}
|
||||
handler.ServeHTTP(resp, req)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode())
|
||||
}
|
||||
|
||||
func TestForUnsupportedHijack(t *testing.T) {
|
||||
resp := newWrappedWriter(httptest.NewRecorder())
|
||||
req := httptest.NewRequest("GET", "/api/v4/test", nil)
|
||||
handler := TestHandler{func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, rw, err := w.(*responseWriterWrapper).Hijack()
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "Hijacker interface not supported by the wrapped ResponseWriter", err.Error())
|
||||
assert.Nil(t, conn, "Expected nil connection")
|
||||
assert.Nil(t, rw, "Expected nil buffer")
|
||||
}}
|
||||
handler.ServeHTTP(resp, req)
|
||||
}
|
||||
|
||||
func TestForSupportedHijack(t *testing.T) {
|
||||
resp := newWrappedWriter(newResponseWithHijack(httptest.NewRecorder()))
|
||||
req := httptest.NewRequest("GET", "/api/v4/test", nil)
|
||||
handler := TestHandler{func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, rw, err := w.(*responseWriterWrapper).Hijack()
|
||||
require.NoError(t, err, "Hijack should succeed with supporting ResponseWriter")
|
||||
assert.Nil(t, conn, "Expected nil connection from test implementation")
|
||||
assert.Nil(t, rw, "Expected nil buffer from test implementation")
|
||||
}}
|
||||
handler.ServeHTTP(resp, req)
|
||||
}
|
||||
|
||||
func TestForSupportedFlush(t *testing.T) {
|
||||
resp := newWrappedWriter(httptest.NewRecorder())
|
||||
req := httptest.NewRequest("GET", "/api/v4/test", nil)
|
||||
handler := TestHandler{func(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := w.Write([]byte{})
|
||||
require.NoError(t, err, "Failed to write response")
|
||||
require.Equal(t, 0, n, "Expected to write 0 bytes")
|
||||
w.(*responseWriterWrapper).Flush()
|
||||
}}
|
||||
handler.ServeHTTP(resp, req)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode())
|
||||
}
|
||||
318
server/channels/web/saml.go
Normal file
318
server/channels/web/saml.go
Normal file
@@ -0,0 +1,318 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"html"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||
)
|
||||
|
||||
const maxSAMLResponseSize = 2 * 1024 * 1024 // 2MB
|
||||
|
||||
// maxRelayStatePropsSize is a sanity bound on the signed relayProps payload size. It isn't tied
|
||||
// to any storage constraint (RelayState is no longer persisted) - it just keeps a maliciously
|
||||
// large redirect_to from bloating the RelayState round-tripped through the IdP indefinitely.
|
||||
const maxRelayStatePropsSize = 4096
|
||||
|
||||
func (w *Web) InitSaml() {
|
||||
w.MainRouter.Handle("/login/sso/saml", w.APIHandler(loginWithSaml)).Methods(http.MethodGet)
|
||||
w.MainRouter.Handle("/login/sso/saml", w.APIHandlerTrustRequester(completeSaml)).Methods(http.MethodPost)
|
||||
}
|
||||
|
||||
func loginWithSaml(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
samlInterface := c.App.Saml()
|
||||
|
||||
if samlInterface == nil {
|
||||
c.Err = model.NewAppError("loginWithSaml", "api.user.saml.not_available.app_error", nil, "", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
tokenID := r.URL.Query().Get("t")
|
||||
inviteId := r.URL.Query().Get("id")
|
||||
|
||||
action := r.URL.Query().Get("action")
|
||||
isMobile := action == model.OAuthActionMobile
|
||||
redirectURL := html.EscapeString(r.URL.Query().Get("redirect_to"))
|
||||
// Optional SAML challenge parameters for mobile code-exchange
|
||||
state := r.URL.Query().Get("state")
|
||||
codeChallenge := r.URL.Query().Get("code_challenge")
|
||||
codeChallengeMethod := r.URL.Query().Get("code_challenge_method")
|
||||
relayProps := map[string]string{}
|
||||
relayState := ""
|
||||
|
||||
if action != "" {
|
||||
if tokenID != "" {
|
||||
relayProps["invite_token"] = tokenID
|
||||
} else if inviteId != "" {
|
||||
relayProps["invite_id"] = inviteId
|
||||
}
|
||||
relayProps["action"] = action
|
||||
if action == model.OAuthActionEmailToSSO {
|
||||
relayProps["email_token"] = r.URL.Query().Get("email_token")
|
||||
}
|
||||
}
|
||||
|
||||
if redirectURL != "" {
|
||||
if isMobile && !utils.IsValidMobileAuthRedirectURL(c.App.Config(), redirectURL) {
|
||||
invalidSchemeErr := model.NewAppError("loginWithOAuth", "api.invalid_custom_url_scheme", nil, "", http.StatusBadRequest)
|
||||
utils.RenderMobileError(c.App.Config(), w, invalidSchemeErr, redirectURL)
|
||||
return
|
||||
}
|
||||
relayProps["redirect_to"] = redirectURL
|
||||
}
|
||||
|
||||
// Forward SAML challenge values via RelayState so the complete step can prefer code-exchange
|
||||
if isMobile {
|
||||
if state != "" {
|
||||
relayProps["state"] = state
|
||||
}
|
||||
if codeChallenge != "" {
|
||||
relayProps["code_challenge"] = codeChallenge
|
||||
}
|
||||
if codeChallengeMethod != "" {
|
||||
relayProps["code_challenge_method"] = codeChallengeMethod
|
||||
}
|
||||
}
|
||||
|
||||
desktopToken := r.URL.Query().Get("desktop_token")
|
||||
if desktopToken != "" {
|
||||
relayProps["desktop_token"] = desktopToken
|
||||
}
|
||||
|
||||
relayProps[model.UserAuthServiceIsMobile] = strconv.FormatBool(isMobile)
|
||||
|
||||
if len(relayProps) > 0 {
|
||||
if size := len(model.MapToJSON(relayProps)); size > maxRelayStatePropsSize {
|
||||
c.Err = model.NewAppError("loginWithSaml", "api.user.saml.relay_state_too_long.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
relayState = model.SignSamlRelayState(c.App.SamlRelayStateSigningKey(), relayProps)
|
||||
}
|
||||
|
||||
data, err := samlInterface.BuildRequest(c.AppContext, relayState)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
http.Redirect(w, r, data.URL, http.StatusFound)
|
||||
}
|
||||
|
||||
func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
samlInterface := c.App.Saml()
|
||||
|
||||
if samlInterface == nil {
|
||||
c.Err = model.NewAppError("completeSaml", "api.user.saml.not_available.app_error", nil, "", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate that the user is with SAML and all that
|
||||
encodedXML := r.FormValue("SAMLResponse")
|
||||
relayState := r.FormValue("RelayState")
|
||||
|
||||
relayProps := make(map[string]string)
|
||||
if relayState != "" {
|
||||
props, err := model.VerifySamlRelayState(c.App.SamlRelayStateSigningKey(), relayState)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("completeSaml", "api.user.authorize_oauth_user.invalid_state.app_error", nil, "", http.StatusFound).Wrap(err)
|
||||
return
|
||||
}
|
||||
relayProps = props
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventCompleteSaml, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
c.LogAudit("attempt")
|
||||
|
||||
action := relayProps["action"]
|
||||
auditRec.AddMeta("action", action)
|
||||
|
||||
isMobile := action == model.OAuthActionMobile
|
||||
redirectURL := ""
|
||||
hasRedirectURL := false
|
||||
if val, ok := relayProps["redirect_to"]; ok {
|
||||
redirectURL = val
|
||||
hasRedirectURL = val != ""
|
||||
}
|
||||
redirectURL = fullyQualifiedRedirectURL(c.GetSiteURLHeader(), redirectURL, c.App.Config().NativeAppSettings.AppCustomURLSchemes)
|
||||
|
||||
handleError := func(err *model.AppError) {
|
||||
if isMobile && hasRedirectURL {
|
||||
err.Translate(c.AppContext.T)
|
||||
utils.RenderMobileError(c.App.Config(), w, err, redirectURL)
|
||||
} else {
|
||||
c.Err = err
|
||||
c.Err.StatusCode = http.StatusFound
|
||||
}
|
||||
}
|
||||
|
||||
if len(encodedXML) > maxSAMLResponseSize {
|
||||
err := model.NewAppError("completeSaml", "api.user.authorize_oauth_user.saml_response_too_long.app_error", nil, "SAML response is too long", http.StatusBadRequest)
|
||||
handleError(err)
|
||||
return
|
||||
}
|
||||
|
||||
user, assertion, err := samlInterface.DoLogin(c.AppContext, encodedXML, relayProps)
|
||||
if err != nil {
|
||||
c.LogAudit("fail")
|
||||
handleError(err)
|
||||
return
|
||||
}
|
||||
|
||||
err = c.App.CheckUserAllAuthenticationCriteria(c.AppContext, user, "")
|
||||
if err != nil {
|
||||
handleError(err)
|
||||
return
|
||||
}
|
||||
|
||||
switch action {
|
||||
case model.OAuthActionSignup:
|
||||
inviteToken := relayProps["invite_token"]
|
||||
inviteId := relayProps["invite_id"]
|
||||
if err = c.App.AddUserToTeamByInviteIfNeeded(c.AppContext, user, inviteToken, inviteId); err != nil {
|
||||
c.LogErrorByCode(err)
|
||||
break
|
||||
}
|
||||
case model.OAuthActionEmailToSSO:
|
||||
if err = c.App.RevokeAllSessions(c.AppContext, user.Id); err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
auditRec.AddMeta("revoked_user_id", user.Id)
|
||||
auditRec.AddMeta("revoked", "Revoked all sessions for user")
|
||||
|
||||
c.LogAuditWithUserId(user.Id, "Revoked all sessions for user")
|
||||
c.App.Srv().Go(func() {
|
||||
if err := c.App.Srv().EmailService.SendSignInChangeEmail(user.Email, strings.Title(model.UserAuthServiceSaml)+" SSO", user.Locale, c.App.GetSiteURL()); err != nil {
|
||||
c.LogErrorByCode(model.NewAppError("SendSignInChangeEmail", "api.user.send_sign_in_change_email_and_forget.error", nil, "", http.StatusInternalServerError).Wrap(err))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pluginContext := &plugin.Context{
|
||||
RequestId: c.AppContext.RequestId(),
|
||||
SessionId: c.AppContext.Session().Id,
|
||||
IPAddress: c.AppContext.IPAddress(),
|
||||
AcceptLanguage: c.AppContext.AcceptLanguage(),
|
||||
UserAgent: c.AppContext.UserAgent(),
|
||||
}
|
||||
|
||||
var hookErr error
|
||||
c.App.Channels().RunMultiHook(func(hooks plugin.Hooks, manifest *model.Manifest) bool {
|
||||
hookErr = hooks.OnSAMLLogin(pluginContext, user, assertion)
|
||||
return hookErr == nil
|
||||
}, plugin.OnSAMLLoginID)
|
||||
if hookErr != nil {
|
||||
handleError(model.NewAppError("completeSaml", "api.user.authorize_oauth_user.saml_hook_error.app_error", nil, "", http.StatusInternalServerError).Wrap(hookErr))
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.AddMeta("obtained_user_id", user.Id)
|
||||
auditRec.AddMeta("obtained_user_email", user.Email)
|
||||
c.LogAuditWithUserId(user.Id, "obtained user")
|
||||
|
||||
desktopToken := relayProps["desktop_token"]
|
||||
|
||||
// If it's a desktop login we generate a token and redirect to another endpoint to handle session creation
|
||||
if desktopToken != "" {
|
||||
serverToken, serverTokenErr := c.App.GenerateAndSaveDesktopToken(time.Now().Unix(), user)
|
||||
if serverTokenErr != nil {
|
||||
handleError(serverTokenErr)
|
||||
return
|
||||
}
|
||||
|
||||
queryString := map[string]string{
|
||||
"client_token": desktopToken,
|
||||
"server_token": *serverToken,
|
||||
}
|
||||
if val, ok := relayProps["redirect_to"]; ok {
|
||||
queryString["redirect_to"] = val
|
||||
}
|
||||
if strings.HasPrefix(desktopToken, "dev-") {
|
||||
queryString["isDesktopDev"] = "true"
|
||||
}
|
||||
|
||||
redirectURL = utils.AppendQueryParamsToURL(c.GetSiteURLHeader()+"/login/desktop", queryString)
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Decide between legacy token-in-URL vs SAML code-exchange for mobile
|
||||
samlState := relayProps["state"]
|
||||
samlChallenge := relayProps["code_challenge"]
|
||||
samlMethod := relayProps["code_challenge_method"]
|
||||
|
||||
if isMobile && hasRedirectURL && samlChallenge != "" && c.App.Config().FeatureFlags.MobileSSOCodeExchange {
|
||||
// Issue one-time login_code bound to user and SAML challenge values; do not create a session here
|
||||
extra := model.MapToJSON(map[string]string{
|
||||
"user_id": user.Id,
|
||||
"state": samlState,
|
||||
"code_challenge": samlChallenge,
|
||||
"code_challenge_method": samlMethod,
|
||||
})
|
||||
|
||||
var code *model.Token
|
||||
code, err = c.App.CreateSamlRelayToken(model.TokenTypeSSOCodeExchange, extra)
|
||||
if err != nil {
|
||||
handleError(model.NewAppError("completeSaml", "app.recover.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err))
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{
|
||||
"login_code": code.Token,
|
||||
"srv": c.App.GetSiteURL(), // Server URL for mobile client verification
|
||||
})
|
||||
utils.RenderMobileAuthComplete(w, redirectURL)
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy: create a session and attach tokens (web/mobile without SAML code exchange)
|
||||
session, err := c.App.DoLogin(c.AppContext, w, r, user, model.LoginOptions{
|
||||
IsMobile: isMobile,
|
||||
IsSaml: true,
|
||||
})
|
||||
if err != nil {
|
||||
handleError(err)
|
||||
return
|
||||
}
|
||||
c.AppContext = c.AppContext.WithSession(session)
|
||||
|
||||
auditRec.Success()
|
||||
c.LogAuditWithUserId(user.Id, "success")
|
||||
c.App.AttachSessionCookies(c.AppContext, w, r)
|
||||
|
||||
if hasRedirectURL {
|
||||
if isMobile {
|
||||
// Mobile clients with redirect url support
|
||||
// Always add tokens for mobile in legacy path (we only reach here if code-exchange was skipped)
|
||||
redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{
|
||||
model.SessionCookieToken: c.AppContext.Session().Token,
|
||||
model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(),
|
||||
"srv": c.App.GetSiteURL(), // Server URL for mobile client verification (config-based, not request Host)
|
||||
})
|
||||
utils.RenderMobileAuthComplete(w, redirectURL)
|
||||
} else {
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch action {
|
||||
// Mobile clients with web view implementation
|
||||
case model.OAuthActionMobile:
|
||||
ReturnStatusOK(w)
|
||||
case model.OAuthActionEmailToSSO:
|
||||
http.Redirect(w, r, c.GetSiteURLHeader()+"/login?extra=signin_change", http.StatusFound)
|
||||
default:
|
||||
http.Redirect(w, r, c.GetSiteURLHeader(), http.StatusFound)
|
||||
}
|
||||
}
|
||||
207
server/channels/web/saml_test.go
Normal file
207
server/channels/web/saml_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
saml2 "github.com/mattermost/gosaml2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app"
|
||||
"github.com/mattermost/mattermost/server/v8/einterfaces"
|
||||
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
|
||||
)
|
||||
|
||||
// TestSamlCallbackIncludesSrvParameter verifies that mobile SAML callbacks
|
||||
// include the 'srv' parameter for origin verification
|
||||
func TestSamlCallbackIncludesSrvParameter(t *testing.T) {
|
||||
// The 'srv' parameter is added to mobile callbacks to allow the client
|
||||
// to verify the server origin
|
||||
|
||||
t.Run("srv parameter should be included in redirect URL construction", func(t *testing.T) {
|
||||
// Verify the pattern: when we construct a redirect URL for mobile,
|
||||
// it should include "srv" parameter with the server's site URL
|
||||
|
||||
siteURL := "https://mattermost.example.com"
|
||||
sessionToken := "test-session-token"
|
||||
csrfToken := "test-csrf-token"
|
||||
|
||||
// Simulate what the code does when constructing the callback
|
||||
params := map[string]string{
|
||||
model.SessionCookieToken: sessionToken,
|
||||
model.SessionCookieCsrf: csrfToken,
|
||||
"srv": siteURL,
|
||||
}
|
||||
|
||||
// Verify all expected parameters are present
|
||||
assert.Equal(t, sessionToken, params[model.SessionCookieToken])
|
||||
assert.Equal(t, csrfToken, params[model.SessionCookieCsrf])
|
||||
assert.Equal(t, siteURL, params["srv"])
|
||||
})
|
||||
|
||||
t.Run("srv parameter detects server mismatch", func(t *testing.T) {
|
||||
// Scenario: The srv parameter from callback doesn't match expected server
|
||||
// Mobile should detect the mismatch
|
||||
|
||||
expectedServer := "https://server-a.example.com"
|
||||
actualSrvFromCallback := "https://server-b.example.com"
|
||||
|
||||
// This is the check that should happen in mobile
|
||||
isMismatch := expectedServer != actualSrvFromCallback
|
||||
assert.True(t, isMismatch, "Should detect server mismatch")
|
||||
})
|
||||
|
||||
t.Run("srv parameter allows legitimate login", func(t *testing.T) {
|
||||
// Scenario: Normal login to legitimate server
|
||||
// Server adds srv=server.com to callback
|
||||
// Mobile verifies: expected == srv
|
||||
|
||||
expectedServer := "https://server.example.com"
|
||||
actualSrvFromCallback := "https://server.example.com"
|
||||
|
||||
// This is the check that should happen in mobile
|
||||
isLegitimate := expectedServer == actualSrvFromCallback
|
||||
assert.True(t, isLegitimate, "Should allow legitimate login")
|
||||
})
|
||||
}
|
||||
|
||||
// registerFakeSamlInterface installs a mocked SamlInterface before the server is created,
|
||||
// mirroring the pattern in channels/app/enterprise_test.go. Must be called before Setup(t).
|
||||
func registerFakeSamlInterface(t *testing.T) *mocks.SamlInterface {
|
||||
t.Helper()
|
||||
|
||||
fakeSaml := &mocks.SamlInterface{}
|
||||
fakeSaml.On("ConfigureSP", mock.Anything).Return(nil)
|
||||
|
||||
app.RegisterSamlInterface(func(a *app.App) einterfaces.SamlInterface {
|
||||
return fakeSaml
|
||||
})
|
||||
t.Cleanup(func() { app.RegisterSamlInterface(nil) })
|
||||
|
||||
return fakeSaml
|
||||
}
|
||||
|
||||
func postCompleteSaml(t *testing.T, th *TestHelper, samlResponse, relayState string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("SAMLResponse", samlResponse)
|
||||
form.Set("RelayState", relayState)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/login/sso/saml", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
res := httptest.NewRecorder()
|
||||
th.Web.MainRouter.ServeHTTP(res, req)
|
||||
return res
|
||||
}
|
||||
|
||||
// TestCompleteSamlRelayStateRejectsForgedRelayState verifies that completeSaml rejects the
|
||||
// pre-fix RelayState format (plain base64(JSON), no signature - the shape of the MM-69889
|
||||
// forged team_id/invite_id attack) as well as arbitrary tampered/malformed strings, since
|
||||
// RelayState must now carry a valid HMAC signature produced by the server.
|
||||
func TestCompleteSamlRelayStateRejectsForgedRelayState(t *testing.T) {
|
||||
fakeSaml := registerFakeSamlInterface(t)
|
||||
th := Setup(t)
|
||||
|
||||
t.Run("legacy base64-encoded relayProps blob is rejected", func(t *testing.T) {
|
||||
forgedRelayProps := map[string]string{
|
||||
"action": model.OAuthActionSignup,
|
||||
"team_id": "forged-team-id",
|
||||
}
|
||||
forgedRelayState := base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(forgedRelayProps)))
|
||||
|
||||
res := postCompleteSaml(t, th, "dummy-encoded-xml", forgedRelayState)
|
||||
|
||||
assert.Equal(t, http.StatusFound, res.Code)
|
||||
fakeSaml.AssertNotCalled(t, "DoLogin", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("arbitrary tampered string is rejected", func(t *testing.T) {
|
||||
res := postCompleteSaml(t, th, "dummy-encoded-xml", "not-a-real-relay-state")
|
||||
|
||||
assert.Equal(t, http.StatusFound, res.Code)
|
||||
fakeSaml.AssertNotCalled(t, "DoLogin", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("validly-formatted but tampered payload is rejected", func(t *testing.T) {
|
||||
signed := model.SignSamlRelayState(th.App.SamlRelayStateSigningKey(), map[string]string{
|
||||
"action": model.OAuthActionSignup,
|
||||
"team_id": "legit-team-id",
|
||||
})
|
||||
payloadPart, sigPart, ok := strings.Cut(signed, ".")
|
||||
require.True(t, ok)
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(payloadPart)
|
||||
require.NoError(t, err)
|
||||
tampered := strings.Replace(string(payload), "legit-team-id", "forged-team-id", 1)
|
||||
require.NotEqual(t, string(payload), tampered)
|
||||
|
||||
forgedRelayState := base64.RawURLEncoding.EncodeToString([]byte(tampered)) + "." + sigPart
|
||||
|
||||
res := postCompleteSaml(t, th, "dummy-encoded-xml", forgedRelayState)
|
||||
|
||||
assert.Equal(t, http.StatusFound, res.Code)
|
||||
fakeSaml.AssertNotCalled(t, "DoLogin", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
}
|
||||
|
||||
// TestCompleteSamlRelayStateSignedRoundTrip verifies the fixed flow end-to-end: loginWithSaml
|
||||
// signs relayProps into an opaque RelayState, and completeSaml verifies the signature and
|
||||
// recovers the original relayProps.
|
||||
func TestCompleteSamlRelayStateSignedRoundTrip(t *testing.T) {
|
||||
fakeSaml := registerFakeSamlInterface(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
var capturedRelayState string
|
||||
fakeSaml.On("BuildRequest", mock.Anything, mock.AnythingOfType("string")).
|
||||
Run(func(args mock.Arguments) { capturedRelayState = args.String(1) }).
|
||||
Return(&model.SamlAuthRequest{URL: "https://idp.example.com/sso"}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/login/sso/saml?action=login&id=inviteABC", nil)
|
||||
res := httptest.NewRecorder()
|
||||
th.Web.MainRouter.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusFound, res.Code)
|
||||
|
||||
// RelayState on the wire must be payload.signature - exactly one separator, both halves
|
||||
// valid base64url.
|
||||
require.NotEmpty(t, capturedRelayState)
|
||||
payloadPart, sigPart, ok := strings.Cut(capturedRelayState, ".")
|
||||
require.True(t, ok)
|
||||
assert.False(t, strings.Contains(sigPart, "."))
|
||||
_, err := base64.RawURLEncoding.DecodeString(payloadPart)
|
||||
assert.NoError(t, err)
|
||||
_, err = base64.RawURLEncoding.DecodeString(sigPart)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var capturedRelayProps map[string]string
|
||||
fakeSaml.On("DoLogin", mock.Anything, mock.Anything, mock.AnythingOfType("map[string]string")).
|
||||
Run(func(args mock.Arguments) { capturedRelayProps = args.Get(2).(map[string]string) }).
|
||||
Return(th.BasicUser, (*saml2.AssertionInfo)(nil), nil)
|
||||
|
||||
res = postCompleteSaml(t, th, "dummy-encoded-xml", capturedRelayState)
|
||||
assert.Equal(t, http.StatusFound, res.Code)
|
||||
assert.Equal(t, model.OAuthActionLogin, capturedRelayProps["action"])
|
||||
assert.Equal(t, "inviteABC", capturedRelayProps["invite_id"])
|
||||
// The internal expiry field is bookkeeping only and must not leak into relayProps.
|
||||
_, hasExp := capturedRelayProps["exp"]
|
||||
assert.False(t, hasExp)
|
||||
|
||||
// A signed RelayState is not single-use: replaying it within its expiry window succeeds
|
||||
// again. Restricting the replay window to a few minutes (rather than eliminating replay
|
||||
// entirely) is an intentional, low-cost defense-in-depth measure requested by the security
|
||||
// team, on top of the fact that no RelayState field is trusted without independent
|
||||
// revalidation at the point it's consumed (invite validity, redirect_to scheme/host, etc.).
|
||||
res = postCompleteSaml(t, th, "dummy-encoded-xml", capturedRelayState)
|
||||
assert.Equal(t, http.StatusFound, res.Code)
|
||||
fakeSaml.AssertNumberOfCalls(t, "DoLogin", 2)
|
||||
}
|
||||
221
server/channels/web/static.go
Normal file
221
server/channels/web/static.go
Normal file
@@ -0,0 +1,221 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/klauspost/compress/gzhttp"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
|
||||
)
|
||||
|
||||
var robotsTxt = []byte("User-agent: *\nDisallow: /\n")
|
||||
|
||||
func (w *Web) InitStatic() {
|
||||
if *w.srv.Config().ServiceSettings.WebserverMode != "disabled" {
|
||||
if err := utils.UpdateAssetsSubpathFromConfig(w.srv.Config()); err != nil {
|
||||
mlog.Error("Failed to update assets subpath from config", mlog.Err(err))
|
||||
}
|
||||
|
||||
staticDir, _ := fileutils.FindDir(model.ClientDir)
|
||||
mlog.Debug("Using client directory", mlog.String("client_dir", staticDir))
|
||||
|
||||
subpath, _ := utils.GetSubpathFromConfig(w.srv.Config())
|
||||
|
||||
staticHandler := staticFilesHandler(http.StripPrefix(path.Join(subpath, "static"), http.FileServer(http.Dir(staticDir))))
|
||||
pluginHandler := staticFilesHandler(http.StripPrefix(path.Join(subpath, "static", "plugins"), http.FileServer(http.Dir(*w.srv.Config().PluginSettings.ClientDirectory))))
|
||||
|
||||
if *w.srv.Config().ServiceSettings.WebserverMode == "gzip" {
|
||||
staticHandler = gzhttp.GzipHandler(staticHandler)
|
||||
pluginHandler = gzhttp.GzipHandler(pluginHandler)
|
||||
}
|
||||
|
||||
w.MainRouter.PathPrefix("/static/plugins/").Handler(pluginHandler)
|
||||
w.MainRouter.PathPrefix("/static/").Handler(staticHandler)
|
||||
w.MainRouter.Handle("/robots.txt", http.HandlerFunc(robotsHandler))
|
||||
w.MainRouter.Handle("/unsupported_browser.js", http.HandlerFunc(unsupportedBrowserScriptHandler))
|
||||
w.MainRouter.Handle("/{anything:.*}", w.NewStaticHandler(root)).Methods(http.MethodGet, http.MethodHead)
|
||||
|
||||
// When a subpath is defined, it's necessary to handle redirects without a
|
||||
// trailing slash. We don't want to use StrictSlash on the w.MainRouter and affect
|
||||
// all routes, just /subpath -> /subpath/.
|
||||
w.MainRouter.HandleFunc("", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.Path += "/"
|
||||
http.Redirect(w, r, r.URL.String(), http.StatusFound)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func root(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !CheckClientCompatibility(r.UserAgent()) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
subpath, _ := utils.GetSubpathFromConfig(c.App.Srv().Config())
|
||||
data := renderUnsupportedBrowser(c.AppContext, r, subpath)
|
||||
|
||||
err := c.App.Srv().TemplatesContainer().Render(w, "unsupported_browser", data)
|
||||
if err != nil {
|
||||
c.Logger.Error("Failed to render template", mlog.Err(err))
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !CheckDesktopAppCompatibility(r.UserAgent(), c.App.Srv().Config().ServiceSettings.MinimumDesktopAppVersion) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
currentVersion, ok := app.GetDesktopAppVersion(r.UserAgent())
|
||||
if !ok {
|
||||
currentVersion = "unknown"
|
||||
}
|
||||
cfg := c.App.Srv().Config()
|
||||
subpath, _ := utils.GetSubpathFromConfig(cfg)
|
||||
|
||||
data := renderUnsupportedDesktopApp(c.AppContext, cfg, currentVersion, subpath)
|
||||
err := c.App.Srv().TemplatesContainer().Render(w, "unsupported_desktop_app", data)
|
||||
if err != nil {
|
||||
c.Logger.Error("Failed to render template", mlog.Err(err))
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if IsAPICall(c.App, r) {
|
||||
Handle404(c.App, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-cache, max-age=31556926, public")
|
||||
|
||||
staticDir, _ := fileutils.FindDir(model.ClientDir)
|
||||
contents, err := os.ReadFile(filepath.Join(staticDir, "root.html"))
|
||||
if err != nil {
|
||||
c.Logger.Warn("Failed to read content from file",
|
||||
mlog.String("file_path", filepath.Join(staticDir, "root.html")),
|
||||
mlog.Err(err))
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
titleTemplate := "<title>%s</title>"
|
||||
originalHTML := fmt.Sprintf(titleTemplate, html.EscapeString(model.TeamSettingsDefaultSiteName))
|
||||
modifiedHTML := getOpenGraphMetaTags(c)
|
||||
if originalHTML != modifiedHTML {
|
||||
contents = bytes.ReplaceAll(contents, []byte(originalHTML), []byte(modifiedHTML))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if _, err = w.Write(contents); err != nil {
|
||||
c.Logger.Warn("Failed to write content to HTTP reply", mlog.Err(err))
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func staticFilesHandler(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
//wrap our ResponseWriter with our no-cache 404-handler
|
||||
w = ¬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
|
||||
}
|
||||
if _, err := w.Write(robotsTxt); err != nil {
|
||||
mlog.Warn("Failed to write robots.txt", mlog.Err(err))
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func unsupportedBrowserScriptHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
templatesDir, _ := templates.GetTemplateDirectory()
|
||||
http.ServeFile(w, r, filepath.Join(templatesDir, "unsupported_browser.js"))
|
||||
}
|
||||
|
||||
func ensureTrailingSlash(s string) string {
|
||||
if s == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasSuffix(s, "/") {
|
||||
return s + "/"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func getOpenGraphMetaTags(c *Context) string {
|
||||
siteName := model.TeamSettingsDefaultSiteName
|
||||
customSiteName := c.App.Srv().Config().TeamSettings.SiteName
|
||||
if customSiteName != nil && *customSiteName != "" {
|
||||
siteName = *customSiteName
|
||||
}
|
||||
|
||||
siteDescription := model.TeamSettingsDefaultCustomDescriptionText
|
||||
customSiteDescription := c.App.Srv().Config().TeamSettings.CustomDescriptionText
|
||||
if customSiteDescription != nil && *customSiteDescription != "" {
|
||||
siteDescription = *customSiteDescription
|
||||
}
|
||||
|
||||
titleTemplate := "<title>%s</title>"
|
||||
titleHTML := fmt.Sprintf(titleTemplate, html.EscapeString(siteName))
|
||||
descriptionHTML := ""
|
||||
if siteDescription != "" {
|
||||
descriptionTemplate := "<meta property=\"og:description\" content=\"%s\" />"
|
||||
descriptionHTML = fmt.Sprintf(descriptionTemplate, html.EscapeString(siteDescription))
|
||||
}
|
||||
|
||||
return titleHTML + descriptionHTML
|
||||
}
|
||||
159
server/channels/web/unsupported_browser.go
Normal file
159
server/channels/web/unsupported_browser.go
Normal file
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/avct/uasurfer"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
|
||||
)
|
||||
|
||||
// MattermostApp describes downloads for the Mattermost App
|
||||
type MattermostApp struct {
|
||||
LogoSrc string
|
||||
Title string
|
||||
SupportedVersionString string
|
||||
Label string
|
||||
Link string
|
||||
InstallGuide string
|
||||
InstallGuideLink string
|
||||
}
|
||||
|
||||
// Browser describes a browser with a download link
|
||||
type Browser struct {
|
||||
LogoSrc string
|
||||
Title string
|
||||
SupportedVersionString string
|
||||
Src string
|
||||
GetLatestString string
|
||||
}
|
||||
|
||||
// SystemBrowser describes a browser but includes 2 links: one to open the local browser, and one to make it default
|
||||
type SystemBrowser struct {
|
||||
LogoSrc string
|
||||
Title string
|
||||
SupportedVersionString string
|
||||
LabelOpen string
|
||||
LinkOpen string
|
||||
LinkMakeDefault string
|
||||
OrString string
|
||||
MakeDefaultString string
|
||||
}
|
||||
|
||||
func renderUnsupportedBrowser(rctx request.CTX, r *http.Request, subpath string) templates.Data {
|
||||
data := templates.Data{
|
||||
Props: map[string]any{
|
||||
"Subpath": ensureTrailingSlash(subpath),
|
||||
"DownloadAppOrUpgradeBrowserString": rctx.T("web.error.unsupported_browser.download_app_or_upgrade_browser"),
|
||||
"LearnMoreString": rctx.T("web.error.unsupported_browser.learn_more"),
|
||||
},
|
||||
}
|
||||
|
||||
// User Agent info
|
||||
ua := uasurfer.Parse(r.UserAgent())
|
||||
isWindows := ua.OS.Platform.String() == "PlatformWindows"
|
||||
isWindows10 := isWindows && ua.OS.Version.Major == 10
|
||||
isMacOSX := ua.OS.Name.String() == "OSMacOSX" && ua.OS.Version.Major == 10
|
||||
isSafari := ua.Browser.Name.String() == "BrowserSafari"
|
||||
|
||||
// Basic heading translations
|
||||
if isSafari {
|
||||
data.Props["NoLongerSupportString"] = rctx.T("web.error.unsupported_browser.no_longer_support_version")
|
||||
} else {
|
||||
data.Props["NoLongerSupportString"] = rctx.T("web.error.unsupported_browser.no_longer_support")
|
||||
}
|
||||
|
||||
// Mattermost app version
|
||||
if isWindows {
|
||||
data.Props["App"] = renderMattermostAppWindows(rctx)
|
||||
} else if isMacOSX {
|
||||
data.Props["App"] = renderMattermostAppMac(rctx)
|
||||
}
|
||||
|
||||
// Browsers to download
|
||||
// Show a link to Safari if you're using safari and it's outdated
|
||||
// Can't show on Mac all the time because there's no way to open it via URI
|
||||
browsers := []Browser{renderBrowserChrome(rctx), renderBrowserFirefox(rctx)}
|
||||
if isSafari {
|
||||
browsers = append(browsers, renderBrowserSafari(rctx))
|
||||
}
|
||||
data.Props["Browsers"] = browsers
|
||||
|
||||
// If on Windows 10, show link to Edge
|
||||
if isWindows10 {
|
||||
data.Props["SystemBrowser"] = renderSystemBrowserEdge(rctx, r)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func renderMattermostAppMac(rctx request.CTX) MattermostApp {
|
||||
return MattermostApp{
|
||||
"mac.png",
|
||||
rctx.T("web.error.unsupported_browser.download_the_app"),
|
||||
rctx.T("web.error.unsupported_browser.min_os_version.mac"),
|
||||
rctx.T("web.error.unsupported_browser.download"),
|
||||
"https://mattermost.com/pl/download-apps",
|
||||
rctx.T("web.error.unsupported_browser.install_guide.mac"),
|
||||
"https://docs.mattermost.com/install/desktop.html#mac-os-x-10-9",
|
||||
}
|
||||
}
|
||||
|
||||
func renderMattermostAppWindows(rctx request.CTX) MattermostApp {
|
||||
return MattermostApp{
|
||||
"windows.svg",
|
||||
rctx.T("web.error.unsupported_browser.download_the_app"),
|
||||
rctx.T("web.error.unsupported_browser.min_os_version.windows"),
|
||||
rctx.T("web.error.unsupported_browser.download"),
|
||||
"https://mattermost.com/pl/download-apps",
|
||||
rctx.T("web.error.unsupported_browser.install_guide.windows"),
|
||||
"https://docs.mattermost.com/install/desktop.html#windows-10-windows-8-1-windows-7",
|
||||
}
|
||||
}
|
||||
|
||||
func renderBrowserChrome(rctx request.CTX) Browser {
|
||||
return Browser{
|
||||
"chrome.svg",
|
||||
rctx.T("web.error.unsupported_browser.browser_title.chrome"),
|
||||
rctx.T("web.error.unsupported_browser.min_browser_version.chrome"),
|
||||
"http://www.google.com/chrome",
|
||||
rctx.T("web.error.unsupported_browser.browser_get_latest.chrome"),
|
||||
}
|
||||
}
|
||||
|
||||
func renderBrowserFirefox(rctx request.CTX) Browser {
|
||||
return Browser{
|
||||
"firefox.svg",
|
||||
rctx.T("web.error.unsupported_browser.browser_title.firefox"),
|
||||
rctx.T("web.error.unsupported_browser.min_browser_version.firefox"),
|
||||
"https://www.mozilla.org/firefox/new/",
|
||||
rctx.T("web.error.unsupported_browser.browser_get_latest.firefox"),
|
||||
}
|
||||
}
|
||||
|
||||
func renderBrowserSafari(rctx request.CTX) Browser {
|
||||
return Browser{
|
||||
"safari.svg",
|
||||
rctx.T("web.error.unsupported_browser.browser_title.safari"),
|
||||
rctx.T("web.error.unsupported_browser.min_browser_version.safari"),
|
||||
"macappstore://showUpdatesPage",
|
||||
rctx.T("web.error.unsupported_browser.browser_get_latest.safari"),
|
||||
}
|
||||
}
|
||||
|
||||
func renderSystemBrowserEdge(rctx request.CTX, r *http.Request) SystemBrowser {
|
||||
return SystemBrowser{
|
||||
"edge.svg",
|
||||
rctx.T("web.error.unsupported_browser.browser_title.edge"),
|
||||
rctx.T("web.error.unsupported_browser.min_browser_version.edge"),
|
||||
rctx.T("web.error.unsupported_browser.open_system_browser.edge"),
|
||||
"microsoft-edge:http://" + r.Host + r.RequestURI, //TODO: Can we get HTTP or HTTPS? If someone's server doesn't have a redirect this won't work
|
||||
"ms-settings:defaultapps",
|
||||
rctx.T("web.error.unsupported_browser.system_browser_or"),
|
||||
rctx.T("web.error.unsupported_browser.system_browser_make_default"),
|
||||
}
|
||||
}
|
||||
39
server/channels/web/unsupported_desktop_app.go
Normal file
39
server/channels/web/unsupported_desktop_app.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
|
||||
)
|
||||
|
||||
func renderUnsupportedDesktopApp(rctx request.CTX, cfg *model.Config, currentVersion, subpath string) templates.Data {
|
||||
return templates.Data{
|
||||
Props: map[string]any{
|
||||
"Subpath": ensureTrailingSlash(subpath),
|
||||
"Title": rctx.T("web.error.unsupported_desktop_app.title"),
|
||||
"MessageString": rctx.T("web.error.unsupported_desktop_app.message", map[string]any{
|
||||
"SiteName": *cfg.TeamSettings.SiteName,
|
||||
"CurrentVersion": currentVersion,
|
||||
"MinimumVersion": *cfg.ServiceSettings.MinimumDesktopAppVersion,
|
||||
}),
|
||||
"DownloadButtonLabel": rctx.T("web.error.unsupported_desktop_app.download_button"),
|
||||
"AssistanceString": rctx.T("web.error.unsupported_desktop_app.assistance"),
|
||||
"FooterAboutLabel": rctx.T("web.error.unsupported_desktop_app.footer_about"),
|
||||
"FooterPrivacyLabel": rctx.T("web.error.unsupported_desktop_app.footer_privacy"),
|
||||
"FooterTermsLabel": rctx.T("web.error.unsupported_desktop_app.footer_terms"),
|
||||
"FooterHelpLabel": rctx.T("web.error.unsupported_desktop_app.footer_help"),
|
||||
"DownloadLink": *cfg.NativeAppSettings.AppDownloadLink,
|
||||
"CopyrightYear": time.Now().Year(),
|
||||
"SiteName": *cfg.TeamSettings.SiteName,
|
||||
"AboutLink": *cfg.SupportSettings.AboutLink,
|
||||
"PrivacyPolicyLink": *cfg.SupportSettings.PrivacyPolicyLink,
|
||||
"TermsOfServiceLink": *cfg.SupportSettings.TermsOfServiceLink,
|
||||
"HelpLink": *cfg.SupportSettings.HelpLink,
|
||||
},
|
||||
}
|
||||
}
|
||||
133
server/channels/web/web.go
Normal file
133
server/channels/web/web.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/avct/uasurfer"
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||
)
|
||||
|
||||
type Web struct {
|
||||
srv *app.Server
|
||||
MainRouter *mux.Router
|
||||
}
|
||||
|
||||
func New(srv *app.Server) *Web {
|
||||
mlog.Debug("Initializing web routes")
|
||||
|
||||
web := &Web{
|
||||
srv: srv,
|
||||
MainRouter: srv.Router,
|
||||
}
|
||||
|
||||
web.InitOAuth()
|
||||
web.InitWebhooks()
|
||||
web.InitSaml()
|
||||
web.InitMagicLink()
|
||||
web.InitStatic()
|
||||
|
||||
return web
|
||||
}
|
||||
|
||||
// Due to the complexities of UA detection and the ramifications of a misdetection
|
||||
// only older Safari and IE browsers throw incompatibility errors.
|
||||
// Map should be of minimum required browser version.
|
||||
// -1 means that the browser is not supported in any version.
|
||||
var browserMinimumSupported = map[string]int{
|
||||
"BrowserIE": 12,
|
||||
"BrowserSafari": 12,
|
||||
}
|
||||
|
||||
func CheckClientCompatibility(agentString string) bool {
|
||||
ua := uasurfer.Parse(agentString)
|
||||
|
||||
if version, exist := browserMinimumSupported[ua.Browser.Name.String()]; exist && (ua.Browser.Version.Major < version || version < 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func CheckDesktopAppCompatibility(agentString string, minVersion *string) bool {
|
||||
if minVersion == nil || *minVersion == "" {
|
||||
return true
|
||||
}
|
||||
clientVersionStr, ok := app.GetDesktopAppVersion(agentString)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
clientVersion, err := semver.NewVersion(clientVersionStr)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
required, err := semver.StrictNewVersion(*minVersion)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return clientVersion.GreaterThanEqual(required)
|
||||
}
|
||||
|
||||
func Handle404(a *app.App, w http.ResponseWriter, r *http.Request) {
|
||||
err := model.NewAppError("Handle404", "api.context.404.app_error", nil, "", http.StatusNotFound)
|
||||
ipAddress := utils.GetIPAddress(r, a.Config().ServiceSettings.TrustedProxyIPHeader)
|
||||
mlog.Debug("not found handler triggered", mlog.String("path", r.URL.Path), mlog.Int("code", 404), mlog.String("ip", ipAddress))
|
||||
if IsAPICall(a, r) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(err.StatusCode)
|
||||
err.DetailedError = "There doesn't appear to be an api call for the url='" + r.URL.Path + "'. Typo? are you missing a team_id or user_id as part of the url?"
|
||||
if _, writeErr := w.Write([]byte(err.ToJSON())); writeErr != nil {
|
||||
mlog.Warn("Error writing 404 response", mlog.Err(writeErr))
|
||||
}
|
||||
} else if *a.Config().ServiceSettings.WebserverMode == "disabled" {
|
||||
http.NotFound(w, r)
|
||||
} else {
|
||||
utils.RenderWebAppError(a.Config(), w, r, err, a.AsymmetricSigningKey())
|
||||
}
|
||||
}
|
||||
|
||||
func IsAPICall(a *app.App, r *http.Request) bool {
|
||||
subpath, _ := utils.GetSubpathFromConfig(a.Config())
|
||||
|
||||
return strings.HasPrefix(r.URL.Path, path.Join(subpath, "api")+"/")
|
||||
}
|
||||
|
||||
func IsWebhookCall(a *app.App, r *http.Request) bool {
|
||||
subpath, _ := utils.GetSubpathFromConfig(a.Config())
|
||||
|
||||
return strings.HasPrefix(r.URL.Path, path.Join(subpath, "hooks")+"/")
|
||||
}
|
||||
|
||||
func IsOAuthAPICall(a *app.App, r *http.Request) bool {
|
||||
subpath, _ := utils.GetSubpathFromConfig(a.Config())
|
||||
|
||||
if r.Method == "POST" && r.URL.Path == path.Join(subpath, "oauth", "authorize") {
|
||||
return true
|
||||
}
|
||||
|
||||
if r.URL.Path == path.Join(subpath, "oauth", "apps", "authorized") ||
|
||||
r.URL.Path == path.Join(subpath, "oauth", "deauthorize") ||
|
||||
r.URL.Path == path.Join(subpath, "oauth", "access_token") ||
|
||||
r.URL.Path == path.Join(subpath, "oauth", "intune") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ReturnStatusOK(w http.ResponseWriter) {
|
||||
m := make(map[string]string)
|
||||
m[model.STATUS] = model.StatusOk
|
||||
if _, err := w.Write([]byte(model.MapToJSON(m))); err != nil {
|
||||
mlog.Warn("Error writing status OK response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
492
server/channels/web/web_test.go
Normal file
492
server/channels/web/web_test.go
Normal file
@@ -0,0 +1,492 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/plugin/utils"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
|
||||
"github.com/mattermost/mattermost/server/v8/config"
|
||||
)
|
||||
|
||||
var apiClient *model.Client4
|
||||
var URL string
|
||||
|
||||
type TestHelper struct {
|
||||
App *app.App
|
||||
Context request.CTX
|
||||
Server *app.Server
|
||||
Web *Web
|
||||
|
||||
BasicUser *model.User
|
||||
BasicChannel *model.Channel
|
||||
BasicTeam *model.Team
|
||||
|
||||
SystemAdminUser *model.User
|
||||
|
||||
tempWorkspace string
|
||||
|
||||
IncludeCacheLayer bool
|
||||
|
||||
TestLogger *mlog.Logger
|
||||
}
|
||||
|
||||
func SetupWithStoreMock(tb testing.TB) *TestHelper {
|
||||
if testing.Short() {
|
||||
tb.SkipNow()
|
||||
}
|
||||
|
||||
th := setupTestHelper(tb, false, nil)
|
||||
emptyMockStore := mocks.Store{}
|
||||
emptyMockStore.On("Close").Return(nil)
|
||||
th.App.Srv().SetStore(&emptyMockStore)
|
||||
return th
|
||||
}
|
||||
|
||||
func Setup(tb testing.TB) *TestHelper {
|
||||
if testing.Short() {
|
||||
tb.SkipNow()
|
||||
}
|
||||
store := mainHelper.GetStore()
|
||||
store.DropAllTables()
|
||||
return setupTestHelper(tb, true, nil)
|
||||
}
|
||||
|
||||
func setupTestHelper(tb testing.TB, includeCacheLayer bool, options []app.Option) *TestHelper {
|
||||
memoryStore := config.NewTestMemoryStore()
|
||||
newConfig := memoryStore.Get().Clone()
|
||||
newConfig.SqlSettings = *mainHelper.GetSQLSettings()
|
||||
*newConfig.AnnouncementSettings.AdminNoticesEnabled = false
|
||||
*newConfig.AnnouncementSettings.UserNoticesEnabled = false
|
||||
*newConfig.PluginSettings.AutomaticPrepackagedPlugins = false
|
||||
*newConfig.LogSettings.EnableSentry = false // disable error reporting during tests
|
||||
*newConfig.LogSettings.ConsoleJson = false
|
||||
|
||||
// Check for environment variable override for console log level (useful for debugging tests)
|
||||
consoleLevel := os.Getenv("MM_LOGSETTINGS_CONSOLELEVEL")
|
||||
if consoleLevel == "" {
|
||||
consoleLevel = mlog.LvlStdLog.Name
|
||||
}
|
||||
*newConfig.LogSettings.ConsoleLevel = consoleLevel
|
||||
|
||||
_, _, err := memoryStore.Set(newConfig)
|
||||
require.NoError(tb, err)
|
||||
options = append(options, app.ConfigStore(memoryStore))
|
||||
if includeCacheLayer {
|
||||
// Adds the cache layer to the test store
|
||||
options = append(options, app.StoreOverrideWithCache(mainHelper.Store))
|
||||
} else {
|
||||
options = append(options, app.StoreOverride(mainHelper.Store))
|
||||
}
|
||||
|
||||
testLogger, err := mlog.NewLogger()
|
||||
require.NoError(tb, err)
|
||||
logCfg, err := config.MloggerConfigFromLoggerConfig(&newConfig.LogSettings, nil, config.GetLogFileLocation)
|
||||
require.NoError(tb, err)
|
||||
err = testLogger.ConfigureTargets(logCfg, nil)
|
||||
require.NoError(tb, err, "failed to configure test logger")
|
||||
// lock logger config so server init cannot override it during testing.
|
||||
testLogger.LockConfiguration()
|
||||
options = append(options, app.SetLogger(testLogger))
|
||||
|
||||
s, err := app.NewServer(options...)
|
||||
require.NoError(tb, err)
|
||||
|
||||
a := app.New(app.ServerConnector(s.Channels()))
|
||||
prevListenAddress := *s.Config().ServiceSettings.ListenAddress
|
||||
a.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ListenAddress = "localhost:0" })
|
||||
err = s.Start()
|
||||
require.NoError(tb, err)
|
||||
a.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ListenAddress = prevListenAddress })
|
||||
|
||||
web := New(s)
|
||||
URL = fmt.Sprintf("http://localhost:%v", s.ListenAddr.Port)
|
||||
apiClient = model.NewAPIv4Client(URL)
|
||||
|
||||
s.Store().MarkSystemRanUnitTests()
|
||||
|
||||
a.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.TeamSettings.EnableOpenServer = true
|
||||
})
|
||||
|
||||
th := &TestHelper{
|
||||
App: a,
|
||||
Context: request.EmptyContext(testLogger),
|
||||
Server: s,
|
||||
Web: web,
|
||||
IncludeCacheLayer: includeCacheLayer,
|
||||
TestLogger: testLogger,
|
||||
}
|
||||
|
||||
tb.Cleanup(func() {
|
||||
if th.IncludeCacheLayer {
|
||||
// Clean all the caches
|
||||
appErr := th.App.Srv().InvalidateAllCaches()
|
||||
require.Nil(tb, appErr)
|
||||
}
|
||||
th.Server.Shutdown()
|
||||
})
|
||||
|
||||
return th
|
||||
}
|
||||
|
||||
func (th *TestHelper) InitPlugins() *TestHelper {
|
||||
pluginDir := filepath.Join(th.tempWorkspace, "plugins")
|
||||
webappDir := filepath.Join(th.tempWorkspace, "webapp")
|
||||
|
||||
th.App.InitPlugins(th.Context, pluginDir, webappDir)
|
||||
|
||||
return th
|
||||
}
|
||||
|
||||
func (th *TestHelper) NewPluginAPI(manifest *model.Manifest) plugin.API {
|
||||
return th.App.NewPluginAPI(th.Context, manifest)
|
||||
}
|
||||
|
||||
func (th *TestHelper) InitBasic(tb testing.TB) *TestHelper {
|
||||
tb.Helper()
|
||||
|
||||
var appErr *model.AppError
|
||||
th.SystemAdminUser, appErr = th.App.CreateUser(th.Context, &model.User{Email: model.NewId() + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: model.NewTestPassword(), EmailVerified: true, Roles: model.SystemAdminRoleId})
|
||||
require.Nil(tb, appErr)
|
||||
|
||||
th.BasicUser, appErr = th.App.CreateUser(th.Context, &model.User{Email: model.NewId() + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: model.NewTestPassword(), EmailVerified: true, Roles: model.SystemUserRoleId})
|
||||
require.Nil(tb, appErr)
|
||||
|
||||
th.BasicTeam, appErr = th.App.CreateTeam(th.Context, &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: th.BasicUser.Email, Type: model.TeamOpen})
|
||||
require.Nil(tb, appErr)
|
||||
|
||||
_, appErr = th.App.JoinUserToTeam(th.Context, th.BasicTeam, th.BasicUser, "")
|
||||
require.Nil(tb, appErr)
|
||||
|
||||
th.BasicChannel, appErr = th.App.CreateChannel(th.Context, &model.Channel{DisplayName: "Test API Name", Name: "zz" + model.NewId() + "a", Type: model.ChannelTypeOpen, TeamId: th.BasicTeam.Id, CreatorId: th.BasicUser.Id}, true)
|
||||
require.Nil(tb, appErr)
|
||||
|
||||
return th
|
||||
}
|
||||
|
||||
func TestStaticFilesRequest(t *testing.T) {
|
||||
th := Setup(t).InitPlugins()
|
||||
|
||||
pluginID := "com.mattermost.sample"
|
||||
|
||||
// Setup the directory directly in the plugin working path.
|
||||
pluginDir := filepath.Join(*th.App.Config().PluginSettings.Directory, pluginID)
|
||||
err := os.MkdirAll(pluginDir, 0777)
|
||||
require.NoError(t, err)
|
||||
pluginDir, err = filepath.Abs(pluginDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Compile the backend
|
||||
backend := filepath.Join(pluginDir, "backend.exe")
|
||||
pluginCode := `
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
)
|
||||
|
||||
type MyPlugin struct {
|
||||
plugin.MattermostPlugin
|
||||
}
|
||||
|
||||
func main() {
|
||||
plugin.ClientMain(&MyPlugin{})
|
||||
}
|
||||
`
|
||||
utils.CompileGo(t, pluginCode, backend)
|
||||
|
||||
// Write out the frontend
|
||||
mainJS := `var x = alert();`
|
||||
mainJSPath := filepath.Join(pluginDir, "main.js")
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(mainJSPath, []byte(mainJS), 0777)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write the plugin.json manifest
|
||||
pluginManifest := `{"id": "com.mattermost.sample", "server": {"executable": "backend.exe"}, "webapp": {"bundle_path":"main.js"}, "settings_schema": {"settings": []}}`
|
||||
err = os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(pluginManifest), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Activate the plugin
|
||||
manifest, activated, reterr := th.App.GetPluginsEnvironment().Activate(pluginID)
|
||||
require.NoError(t, reterr)
|
||||
require.NotNil(t, manifest)
|
||||
require.True(t, activated)
|
||||
|
||||
// Verify access to the bundle with requisite headers
|
||||
req, err := http.NewRequest("GET", "/static/plugins/com.mattermost.sample/com.mattermost.sample_724ed0e2ebb2b841_bundle.js", nil)
|
||||
require.NoError(t, err)
|
||||
res := httptest.NewRecorder()
|
||||
th.Web.MainRouter.ServeHTTP(res, req)
|
||||
assert.Equal(t, http.StatusOK, res.Code)
|
||||
assert.Equal(t, mainJS, res.Body.String())
|
||||
assert.Equal(t, []string{"max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
|
||||
|
||||
// Verify cached access to the bundle with an If-Modified-Since timestamp in the future
|
||||
future := time.Now().Add(24 * time.Hour)
|
||||
req, err = http.NewRequest("GET", "/static/plugins/com.mattermost.sample/com.mattermost.sample_724ed0e2ebb2b841_bundle.js", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("If-Modified-Since", future.Format(time.RFC850))
|
||||
res = httptest.NewRecorder()
|
||||
th.Web.MainRouter.ServeHTTP(res, req)
|
||||
assert.Equal(t, http.StatusNotModified, res.Code)
|
||||
assert.Empty(t, res.Body.String())
|
||||
assert.Equal(t, []string{"max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
|
||||
|
||||
// Verify access to the bundle with an If-Modified-Since timestamp in the past
|
||||
past := time.Now().Add(-24 * time.Hour)
|
||||
req, err = http.NewRequest("GET", "/static/plugins/com.mattermost.sample/com.mattermost.sample_724ed0e2ebb2b841_bundle.js", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("If-Modified-Since", past.Format(time.RFC850))
|
||||
res = httptest.NewRecorder()
|
||||
th.Web.MainRouter.ServeHTTP(res, req)
|
||||
assert.Equal(t, http.StatusOK, res.Code)
|
||||
assert.Equal(t, mainJS, res.Body.String())
|
||||
assert.Equal(t, []string{"max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
|
||||
|
||||
// Verify handling of 404.
|
||||
req, err = http.NewRequest("GET", "/static/plugins/com.mattermost.sample/404.js", nil)
|
||||
require.NoError(t, err)
|
||||
res = httptest.NewRecorder()
|
||||
th.Web.MainRouter.ServeHTTP(res, req)
|
||||
assert.Equal(t, http.StatusNotFound, res.Code)
|
||||
assert.Equal(t, "404 page not found\n", res.Body.String())
|
||||
assert.Equal(t, []string{"no-cache, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
|
||||
}
|
||||
|
||||
func TestPublicFilesRequest(t *testing.T) {
|
||||
th := Setup(t).InitPlugins()
|
||||
|
||||
pluginDir, err := os.MkdirTemp("", "")
|
||||
require.NoError(t, err)
|
||||
webappPluginDir, err := os.MkdirTemp("", "")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(pluginDir)
|
||||
defer os.RemoveAll(webappPluginDir)
|
||||
|
||||
env, err := plugin.NewEnvironment(th.NewPluginAPI, app.NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
pluginID := "com.mattermost.sample"
|
||||
pluginCode :=
|
||||
`
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
)
|
||||
|
||||
type MyPlugin struct {
|
||||
plugin.MattermostPlugin
|
||||
}
|
||||
|
||||
func main() {
|
||||
plugin.ClientMain(&MyPlugin{})
|
||||
}
|
||||
|
||||
`
|
||||
// Compile and write the plugin
|
||||
backend := filepath.Join(pluginDir, pluginID, "backend.exe")
|
||||
utils.CompileGo(t, pluginCode, backend)
|
||||
|
||||
// Write the plugin.json manifest
|
||||
pluginManifest := `{"id": "com.mattermost.sample", "server": {"executable": "backend.exe"}, "settings_schema": {"settings": []}}`
|
||||
err = os.WriteFile(filepath.Join(pluginDir, pluginID, "plugin.json"), []byte(pluginManifest), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write the test public file
|
||||
helloHTML := `Hello from the static files public folder for the com.mattermost.sample plugin!`
|
||||
htmlFolderPath := filepath.Join(pluginDir, pluginID, "public")
|
||||
err = os.MkdirAll(htmlFolderPath, os.ModePerm)
|
||||
require.NoError(t, err)
|
||||
htmlFilePath := filepath.Join(htmlFolderPath, "hello.html")
|
||||
|
||||
htmlFileErr := os.WriteFile(htmlFilePath, []byte(helloHTML), 0600)
|
||||
assert.NoError(t, htmlFileErr)
|
||||
|
||||
nefariousHTML := `You shouldn't be able to get here!`
|
||||
htmlFileErr = os.WriteFile(filepath.Join(pluginDir, pluginID, "nefarious-file-access.html"), []byte(nefariousHTML), 0600)
|
||||
assert.NoError(t, htmlFileErr)
|
||||
|
||||
manifest, activated, reterr := env.Activate(pluginID)
|
||||
require.NoError(t, reterr)
|
||||
require.NotNil(t, manifest)
|
||||
require.True(t, activated)
|
||||
|
||||
th.App.Channels().SetPluginsEnvironment(env)
|
||||
|
||||
req, err := http.NewRequest("GET", "/plugins/com.mattermost.sample/public/hello.html", nil)
|
||||
require.NoError(t, err)
|
||||
res := httptest.NewRecorder()
|
||||
th.Web.MainRouter.ServeHTTP(res, req)
|
||||
assert.Equal(t, helloHTML, res.Body.String())
|
||||
|
||||
req, err = http.NewRequest("GET", "/plugins/com.mattermost.sample/nefarious-file-access.html", nil)
|
||||
require.NoError(t, err)
|
||||
res = httptest.NewRecorder()
|
||||
th.Web.MainRouter.ServeHTTP(res, req)
|
||||
assert.Equal(t, 404, res.Code)
|
||||
|
||||
req, err = http.NewRequest("GET", "/plugins/com.mattermost.sample/public/../nefarious-file-access.html", nil)
|
||||
require.NoError(t, err)
|
||||
res = httptest.NewRecorder()
|
||||
th.Web.MainRouter.ServeHTTP(res, req)
|
||||
assert.Equal(t, 301, res.Code)
|
||||
}
|
||||
|
||||
/* Test disabled for now so we don't require the client to build. Maybe re-enable after client gets moved out.
|
||||
func TestStatic(t *testing.T) {
|
||||
Setup()
|
||||
|
||||
// add a short delay to make sure the server is ready to receive requests
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
resp, err := http.Get(URL + "/static/root.html")
|
||||
|
||||
assert.NoErrorf(t, err, "got error while trying to get static files %v", err)
|
||||
assert.Equalf(t, resp.StatusCode, http.StatusOK, "couldn't get static files %v", resp.StatusCode)
|
||||
}
|
||||
*/
|
||||
|
||||
func TestStaticFilesCaching(t *testing.T) {
|
||||
th := Setup(t).InitPlugins()
|
||||
|
||||
fakeMainBundleName := "main.1234ab.js"
|
||||
fakeRootHTML := `<html>
|
||||
<head>
|
||||
<title>Mattermost</title>
|
||||
</head>
|
||||
</html>`
|
||||
fakeMainBundle := `module.exports = 'main';`
|
||||
fakeRemoteEntry := `module.exports = 'remote';`
|
||||
|
||||
err := os.WriteFile("./client/root.html", []byte(fakeRootHTML), 0600)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile("./client/"+fakeMainBundleName, []byte(fakeMainBundle), 0600)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile("./client/remote_entry.js", []byte(fakeRemoteEntry), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.MkdirAll("./client/products/boards", 0777)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile("./client/products/boards/remote_entry.js", []byte(fakeRemoteEntry), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest("GET", "/", nil)
|
||||
require.NoError(t, err)
|
||||
res := httptest.NewRecorder()
|
||||
th.Web.MainRouter.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Equal(t, fakeRootHTML, res.Body.String())
|
||||
require.Equal(t, []string{"no-cache, max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
|
||||
|
||||
// Checking for HEAD method as well.
|
||||
req, err = http.NewRequest(http.MethodHead, "/", nil)
|
||||
require.NoError(t, err)
|
||||
res = httptest.NewRecorder()
|
||||
th.Web.MainRouter.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Equal(t, fakeRootHTML, res.Body.String())
|
||||
require.Equal(t, []string{"no-cache, max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
|
||||
|
||||
req, err = http.NewRequest("GET", "/static/"+fakeMainBundleName, nil)
|
||||
require.NoError(t, err)
|
||||
res = httptest.NewRecorder()
|
||||
th.Web.MainRouter.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Equal(t, fakeMainBundle, res.Body.String())
|
||||
require.Equal(t, []string{"max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
|
||||
|
||||
req, err = http.NewRequest("GET", "/static/remote_entry.js", nil)
|
||||
require.NoError(t, err)
|
||||
res = httptest.NewRecorder()
|
||||
th.Web.MainRouter.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Equal(t, fakeRemoteEntry, res.Body.String())
|
||||
require.Equal(t, []string{"no-cache, max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
|
||||
|
||||
req, err = http.NewRequest("GET", "/static/products/boards/remote_entry.js", nil)
|
||||
require.NoError(t, err)
|
||||
res = httptest.NewRecorder()
|
||||
th.Web.MainRouter.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Equal(t, fakeRemoteEntry, res.Body.String())
|
||||
require.Equal(t, []string{"no-cache, max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")])
|
||||
}
|
||||
|
||||
func TestCheckClientCompatability(t *testing.T) {
|
||||
//Browser Name, UA String, expected result (if the browser should fail the test false and if it should pass the true)
|
||||
type uaTest struct {
|
||||
Name string // Name of Browser
|
||||
UserAgent string // Useragent of Browser
|
||||
Result bool // Expected result (true if browser should be compatible, false if browser shouldn't be compatible)
|
||||
}
|
||||
var uaTestParameters = []uaTest{
|
||||
{"Mozilla 40.1", "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.1", true},
|
||||
{"Chrome 60", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36", true},
|
||||
{"Chrome Mobile", "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Mobile Safari/537.36", true},
|
||||
{"MM Classic App", "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR6.170623.013; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/61.0.3163.81 Mobile Safari/537.36 Web-Atoms-Mobile-WebView", true},
|
||||
{"MM App 3.7.1", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Mattermost/3.7.1 Chrome/56.0.2924.87 Electron/1.6.11 Safari/537.36", true},
|
||||
{"Franz 4.0.4", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Franz/4.0.4 Chrome/52.0.2743.82 Electron/1.3.1 Safari/537.36", true},
|
||||
{"Edge 14", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.79 Safari/537.36 Edge/14.14393", true},
|
||||
{"Internet Explorer 9", "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 7.1; Trident/5.0", false},
|
||||
{"Internet Explorer 11", "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko", false},
|
||||
{"Internet Explorer 11 2", "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; Zoom 3.6.0; rv:11.0) like Gecko", false},
|
||||
{"Internet Explorer 11 (Compatibility Mode) 1", "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; .NET CLR 1.1.4322; InfoPath.3; Zoom 3.6.0)", false},
|
||||
{"Internet Explorer 11 (Compatibility Mode) 2", "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; Zoom 3.6.0)", false},
|
||||
{"Safari 12", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Safari/605.1.15", true},
|
||||
{"Safari 11", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Safari/604.1.38", false},
|
||||
{"Safari 10", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/602.4.8 (KHTML, like Gecko) Version/10.0.3 Safari/602.4.8", false},
|
||||
{"Safari 9", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_3) AppleWebKit/601.4.4 (KHTML, like Gecko) Version/9.0.3 Safari/601.4.4", false},
|
||||
{"Safari 8", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_4) AppleWebKit/600.7.12 (KHTML, like Gecko) Version/8.0.7 Safari/600.7.12", false},
|
||||
{"Safari Mobile 12", "Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like macOS) AppleWebKit/602.1.50 (KHTML, like Gecko) Version/12.0 Mobile/14A5335d Safari/602.1.50", true},
|
||||
{"Safari Mobile 9", "Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B137 Safari/601.1", false},
|
||||
}
|
||||
for _, browser := range uaTestParameters {
|
||||
t.Run(browser.Name, func(t *testing.T) {
|
||||
result := CheckClientCompatibility(browser.UserAgent)
|
||||
require.Equalf(t, result, browser.Result, "user agent test failed for %s", browser.Name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDesktopAppCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
userAgent string
|
||||
minVersion string
|
||||
want bool
|
||||
}{
|
||||
{"blank min version allows all", "Mattermost/5.0.0", "", true},
|
||||
{"desktop app at min version", "Mattermost/5.0.0", "5.0.0", true},
|
||||
{"desktop app above min version", "Mattermost/5.1.0", "5.0.0", true},
|
||||
{"desktop app below min version", "Mattermost/4.9.0", "5.0.0", false},
|
||||
{"browser user agent not checked", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0", "5.0.0", true},
|
||||
{"desktop app version at start of UA", "Mattermost/5.3.1 Chrome/110.0.5481.177", "5.0.0", true},
|
||||
{"desktop app version at end of UA", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0.6478.127 Electron/31.2.1 Safari/537.36 Mattermost/5.9.0", "5.0.0", true},
|
||||
{"desktop app old version rejected", "Mattermost/3.7.1 Chrome/56.0.2924.87 Electron/1.6.11 Safari/537.36", "5.0.0", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := CheckDesktopAppCompatibility(tt.userAgent, &tt.minVersion)
|
||||
require.Equalf(t, tt.want, got, "CheckDesktopAppCompatibility(%q, %q)", tt.userAgent, tt.minVersion)
|
||||
})
|
||||
}
|
||||
}
|
||||
147
server/channels/web/webhook.go
Normal file
147
server/channels/web/webhook.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/schema"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
)
|
||||
|
||||
func (w *Web) InitWebhooks() {
|
||||
w.MainRouter.Handle("/hooks/commands/{id:[A-Za-z0-9]+}", w.APIHandlerTrustRequester(commandWebhook)).Methods(http.MethodPost)
|
||||
w.MainRouter.Handle("/hooks/{id:[A-Za-z0-9]+}", w.APIHandlerTrustRequester(incomingWebhook)).Methods(http.MethodPost)
|
||||
}
|
||||
|
||||
func incomingWebhook(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
params := mux.Vars(r)
|
||||
id := params["id"]
|
||||
errCtx := map[string]any{"hook_id": id}
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("incomingWebhook", "web.incoming_webhook.parse_form.app_error", errCtx, "", http.StatusBadRequest).Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
var appErr *model.AppError
|
||||
var mediaType string
|
||||
incomingWebhookPayload := &model.IncomingWebhookRequest{}
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
// Content-Type header is optional so could be empty
|
||||
if contentType != "" {
|
||||
var mimeErr error
|
||||
mediaType, _, mimeErr = mime.ParseMediaType(contentType)
|
||||
if mimeErr != nil && mimeErr != mime.ErrInvalidMediaParameter {
|
||||
c.Err = model.NewAppError("incomingWebhook", "web.incoming_webhook.media_type.app_error", errCtx, "", http.StatusBadRequest).Wrap(mimeErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if *c.App.Config().LogSettings.EnableWebhookDebugging {
|
||||
if c.Err != nil {
|
||||
fields := []mlog.Field{mlog.String("webhook_id", id), mlog.String("request_id", c.AppContext.RequestId())}
|
||||
payload, err := json.Marshal(incomingWebhookPayload)
|
||||
if err != nil {
|
||||
fields = append(fields, mlog.NamedErr("encoding_err", err))
|
||||
} else {
|
||||
fields = append(fields, mlog.String("payload", payload))
|
||||
}
|
||||
|
||||
mlog.Debug("Incoming webhook received", fields...)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
errCtx["media_type"] = mediaType
|
||||
if mediaType == "application/x-www-form-urlencoded" {
|
||||
payload := strings.NewReader(r.FormValue("payload"))
|
||||
|
||||
incomingWebhookPayload, appErr = decodePayload(payload)
|
||||
if appErr != nil {
|
||||
c.Err = model.NewAppError("incomingWebhook", "web.incoming_webhook.decode.app_error", errCtx, "", http.StatusBadRequest).Wrap(appErr)
|
||||
return
|
||||
}
|
||||
} else if mediaType == "multipart/form-data" {
|
||||
if err := r.ParseMultipartForm(0); err != nil {
|
||||
c.Err = model.NewAppError("incomingWebhook", "web.incoming_webhook.parse_multipart.app_error", errCtx, "", http.StatusBadRequest).Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
decoder := schema.NewDecoder()
|
||||
err := decoder.Decode(incomingWebhookPayload, r.PostForm)
|
||||
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("incomingWebhook", "web.incoming_webhook.decode.app_error", errCtx, "", http.StatusBadRequest).Wrap(err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
incomingWebhookPayload, appErr = decodePayload(r.Body)
|
||||
if appErr != nil {
|
||||
c.Err = model.NewAppError("incomingWebhook", "web.incoming_webhook.decode.app_error", errCtx, "", appErr.StatusCode).Wrap(appErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
appErr = c.App.HandleIncomingWebhook(c.AppContext, id, incomingWebhookPayload)
|
||||
if appErr != nil {
|
||||
c.Err = model.NewAppError("incomingWebhook", "web.incoming_webhook.general.app_error", errCtx, "", appErr.StatusCode).Wrap(appErr)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
if _, err := w.Write([]byte("ok")); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func commandWebhook(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
params := mux.Vars(r)
|
||||
id := params["id"]
|
||||
errCtx := map[string]any{"hook_id": id}
|
||||
|
||||
response, err := model.CommandResponseFromHTTPBody(r.Header.Get("Content-Type"), r.Body)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("commandWebhook", "web.command_webhook.parse.app_error", errCtx, "", http.StatusBadRequest).Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
if response != nil {
|
||||
if vErr := response.IsValid(); vErr != nil {
|
||||
c.Logger.Warn("Command response is not valid. Please update your integration to be compliant.", mlog.Err(vErr))
|
||||
}
|
||||
}
|
||||
|
||||
appErr := c.App.HandleCommandWebhook(c.AppContext, id, response)
|
||||
if appErr != nil {
|
||||
c.Err = model.NewAppError("commandWebhook", "web.command_webhook.general.app_error", errCtx, "", appErr.StatusCode).Wrap(appErr)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
if _, err := w.Write([]byte("ok")); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func decodePayload(payload io.Reader) (*model.IncomingWebhookRequest, *model.AppError) {
|
||||
incomingWebhookPayload, decodeError := model.IncomingWebhookRequestFromJSON(payload)
|
||||
|
||||
if decodeError != nil {
|
||||
return nil, decodeError
|
||||
}
|
||||
|
||||
return incomingWebhookPayload, nil
|
||||
}
|
||||
280
server/channels/web/webhook_test.go
Normal file
280
server/channels/web/webhook_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
func TestIncomingWebhook(t *testing.T) {
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
if !*th.App.Config().ServiceSettings.EnableIncomingWebhooks {
|
||||
_, err := http.Post(apiClient.URL+"/hooks/123", "", strings.NewReader("123"))
|
||||
assert.Error(t, err, "should have errored - webhooks turned off")
|
||||
return
|
||||
}
|
||||
|
||||
hook, err := th.App.CreateIncomingWebhookForChannel(th.BasicUser.Id, th.BasicChannel, &model.IncomingWebhook{ChannelId: th.BasicChannel.Id})
|
||||
require.Nil(t, err)
|
||||
|
||||
url := apiClient.URL + "/hooks/" + hook.Id
|
||||
|
||||
var tooLongTextBuilder strings.Builder
|
||||
for range 8200 {
|
||||
tooLongTextBuilder.WriteString("a")
|
||||
}
|
||||
tooLongText := tooLongTextBuilder.String()
|
||||
|
||||
t.Run("WebhookBasics", func(t *testing.T) {
|
||||
payload := "payload={\"text\": \"test text\"}"
|
||||
resp, err := http.Post(url, "application/x-www-form-urlencoded", strings.NewReader(payload))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
refreshed, appErr := th.App.GetIncomingWebhook(hook.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.NotZero(t, refreshed.LastUsed)
|
||||
|
||||
payload = "payload={\"text\": \"\"}"
|
||||
resp, err = http.Post(url, "application/x-www-form-urlencoded", strings.NewReader(payload))
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode, "should have errored - no text post")
|
||||
|
||||
payload = "payload={\"text\": \"test text\", \"channel\": \"junk\"}"
|
||||
resp, err = http.Post(url, "application/x-www-form-urlencoded", strings.NewReader(payload))
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode, "should have errored - bad channel")
|
||||
|
||||
payload = "payload={\"text\": \"test text\"}"
|
||||
resp, err = http.Post(apiClient.URL+"/hooks/abc123", "application/x-www-form-urlencoded", strings.NewReader(payload))
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode, "should have errored - bad hook")
|
||||
|
||||
resp, err = http.Post(url, "application/json", strings.NewReader("{\"text\":\"this is a test\"}"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
text := `this is a \"test\"
|
||||
that contains a newline and a tab`
|
||||
resp, err = http.Post(url, "application/json", strings.NewReader("{\"text\":\""+text+"\"}"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
resp, err = http.Post(url, "application/json", strings.NewReader(fmt.Sprintf("{\"text\":\"this is a test\", \"channel\":\"%s\"}", th.BasicChannel.Name)))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
resp, err = http.Post(url, "application/json", strings.NewReader(fmt.Sprintf("{\"text\":\"this is a test\", \"channel\":\"#%s\"}", th.BasicChannel.Name)))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
resp, err = http.Post(url, "application/json", strings.NewReader(fmt.Sprintf("{\"text\":\"this is a test\", \"channel\":\"@%s\"}", th.BasicUser.Username)))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
resp, err = http.Post(url, "application/x-www-form-urlencoded", strings.NewReader("payload={\"text\":\"this is a test\"}"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
resp, err = http.Post(url, "application/x-www-form-urlencoded", strings.NewReader("payload={\"text\":\""+text+"\"}"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
resp, err = http.Post(url, "AppLicaTion/x-www-Form-urlencoded", strings.NewReader("payload={\"text\":\""+text+"\"}"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
resp, err = http.Post(url, "application/x-www-form-urlencoded;charset=utf-8", strings.NewReader("payload={\"text\":\""+text+"\"}"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
resp, err = http.Post(url, "application/x-www-form-urlencoded; charset=utf-8", strings.NewReader("payload={\"text\":\""+text+"\"}"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
resp, err = http.Post(url, "application/x-www-form-urlencoded wrongtext", strings.NewReader("payload={\"text\":\""+text+"\"}"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
resp, err = http.Post(url, "application/json", strings.NewReader("{\"text\":\""+tooLongText+"\"}"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
resp, err = http.Post(url, "application/x-www-form-urlencoded", strings.NewReader("{\"text\":\""+tooLongText+"\"}"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
resp, err = http.Post(url, "application/json", strings.NewReader("payload={\"text\":\""+text+"\"}"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
payloadMultiPart := "------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"username\"\r\n\r\nwebhook-bot\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"text\"\r\n\r\nthis is a test :tada:\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW--"
|
||||
resp, err = http.Post(apiClient.URL+"/hooks/"+hook.Id, "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW", strings.NewReader(payloadMultiPart))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
resp, err = http.Post(url, "mimetype/wrong", strings.NewReader("payload={\"text\":\""+text+"\"}"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
resp, err = http.Post(url, "", strings.NewReader("{\"text\":\""+text+"\"}"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("WebhookAttachments", func(t *testing.T) {
|
||||
attachmentPayload := `{
|
||||
"text": "this is a test",
|
||||
"attachments": [
|
||||
{
|
||||
"fallback": "Required plain-text summary of the attachment.",
|
||||
|
||||
"color": "#36a64f",
|
||||
|
||||
"pretext": "Optional text that appears above the attachment block",
|
||||
|
||||
"author_name": "Bobby Tables",
|
||||
"author_link": "http://flickr.com/bobby/",
|
||||
"author_icon": "http://flickr.com/icons/bobby.jpg",
|
||||
|
||||
"title": "Slack API Documentation",
|
||||
"title_link": "https://api.slack.com/",
|
||||
|
||||
"text": "Optional text that appears within the attachment",
|
||||
|
||||
"fields": [
|
||||
{
|
||||
"title": "Priority",
|
||||
"value": "High",
|
||||
"short": false
|
||||
}
|
||||
],
|
||||
|
||||
"image_url": "http://my-website.com/path/to/image.jpg",
|
||||
"thumb_url": "http://example.com/path/to/thumb.png"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
resp, err := http.Post(url, "application/json", strings.NewReader(attachmentPayload))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.StatusCode == http.StatusOK)
|
||||
|
||||
attachmentPayload = `{
|
||||
"text": "this is a test",
|
||||
"attachments": [
|
||||
{
|
||||
"fallback": "Required plain-text summary of the attachment.",
|
||||
|
||||
"color": "#36a64f",
|
||||
|
||||
"pretext": "Optional text that appears above the attachment block",
|
||||
|
||||
"author_name": "Bobby Tables",
|
||||
"author_link": "http://flickr.com/bobby/",
|
||||
"author_icon": "http://flickr.com/icons/bobby.jpg",
|
||||
|
||||
"title": "Slack API Documentation",
|
||||
"title_link": "https://api.slack.com/",
|
||||
|
||||
"text": "` + tooLongText + `",
|
||||
|
||||
"fields": [
|
||||
{
|
||||
"title": "Priority",
|
||||
"value": "High",
|
||||
"short": false
|
||||
}
|
||||
],
|
||||
|
||||
"image_url": "http://my-website.com/path/to/image.jpg",
|
||||
"thumb_url": "http://example.com/path/to/thumb.png"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
resp, err = http.Post(url, "application/json", strings.NewReader(attachmentPayload))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.StatusCode == http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("ChannelLockedWebhook", func(t *testing.T) {
|
||||
channel, err := th.App.CreateChannel(th.Context, &model.Channel{TeamId: th.BasicTeam.Id, Name: model.NewId(), DisplayName: model.NewId(), Type: model.ChannelTypeOpen, CreatorId: th.BasicUser.Id}, true)
|
||||
require.Nil(t, err)
|
||||
|
||||
hook, err := th.App.CreateIncomingWebhookForChannel(th.BasicUser.Id, th.BasicChannel, &model.IncomingWebhook{ChannelId: th.BasicChannel.Id, ChannelLocked: true})
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, hook)
|
||||
|
||||
apiHookURL := apiClient.URL + "/hooks/" + hook.Id
|
||||
|
||||
payload := "payload={\"text\": \"test text\"}"
|
||||
resp, err2 := http.Post(apiHookURL, "application/x-www-form-urlencoded", strings.NewReader(payload))
|
||||
require.NoError(t, err2)
|
||||
assert.True(t, resp.StatusCode == http.StatusOK)
|
||||
|
||||
resp, err2 = http.Post(apiHookURL, "application/json", strings.NewReader(fmt.Sprintf("{\"text\":\"this is a test\", \"channel\":\"%s\"}", th.BasicChannel.Name)))
|
||||
require.NoError(t, err2)
|
||||
assert.True(t, resp.StatusCode == http.StatusOK)
|
||||
|
||||
resp, err2 = http.Post(apiHookURL, "application/json", strings.NewReader(fmt.Sprintf("{\"text\":\"this is a test\", \"channel\":\"%s\"}", channel.Name)))
|
||||
require.NoError(t, err2)
|
||||
assert.True(t, resp.StatusCode == http.StatusForbidden)
|
||||
})
|
||||
|
||||
t.Run("DisableWebhooks", func(t *testing.T) {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = false })
|
||||
resp, err := http.Post(url, "application/json", strings.NewReader("{\"text\":\"this is a test\"}"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.StatusCode == http.StatusNotImplemented)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommandWebhooks(t *testing.T) {
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
cmd, appErr := th.App.CreateCommand(&model.Command{
|
||||
CreatorId: th.BasicUser.Id,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
URL: "http://nowhere.com",
|
||||
Method: model.CommandMethodPost,
|
||||
Trigger: "delayed"})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
args := &model.CommandArgs{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
}
|
||||
|
||||
hook, appErr := th.App.CreateCommandWebhook(cmd.Id, args)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
resp, err := http.Post(apiClient.URL+"/hooks/commands/123123123123", "application/json", bytes.NewBufferString(`{"text":"this is a test"}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode, "expected not-found for non-existent hook")
|
||||
|
||||
resp, err = http.Post(apiClient.URL+"/hooks/commands/"+hook.Id, "application/json", bytes.NewBufferString(`{"text":"invalid`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
for range 5 {
|
||||
response, err2 := http.Post(apiClient.URL+"/hooks/commands/"+hook.Id, "application/json", bytes.NewBufferString(`{"text":"this is a test"}`))
|
||||
require.NoError(t, err2)
|
||||
require.Equal(t, http.StatusOK, response.StatusCode)
|
||||
}
|
||||
|
||||
resp, _ = http.Post(apiClient.URL+"/hooks/commands/"+hook.Id, "application/json", bytes.NewBufferString(`{"text":"this is a test"}`))
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
Reference in New Issue
Block a user