diff --git a/graphql2/graphqlapp/app.go b/graphql2/graphqlapp/app.go index effe656fd7..848f9bab0b 100644 --- a/graphql2/graphqlapp/app.go +++ b/graphql2/graphqlapp/app.go @@ -153,7 +153,16 @@ func isGQLValidation(gqlErr *gqlerror.Error) bool { return false } - return code == errcode.ValidationFailed || code == errcode.ParseFailed + switch code { + case errcode.ValidationFailed, errcode.ParseFailed: + // These are gqlgen validation errors. + return true + case ErrCodeInvalidDestType, ErrCodeInvalidDestValue: + // These are destination validation errors. + return true + } + + return false } func (a *App) Handler() http.Handler { diff --git a/graphql2/graphqlapp/contactmethod.go b/graphql2/graphqlapp/contactmethod.go index dbd224aae0..f811436be4 100644 --- a/graphql2/graphqlapp/contactmethod.go +++ b/graphql2/graphqlapp/contactmethod.go @@ -139,6 +139,9 @@ func (m *Mutation) CreateUserContactMethod(ctx context.Context, input graphql2.C cfg := config.FromContext(ctx) if input.Dest != nil { + if ok, err := (*App)(m).ValidateDestination(ctx, "dest", input.Dest); !ok { + return nil, err + } t, v := CompatDestToCMTypeVal(*input.Dest) input.Type = &t input.Value = &v diff --git a/graphql2/graphqlapp/destinationvalidation.go b/graphql2/graphqlapp/destinationvalidation.go new file mode 100644 index 0000000000..87656271fe --- /dev/null +++ b/graphql2/graphqlapp/destinationvalidation.go @@ -0,0 +1,181 @@ +package graphqlapp + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/99designs/gqlgen/graphql" + "github.com/target/goalert/config" + "github.com/target/goalert/graphql2" + "github.com/target/goalert/permission" + "github.com/target/goalert/validation" + "github.com/target/goalert/validation/validate" + "github.com/vektah/gqlparser/v2/ast" + "github.com/vektah/gqlparser/v2/gqlerror" +) + +const ( + ErrCodeInvalidDestType = "INVALID_DESTINATION_TYPE" + ErrCodeInvalidDestValue = "INVALID_DESTINATION_FIELD_VALUE" +) + +// addDestFieldError will add a destination field error to the current request, and return +// the original error if it is not a destination field validation error. +func addDestFieldError(ctx context.Context, parentField, fieldID string, err error) error { + if permission.IsPermissionError(err) { + // request level, return as is + return err + } + if !validation.IsClientError(err) { + // internal error, return as is + return err + } + + p := graphql.GetPath(ctx) + p = append(p, + ast.PathName(parentField), + ast.PathName("values"), // DestinationInput.Values + ast.PathName(fieldID), + ) + + graphql.AddError(ctx, &gqlerror.Error{ + Message: err.Error(), + Path: p, + Extensions: map[string]interface{}{ + "code": ErrCodeInvalidDestValue, + }, + }) + + return nil +} + +// ValidateDestination will validate a destination input. +// +// In the future this will be a call to the plugin system. +func (a *App) ValidateDestination(ctx context.Context, fieldName string, dest *graphql2.DestinationInput) (ok bool, err error) { + cfg := config.FromContext(ctx) + switch dest.Type { + case destTwilioSMS: + phone := dest.FieldValue(fieldPhoneNumber) + err := validate.Phone(fieldPhoneNumber, phone) + if err != nil { + return false, addDestFieldError(ctx, fieldName, fieldPhoneNumber, err) + } + return true, nil + case destTwilioVoice: + phone := dest.FieldValue(fieldPhoneNumber) + err := validate.Phone(fieldPhoneNumber, phone) + if err != nil { + return false, addDestFieldError(ctx, fieldName, fieldPhoneNumber, err) + } + return true, nil + case destSlackChan: + chanID := dest.FieldValue(fieldSlackChanID) + err := a.SlackStore.ValidateChannel(ctx, chanID) + if err != nil { + return false, addDestFieldError(ctx, fieldName, fieldSlackChanID, err) + } + + return true, nil + case destSlackDM: + userID := dest.FieldValue(fieldSlackUserID) + if err := a.SlackStore.ValidateUser(ctx, userID); err != nil { + return false, addDestFieldError(ctx, fieldName, fieldSlackUserID, err) + } + return true, nil + case destSlackUG: + ugID := dest.FieldValue(fieldSlackUGID) + userErr := a.SlackStore.ValidateUserGroup(ctx, ugID) + if err != nil { + return false, addDestFieldError(ctx, fieldName, fieldSlackUGID, userErr) + } + + chanID := dest.FieldValue(fieldSlackChanID) + chanErr := a.SlackStore.ValidateChannel(ctx, chanID) + if err != nil { + return false, addDestFieldError(ctx, fieldName, fieldSlackChanID, chanErr) + } + + return true, nil + case destSMTP: + email := dest.FieldValue(fieldEmailAddress) + err := validate.Email(fieldEmailAddress, email) + if err != nil { + return false, addDestFieldError(ctx, fieldName, fieldEmailAddress, err) + } + return true, nil + case destWebhook: + url := dest.FieldValue(fieldWebhookURL) + err := validate.AbsoluteURL(fieldWebhookURL, url) + if err != nil { + return false, addDestFieldError(ctx, fieldName, fieldWebhookURL, err) + } + if !cfg.ValidWebhookURL(url) { + return false, addDestFieldError(ctx, fieldName, fieldWebhookURL, validation.NewGenericError("url is not allowed by administator")) + } + return true, nil + case destSchedule: // must be valid UUID and exist + _, err := validate.ParseUUID(fieldScheduleID, dest.FieldValue(fieldScheduleID)) + if err != nil { + return false, addDestFieldError(ctx, fieldName, fieldScheduleID, err) + } + + _, err = a.ScheduleStore.FindOne(ctx, dest.FieldValue(fieldScheduleID)) + if errors.Is(err, sql.ErrNoRows) { + return false, addDestFieldError(ctx, fieldName, fieldScheduleID, validation.NewGenericError("schedule does not exist")) + } + if err != nil { + return false, addDestFieldError(ctx, fieldName, fieldScheduleID, err) + } + + return true, nil + case destRotation: // must be valid UUID and exist + rotID := dest.FieldValue(fieldRotationID) + _, err := validate.ParseUUID(fieldRotationID, rotID) + if err != nil { + return false, addDestFieldError(ctx, fieldName, fieldRotationID, err) + } + _, err = a.RotationStore.FindRotation(ctx, rotID) + if errors.Is(err, sql.ErrNoRows) { + return false, addDestFieldError(ctx, fieldName, fieldRotationID, validation.NewGenericError("rotation does not exist")) + } + if err != nil { + return false, addDestFieldError(ctx, fieldName, fieldRotationID, err) + } + + return true, nil + case destUser: // must be valid UUID and exist + userID := dest.FieldValue(fieldUserID) + uid, err := validate.ParseUUID(fieldUserID, userID) + if err != nil { + return false, addDestFieldError(ctx, fieldName, fieldUserID, err) + } + check, err := a.UserStore.UserExists(ctx) + if err != nil { + return false, fmt.Errorf("get user existance checker: %w", err) + } + if !check.UserExistsUUID(uid) { + return false, addDestFieldError(ctx, fieldName, fieldUserID, validation.NewGenericError("user does not exist")) + } + return true, nil + } + + // unsupported destination type + p := graphql.GetPath(ctx) + p = append(p, + ast.PathName(fieldName), + ast.PathName("type"), + ) + + graphql.AddError(ctx, &gqlerror.Error{ + Message: "unsupported destination type", + Path: p, + Extensions: map[string]interface{}{ + "code": ErrCodeInvalidDestType, + }, + }) + + return false, nil +} diff --git a/notification/slack/channel.go b/notification/slack/channel.go index 37ce074cb2..644df27378 100644 --- a/notification/slack/channel.go +++ b/notification/slack/channel.go @@ -75,6 +75,8 @@ type Channel struct { ID string Name string TeamID string + + IsArchived bool } // User contains information about a Slack user. @@ -133,6 +135,34 @@ func mapError(ctx context.Context, err error) error { return err } +func (s *ChannelSender) ValidateChannel(ctx context.Context, id string) error { + err := permission.LimitCheckAny(ctx, permission.User, permission.System) + if err != nil { + return err + } + + s.chanMx.Lock() + defer s.chanMx.Unlock() + res, ok := s.chanCache.Get(id) + if !ok { + res, err = s.loadChannel(ctx, id) + if err != nil { + if rootMsg(err) == "channel_not_found" { + return validation.NewGenericError("Channel does not exist, is private (need to invite goalert bot).") + } + + return err + } + s.chanCache.Add(id, res) + } + + if res.IsArchived { + return validation.NewGenericError("Channel is archived.") + } + + return nil +} + // Channel will lookup a single Slack channel for the bot. func (s *ChannelSender) Channel(ctx context.Context, channelID string) (*Channel, error) { err := permission.LimitCheckAny(ctx, permission.User, permission.System) @@ -232,6 +262,7 @@ func (s *ChannelSender) loadChannel(ctx context.Context, channelID string) (*Cha ch.ID = resp.ID ch.Name = "#" + resp.Name + ch.IsArchived = resp.IsArchived return nil }) diff --git a/notification/slack/user.go b/notification/slack/user.go index e775da2e6f..9e299ff21e 100644 --- a/notification/slack/user.go +++ b/notification/slack/user.go @@ -6,8 +6,26 @@ import ( "github.com/slack-go/slack" "github.com/target/goalert/permission" + "github.com/target/goalert/validation" ) +func (s *ChannelSender) ValidateUser(ctx context.Context, id string) error { + err := permission.LimitCheckAny(ctx, permission.User, permission.System) + if err != nil { + return err + } + + _, err = s.User(ctx, id) + if rootMsg(err) == "user_not_found" { + return validation.NewGenericError("user not found") + } + if err != nil { + return fmt.Errorf("validate user: %w", err) + } + + return nil +} + // User will lookup a single Slack user. func (s *ChannelSender) User(ctx context.Context, id string) (*User, error) { err := permission.LimitCheckAny(ctx, permission.User, permission.System) diff --git a/notification/slack/usergroup.go b/notification/slack/usergroup.go index 001e6ae02d..984c57d289 100644 --- a/notification/slack/usergroup.go +++ b/notification/slack/usergroup.go @@ -16,8 +16,34 @@ type UserGroup struct { Handle string } +func (s *ChannelSender) ValidateUserGroup(ctx context.Context, id string) error { + ug, err := s._UserGroup(ctx, id) + if err != nil { + return err + } + + if ug == nil { + return validation.NewGenericError("user group not found") + } + + return nil +} + // User will lookup a single Slack user group. func (s *ChannelSender) UserGroup(ctx context.Context, id string) (*UserGroup, error) { + ug, err := s._UserGroup(ctx, id) + if err != nil { + return nil, err + } + if ug == nil { + return nil, validation.NewGenericError("invalid user group id") + } + + return ug, nil +} + +// User will lookup a single Slack user group. +func (s *ChannelSender) _UserGroup(ctx context.Context, id string) (*UserGroup, error) { err := permission.LimitCheckAny(ctx, permission.User, permission.System) if err != nil { return nil, err