Files
upstream-mattermost/server/public/model/integration_action.go
upstream-mirror 240db4e2a7
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 Nightly Race / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:29:55 +05:00

1657 lines
58 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"crypto"
"crypto/aes"
"crypto/cipher"
"crypto/ecdsa"
"crypto/rand"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"maps"
"math/big"
"net/http"
"net/url"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
)
const (
PostActionTypeButton = "button"
PostActionTypeSelect = "select"
DialogTitleMaxLength = 80
DialogElementDisplayNameMaxLength = 24
DialogElementNameMaxLength = 300
DialogElementHelpTextMaxLength = 150
DialogElementTextMaxLength = 150
DialogElementTextareaMaxLength = 3000
DialogElementSelectMaxLength = 3000
DialogElementBoolMaxLength = 150
DialogElementFileMaxLength = 300
DefaultTimeIntervalMinutes = 60 // Default time interval for DateTime fields
DialogCollapsibleMaxDepth = 3 // Maximum levels of nested collapsible sections (top-level collapsible is depth 1)
MaxDialogCheckboxGroupOptions = 50
MaxDialogMatrixRows = 30
MaxDialogMatrixColumns = 10
DialogLabelPositionBefore = "before"
DialogLabelPositionAfter = "after"
DialogMatrixRowSelectionMultiple = "multiple"
DialogMatrixRowSelectionSingle = "single"
MaxDialogFileIds = 10
// MaxDialogSubmissionIDShapedTokenScan bounds defense-in-depth scanning of
// request.Submission for ID-shaped tokens (file IDs, user/channel select values,
// etc.). This is not the per-dialog file upload limit — see MaxDialogFileIds.
MaxDialogSubmissionIDShapedTokenScan = 256
// Go date/time format constants
ISODateFormat = "2006-01-02" // YYYY-MM-DD
ISODateTimeFormat = "2006-01-02T15:04:05Z" // RFC3339 UTC
ISODateTimeWithTimezoneFormat = "2006-01-02T15:04:05-07:00" // RFC3339 with timezone
ISODateTimeNoTimezoneFormat = "2006-01-02T15:04:05" // ISO datetime without timezone
ISODateTimeNoSecondsFormat = "2006-01-02T15:04" // ISO datetime without seconds
)
// Common datetime formats used by both date and datetime validation
var commonDateTimeFormats = []string{
ISODateTimeFormat, // RFC3339 UTC
ISODateTimeWithTimezoneFormat, // RFC3339 with timezone
ISODateTimeNoTimezoneFormat, // ISO datetime without timezone
ISODateTimeNoSecondsFormat, // ISO datetime without seconds
}
var PostActionRetainPropKeys = []string{
PostPropsFromWebhook,
PostPropsFromBot,
PostPropsFromPlugin,
PostPropsOverrideUsername,
PostPropsOverrideIconURL,
PostPropsOverrideIconEmoji,
PostPropsWebhookDisplayName,
}
// PostActionPreserve captures post fields preserved across an interactive action update.
type PostActionPreserve struct {
Retain map[string]any
Remove []string
OriginalProps map[string]any
OriginalIsPinned bool
OriginalHasReactions bool
RootPostId string
}
// PostActionPreserveState returns retain/remove props and metadata used when applying integration responses.
func (o *Post) PostActionPreserveState() PostActionPreserve {
retain := map[string]any{}
remove := []string{}
for _, key := range PostActionRetainPropKeys {
value, ok := o.GetProps()[key]
if ok {
retain[key] = value
} else {
remove = append(remove, key)
}
}
rootPostId := o.Id
if o.RootId != "" {
rootPostId = o.RootId
}
var originalProps map[string]any
if props := o.GetProps(); props != nil {
originalProps = make(map[string]any, len(props))
maps.Copy(originalProps, props)
}
return PostActionPreserve{
Retain: retain,
Remove: remove,
OriginalProps: originalProps,
OriginalIsPinned: o.IsPinned,
OriginalHasReactions: o.HasReactions,
RootPostId: rootPostId,
}
}
type DoPostActionRequest struct {
SelectedOption string `json:"selected_option,omitempty"`
Cookie string `json:"cookie,omitempty"`
Query map[string]string `json:"query,omitempty"`
// IntegrationFormat identifies which format originally had the action (attachment, mm_block, ...).
// Empty means a legacy client and is treated as attachment.
IntegrationFormat string `json:"integration_format,omitempty"`
}
// Integration format values for DoPostActionRequest.IntegrationFormat (client → server).
const (
PostActionIntegrationFormatAttachment = "attachment"
PostActionIntegrationFormatAppsBinding = "apps_binding"
PostActionIntegrationFormatBlock = "block"
PostActionIntegrationFormatCard = "card"
PostActionIntegrationFormatMmBlock = "mm_block"
)
// NormalizePostActionIntegrationFormat returns a canonical integration format, defaulting to attachment when empty or unknown.
func NormalizePostActionIntegrationFormat(s string) string {
c := strings.TrimSpace(strings.ToLower(s))
switch c {
case PostActionIntegrationFormatMmBlock,
PostActionIntegrationFormatAppsBinding,
PostActionIntegrationFormatBlock,
PostActionIntegrationFormatCard:
return c
case PostActionIntegrationFormatAttachment, "":
return PostActionIntegrationFormatAttachment
default:
return PostActionIntegrationFormatAttachment
}
}
const (
PostActionDataSourceUsers = "users"
PostActionDataSourceChannels = "channels"
MaxMmBlocksActionsPerPost = 50
MaxMmBlocksActionKeyLength = 64
MaxActionQueryEntries = 50
MaxActionQueryKeyLength = 128
MaxActionQueryValueLength = 2048
)
type PostAction struct {
// A unique Action ID. If not set, generated automatically.
Id string `json:"id,omitempty"`
// The type of the interactive element. Currently supported are
// "select" and "button".
Type string `json:"type,omitempty"`
// The text on the button, or in the select placeholder.
Name string `json:"name,omitempty"`
// Tooltip text displayed on hover.
Tooltip string `json:"tooltip,omitempty"`
// If the action is disabled.
Disabled bool `json:"disabled,omitempty"`
// Style defines a text and border style.
// Supported values are "default", "primary", "success", "good", "warning", "danger"
// and any hex color.
Style string `json:"style,omitempty"`
// DataSource indicates the data source for the select action. If left
// empty, the select is populated from Options. Other supported values
// are "users" and "channels".
DataSource string `json:"data_source,omitempty"`
// Options contains the values listed in a select dropdown on the post.
Options []*PostActionOptions `json:"options,omitempty"`
// DefaultOption contains the option, if any, that will appear as the
// default selection in a select box. It has no effect when used with
// other types of actions.
DefaultOption string `json:"default_option,omitempty"`
// Defines the interaction with the backend upon a user action.
// Integration contains Context, which is private plugin data;
// Integrations are stripped from Posts when they are sent to the
// client, or are encrypted in a Cookie.
Integration *PostActionIntegration `json:"integration,omitempty"`
Cookie string `json:"cookie,omitempty" db:"-"`
}
// IsValid validates the action and returns an error if it is invalid.
func (p *PostAction) IsValid() error {
var multiErr *multierror.Error
if p.Name == "" {
multiErr = multierror.Append(multiErr, fmt.Errorf("action must have a name"))
}
if p.Style != "" {
validStyles := []string{"default", "primary", "success", "good", "warning", "danger"}
// If not a predefined style, check if it's a hex color
if !slices.Contains(validStyles, p.Style) && !hexColorRegex.MatchString(p.Style) {
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid style '%s' - must be one of [default, primary, success, good, warning, danger] or a hex color", p.Style))
}
}
switch p.Type {
case PostActionTypeButton:
if len(p.Options) > 0 {
multiErr = multierror.Append(multiErr, fmt.Errorf("button action must not have options"))
}
if p.DataSource != "" {
multiErr = multierror.Append(multiErr, fmt.Errorf("button action must not have a data source"))
}
case PostActionTypeSelect:
if p.DataSource != "" {
validSources := []string{PostActionDataSourceUsers, PostActionDataSourceChannels}
if !slices.Contains(validSources, p.DataSource) {
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid data_source '%s' for select action", p.DataSource))
}
if len(p.Options) > 0 {
multiErr = multierror.Append(multiErr, fmt.Errorf("select action cannot have both DataSource and Options set"))
}
} else {
if len(p.Options) == 0 {
multiErr = multierror.Append(multiErr, fmt.Errorf("select action must have either DataSource or Options set"))
} else {
for i, opt := range p.Options {
if opt == nil {
multiErr = multierror.Append(multiErr, fmt.Errorf("select action contains nil option"))
continue
}
if err := opt.IsValid(); err != nil {
multiErr = multierror.Append(multiErr, multierror.Prefix(err, fmt.Sprintf("option at index %d is invalid:", i)))
}
}
}
}
default:
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid action type: must be '%s' or '%s'", PostActionTypeButton, PostActionTypeSelect))
}
if p.Integration == nil {
multiErr = multierror.Append(multiErr, fmt.Errorf("action must have integration settings"))
} else {
if p.Integration.URL == "" {
multiErr = multierror.Append(multiErr, fmt.Errorf("action must have an integration URL"))
}
if !(strings.HasPrefix(p.Integration.URL, "/plugins/") || strings.HasPrefix(p.Integration.URL, "plugins/") || IsValidHTTPURL(p.Integration.URL)) {
multiErr = multierror.Append(multiErr, fmt.Errorf("action must have an valid integration URL"))
}
}
return multiErr.ErrorOrNil()
}
func (p *PostAction) Equals(input *PostAction) bool {
if p.Id != input.Id {
return false
}
if p.Type != input.Type {
return false
}
if p.Name != input.Name {
return false
}
if p.DataSource != input.DataSource {
return false
}
if p.DefaultOption != input.DefaultOption {
return false
}
if p.Cookie != input.Cookie {
return false
}
// Compare PostActionOptions
if len(p.Options) != len(input.Options) {
return false
}
for k := range p.Options {
if p.Options[k].Text != input.Options[k].Text {
return false
}
if p.Options[k].Value != input.Options[k].Value {
return false
}
}
// Compare PostActionIntegration
// If input is nil, then return true if original is also nil.
// Else return false.
if input.Integration == nil {
return p.Integration == nil
}
// At this point, input is not nil, so return false if original is.
if p.Integration == nil {
return false
}
// Both are unequal and not nil.
if p.Integration.URL != input.Integration.URL {
return false
}
if len(p.Integration.Context) != len(input.Integration.Context) {
return false
}
for key, value := range p.Integration.Context {
inputValue, ok := input.Integration.Context[key]
if !ok {
return false
}
switch inputValue.(type) {
case string, bool, int, float64:
if value != inputValue {
return false
}
default:
if !reflect.DeepEqual(value, inputValue) {
return false
}
}
}
return true
}
// PostActionCookie is set by the server, serialized and encrypted into
// PostAction.Cookie. The clients should hold on to it, and include it with
// subsequent DoPostAction requests. This allows the server to access the
// action metadata even when it's not available in the database, for ephemeral
// posts. Used for attachment-based interactive messages (legacy PostAction shape).
//
// mm_blocks uses [MmBlocksActionCookie] instead; see mm_blocks_actions.go.
type PostActionCookie struct {
Type string `json:"type,omitempty"`
PostId string `json:"post_id,omitempty"`
RootPostId string `json:"root_post_id,omitempty"`
ChannelId string `json:"channel_id,omitempty"`
DataSource string `json:"data_source,omitempty"`
Integration *PostActionIntegration `json:"integration,omitempty"`
RetainProps map[string]any `json:"retain_props,omitempty"`
RemoveProps []string `json:"remove_props,omitempty"`
}
// MmBlocksActionCookieKind is the JSON "kind" discriminator for [MmBlocksActionCookie] payloads.
const MmBlocksActionCookieKind = "mm_blocks_actions"
// MmBlocksActionCookie is the decrypted cookie payload for mm_blocks interactive messages
// (encrypted into props.mm_blocks_actions for clients). It mirrors the shared post/channel
// metadata of [PostActionCookie] but carries props.mm_blocks_actions as an actions map instead
// of a single PostAction integration.
type MmBlocksActionCookie struct {
Kind string `json:"kind,omitempty"`
PostId string `json:"post_id,omitempty"`
RootPostId string `json:"root_post_id,omitempty"`
ChannelId string `json:"channel_id,omitempty"`
RetainProps map[string]any `json:"retain_props,omitempty"`
RemoveProps []string `json:"remove_props,omitempty"`
Actions map[string]map[string]any `json:"actions"`
}
type PostActionOptions struct {
Text string `json:"text"`
Value string `json:"value"`
}
func (o *PostActionOptions) IsValid() error {
var multiErr *multierror.Error
if o.Text == "" {
multiErr = multierror.Append(multiErr, fmt.Errorf("text is required"))
}
if o.Value == "" {
multiErr = multierror.Append(multiErr, fmt.Errorf("value is required"))
}
return multiErr.ErrorOrNil()
}
type PostActionIntegration struct {
// URL is the endpoint that the action will be sent to.
// It can be a relative path to a plugin.
URL string `json:"url,omitempty"`
Context map[string]any `json:"context,omitempty"`
}
type PostActionIntegrationRequest struct {
UserId string `json:"user_id"`
UserName string `json:"user_name"`
ChannelId string `json:"channel_id"`
ChannelName string `json:"channel_name"`
TeamId string `json:"team_id"`
TeamName string `json:"team_domain"`
PostId string `json:"post_id"`
TriggerId string `json:"trigger_id"`
Type string `json:"type"`
DataSource string `json:"data_source"`
Context map[string]any `json:"context,omitempty"`
}
type PostActionIntegrationResponse struct {
Update *Post `json:"update"`
EphemeralText string `json:"ephemeral_text"`
SkipSlackParsing bool `json:"skip_slack_parsing"` // Set to `true` to skip the Slack-compatibility handling of Text.
GotoLocation string `json:"goto_location,omitempty"`
}
type PostActionAPIResponse struct {
Status string `json:"status"` // needed to maintain backwards compatibility
TriggerId string `json:"trigger_id"`
GotoLocation string `json:"goto_location,omitempty"`
}
type ExecuteDialogActionResponse struct {
TriggerId string `json:"trigger_id"`
}
type Dialog struct {
CallbackId string `json:"callback_id"`
Title string `json:"title"`
IntroductionText string `json:"introduction_text"`
IconURL string `json:"icon_url"`
Elements []DialogElement `json:"elements"`
SubmitLabel string `json:"submit_label"`
NotifyOnCancel bool `json:"notify_on_cancel"`
State string `json:"state"`
SourceURL string `json:"source_url,omitempty"`
}
// DialogDateTimeConfig groups date/datetime specific configuration
type DialogDateTimeConfig struct {
// MinDate: Minimum allowed date (ISO date, datetime, or relative like "+2H", "today")
MinDate string `json:"min_date,omitempty"`
// MaxDate: Maximum allowed date (ISO date, datetime, or relative like "+7d", "tomorrow")
MaxDate string `json:"max_date,omitempty"`
// TimeInterval: Minutes between time options in dropdown (default: 60)
TimeInterval int `json:"time_interval,omitempty"`
// LocationTimezone: IANA timezone for display (e.g., "America/Denver", "Asia/Tokyo")
LocationTimezone string `json:"location_timezone,omitempty"`
// ManualTimeEntry: Allow manual text entry for time instead of dropdown
ManualTimeEntry bool `json:"manual_time_entry,omitempty"`
}
// DialogMatrixConfig groups checkbox_matrix specific configuration.
type DialogMatrixConfig struct {
Rows []*PostActionOptions `json:"rows"`
Columns []*PostActionOptions `json:"columns"`
RowSelection string `json:"row_selection,omitempty"` // "multiple" | "single"
}
type DialogElement struct {
DisplayName string `json:"display_name"`
Name string `json:"name"`
Type string `json:"type"`
SubType string `json:"subtype"`
Default string `json:"default"`
Placeholder string `json:"placeholder"`
HelpText string `json:"help_text"`
Optional bool `json:"optional"`
MinLength int `json:"min_length"`
MaxLength int `json:"max_length"`
DataSource string `json:"data_source"`
DataSourceURL string `json:"data_source_url,omitempty"`
Options []*PostActionOptions `json:"options"`
MultiSelect bool `json:"multiselect"`
AllowMultiple bool `json:"allow_multiple,omitempty"`
Refresh bool `json:"refresh,omitempty"`
// CollapsibleConfig holds child elements and display options for a
// "collapsible" element. Nil for all other element types.
CollapsibleConfig *DialogElementCollapsibleConfig `json:"collapsible_config,omitempty"`
// Date/datetime field configuration
DateTimeConfig *DialogDateTimeConfig `json:"datetime_config,omitempty"`
LabelPosition string `json:"label_position,omitempty"` // "before" | "after"
MatrixConfig *DialogMatrixConfig `json:"matrix_config,omitempty"`
// Action button configuration (type "action_button")
ActionButton *DialogActionButton `json:"action_button,omitempty"`
}
// EffectiveDateTimeConfig returns the resolved date/datetime configuration,
// treating a nil DateTimeConfig as the zero value.
func (e *DialogElement) EffectiveDateTimeConfig() DialogDateTimeConfig {
if e.DateTimeConfig != nil {
return *e.DateTimeConfig
}
return DialogDateTimeConfig{}
}
// EffectiveMatrixConfig returns the resolved checkbox_matrix configuration.
func (e *DialogElement) EffectiveMatrixConfig() DialogMatrixConfig {
if e.MatrixConfig == nil {
return DialogMatrixConfig{RowSelection: DialogMatrixRowSelectionMultiple}
}
cfg := *e.MatrixConfig
if cfg.RowSelection == "" {
cfg.RowSelection = DialogMatrixRowSelectionMultiple
}
return cfg
}
type DialogActionButton struct {
URL string `json:"url"`
Context map[string]string `json:"context,omitempty"`
}
type DialogElementCollapsibleConfig struct {
// Elements are the child elements rendered inside the collapsible section.
Elements []DialogElement `json:"elements,omitempty"`
// Collapsed controls the initial open/closed state. False means expanded.
Collapsed bool `json:"collapsed,omitempty"`
// Borderless controls whether the section renders without a box outline.
Borderless bool `json:"borderless,omitempty"`
}
type OpenDialogRequest struct {
TriggerId string `json:"trigger_id"`
URL string `json:"url"`
Dialog Dialog `json:"dialog"`
// ChannelId is populated by the server from the trigger and sent to the client,
// which echoes it back on submit so the submission targets the correct channel.
ChannelId string `json:"channel_id,omitempty"`
}
type SubmitDialogRequest struct {
Type string `json:"type"`
URL string `json:"url,omitempty"`
CallbackId string `json:"callback_id"`
State string `json:"state"`
UserId string `json:"user_id"`
ChannelId string `json:"channel_id"`
TeamId string `json:"team_id"`
Submission map[string]any `json:"submission"`
Cancelled bool `json:"cancelled"`
FileIds []string `json:"file_ids,omitempty"`
}
type SubmitDialogResponseType string
const (
SubmitDialogResponseTypeEmpty SubmitDialogResponseType = ""
SubmitDialogResponseTypeOK SubmitDialogResponseType = "ok"
SubmitDialogResponseTypeForm SubmitDialogResponseType = "form"
SubmitDialogResponseTypeNavigate SubmitDialogResponseType = "navigate"
)
type SubmitDialogResponse struct {
Error string `json:"error,omitempty"`
Errors map[string]string `json:"errors,omitempty"`
Type string `json:"type,omitempty"`
Form *Dialog `json:"form,omitempty"`
}
type ExecuteDialogActionRequest struct {
URL string `json:"url"`
Context map[string]string `json:"context,omitempty"`
ChannelId string `json:"channel_id"`
TeamId string `json:"team_id"`
}
func (r *SubmitDialogResponse) IsValid() error {
// If Error or Errors are set, this is valid and everything else is ignored
if r.Error != "" || len(r.Errors) > 0 {
return nil
}
// Validate Type field and handle Form field appropriately for each type
switch SubmitDialogResponseType(r.Type) {
case SubmitDialogResponseTypeEmpty, SubmitDialogResponseTypeOK, SubmitDialogResponseTypeNavigate:
// Completion types - Form field should be nil
if r.Form != nil {
return errors.Errorf("form field must be nil for type %q", r.Type)
}
case SubmitDialogResponseTypeForm:
// Continuation type - Form field is required and must be valid
if r.Form == nil {
return errors.New("form field is required for form type")
}
if err := r.Form.IsValid(); err != nil {
return errors.Wrap(err, "invalid form")
}
default:
return errors.Errorf("invalid type %q, must be one of: empty, ok, form, navigate", r.Type)
}
return nil
}
// DialogSelectOption represents an option in a select dropdown for dialogs
type DialogSelectOption struct {
Text string `json:"text"`
Value string `json:"value"`
}
// LookupDialogResponse represents the response for a lookup dialog request.
type LookupDialogResponse struct {
Items []DialogSelectOption `json:"items"`
}
// signForGenerateTriggerId wraps the signing operation with panic recovery
// to handle invalid signers that may cause panics in the crypto package
func signForGenerateTriggerId(s crypto.Signer, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) {
defer func() {
if r := recover(); r != nil {
signature = nil
err = fmt.Errorf("invalid signing key: %v", r)
}
}()
return s.Sign(rand.Reader, digest, opts)
}
// GenerateTriggerId signs the context an interactive dialog needs on submit.
func GenerateTriggerId(userId, channelId string, s crypto.Signer) (string, string, *AppError) {
clientTriggerId := NewId()
triggerData := strings.Join([]string{clientTriggerId, userId, strconv.FormatInt(GetMillis(), 10), channelId}, ":") + ":"
h := crypto.SHA256
sum := h.New()
sum.Write([]byte(triggerData))
signature, err := signForGenerateTriggerId(s, sum.Sum(nil), h)
if err != nil {
return "", "", NewAppError("GenerateTriggerId", "interactive_message.generate_trigger_id.signing_failed", nil, "", http.StatusInternalServerError).Wrap(err)
}
base64Sig := base64.StdEncoding.EncodeToString(signature)
triggerId := base64.StdEncoding.EncodeToString([]byte(triggerData + base64Sig))
return clientTriggerId, triggerId, nil
}
func (r *PostActionIntegrationRequest) GenerateTriggerId(s crypto.Signer) (string, string, *AppError) {
clientTriggerId, triggerId, appErr := GenerateTriggerId(r.UserId, r.ChannelId, s)
if appErr != nil {
return "", "", appErr
}
r.TriggerId = triggerId
return clientTriggerId, triggerId, nil
}
// DecodeAndVerifyTriggerId returns the client trigger ID, the user the trigger was
// minted for, and the channel it originated in.
func DecodeAndVerifyTriggerId(triggerId string, s *ecdsa.PrivateKey, timeout time.Duration) (string, string, string, *AppError) {
triggerIdBytes, err := base64.StdEncoding.DecodeString(triggerId)
if err != nil {
return "", "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.base64_decode_failed", nil, "", http.StatusBadRequest).Wrap(err)
}
split := strings.Split(string(triggerIdBytes), ":")
if len(split) < 5 {
return "", "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.missing_data", nil, "", http.StatusBadRequest)
}
clientTriggerId := split[0]
userId := split[1]
timestampStr := split[2]
timestamp, _ := strconv.ParseInt(timestampStr, 10, 64)
channelId := split[3]
signatureStr := split[len(split)-1]
if time.Since(time.UnixMilli(timestamp)) > timeout {
return "", "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.expired", map[string]any{"Duration": timeout.String()}, "", http.StatusBadRequest)
}
signature, err := base64.StdEncoding.DecodeString(signatureStr)
if err != nil {
return "", "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.base64_decode_failed_signature", nil, "", http.StatusBadRequest).Wrap(err)
}
var esig struct {
R, S *big.Int
}
if _, err := asn1.Unmarshal(signature, &esig); err != nil {
return "", "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.signature_decode_failed", nil, "", http.StatusBadRequest).Wrap(err)
}
triggerData := strings.Join(split[:len(split)-1], ":") + ":"
h := crypto.SHA256
sum := h.New()
sum.Write([]byte(triggerData))
if !ecdsa.Verify(&s.PublicKey, sum.Sum(nil), esig.R, esig.S) {
return "", "", "", NewAppError("DecodeAndVerifyTriggerId", "interactive_message.decode_trigger_id.verify_signature_failed", nil, "", http.StatusBadRequest)
}
return clientTriggerId, userId, channelId, nil
}
func (r *OpenDialogRequest) DecodeAndVerifyTriggerId(s *ecdsa.PrivateKey, timeout time.Duration) (string, string, string, *AppError) {
return DecodeAndVerifyTriggerId(r.TriggerId, s, timeout)
}
func (r *OpenDialogRequest) IsValid() error {
var multiErr *multierror.Error
if r.URL == "" {
multiErr = multierror.Append(multiErr, errors.New("empty URL"))
}
if r.TriggerId == "" {
multiErr = multierror.Append(multiErr, errors.New("empty trigger id"))
}
err := r.Dialog.IsValid()
if err != nil {
multiErr = multierror.Append(multiErr, err)
}
return multiErr.ErrorOrNil()
}
func (d *Dialog) IsValid() error {
var multiErr *multierror.Error
if d.Title == "" || len(d.Title) > DialogTitleMaxLength {
multiErr = multierror.Append(multiErr, errors.Errorf("invalid dialog title %q", d.Title))
}
if d.IconURL != "" && !IsValidHTTPURL(d.IconURL) {
multiErr = multierror.Append(multiErr, errors.New("invalid icon url"))
}
if len(d.Elements) != 0 {
elementMap := make(map[string]bool)
for _, element := range d.Elements {
if elementMap[element.Name] {
multiErr = multierror.Append(multiErr, errors.Errorf("duplicate dialog element %q", element.Name))
}
elementMap[element.Name] = true
err := element.isValid(elementMap)
if err != nil {
multiErr = multierror.Append(multiErr, errors.Wrapf(err, "%q field is not valid", element.Name))
}
}
}
return multiErr.ErrorOrNil()
}
func (e *DialogElement) IsValid() error {
seen := make(map[string]bool)
if e.Name != "" {
seen[e.Name] = true
}
return e.isValid(seen)
}
// isValid validates the element, threading a shared seen-name set through
// recursive validation of collapsible children so duplicate names are detected
// across the whole element tree, not just at the top level.
func (e *DialogElement) isValid(seen map[string]bool) error {
var multiErr *multierror.Error
textSubTypes := map[string]bool{
"": true,
"text": true,
"email": true,
"number": true,
"tel": true,
"url": true,
"password": true,
}
multiErr = multierror.Append(multiErr, e.validateCommon())
multiErr = multierror.Append(multiErr, validateDialogElementLabelPosition(e))
multiErr = multierror.Append(multiErr, validateDialogElementMatrixConfigPlacement(e))
if e.AllowMultiple && e.Type != "file" {
multiErr = multierror.Append(multiErr, errors.Errorf("allow_multiple can only be used with file elements, got type %q", e.Type))
}
switch e.Type {
case "text":
multiErr = multierror.Append(multiErr, checkMaxLength("Default", e.Default, DialogElementTextMaxLength))
multiErr = multierror.Append(multiErr, checkMaxLength("Placeholder", e.Placeholder, DialogElementTextMaxLength))
if _, ok := textSubTypes[e.SubType]; !ok {
multiErr = multierror.Append(multiErr, errors.Errorf("invalid subtype %q", e.Type))
}
case "textarea":
multiErr = multierror.Append(multiErr, checkMaxLength("Default", e.Default, DialogElementTextareaMaxLength))
multiErr = multierror.Append(multiErr, checkMaxLength("Placeholder", e.Placeholder, DialogElementTextareaMaxLength))
if _, ok := textSubTypes[e.SubType]; !ok {
multiErr = multierror.Append(multiErr, errors.Errorf("invalid subtype %q", e.Type))
}
case "select":
multiErr = multierror.Append(multiErr, checkMaxLength("Default", e.Default, DialogElementSelectMaxLength))
multiErr = multierror.Append(multiErr, checkMaxLength("Placeholder", e.Placeholder, DialogElementSelectMaxLength))
if e.DataSource != "" && e.DataSource != "users" && e.DataSource != "channels" && e.DataSource != "dynamic" {
multiErr = multierror.Append(multiErr, errors.Errorf("invalid data source %q, allowed are 'users', 'channels', or 'dynamic'", e.DataSource))
}
if e.DataSource == "dynamic" {
// Dynamic selects should have a data_source_url
if e.DataSourceURL == "" {
multiErr = multierror.Append(multiErr, errors.New("dynamic data_source requires data_source_url"))
} else if !IsValidLookupURL(e.DataSourceURL) {
multiErr = multierror.Append(multiErr, errors.New("invalid data_source_url for dynamic select"))
}
// Dynamic selects should not have static options
if len(e.Options) > 0 {
multiErr = multierror.Append(multiErr, errors.New("dynamic select element should not have static options"))
}
} else if e.DataSource == "" {
if e.MultiSelect {
if !isMultiSelectDefaultInOptions(e.Default, e.Options) {
multiErr = multierror.Append(multiErr, errors.Errorf("multiselect default value %q contains values not in options", e.Default))
}
} else if !isDefaultInOptions(e.Default, e.Options) {
multiErr = multierror.Append(multiErr, errors.Errorf("default value %q doesn't exist in options ", e.Default))
}
}
case "bool":
if e.Default != "" && e.Default != "true" && e.Default != "false" {
multiErr = multierror.Append(multiErr, errors.New("invalid default of bool"))
}
multiErr = multierror.Append(multiErr, checkMaxLength("Placeholder", e.Placeholder, DialogElementBoolMaxLength))
case "radio":
if !isDefaultInOptions(e.Default, e.Options) {
multiErr = multierror.Append(multiErr, errors.Errorf("default value %q doesn't exist in options ", e.Default))
}
case "checkbox_group":
multiErr = multierror.Append(multiErr, validateDialogElementOptions(e.Options, 1, MaxDialogCheckboxGroupOptions, "options", ",")...)
multiErr = multierror.Append(multiErr, checkMaxLength("Default", e.Default, DialogElementSelectMaxLength))
if !isMultiSelectDefaultInOptions(e.Default, e.Options) {
multiErr = multierror.Append(multiErr, errors.Errorf("default value %q contains values not in options", e.Default))
}
if e.MultiSelect {
multiErr = multierror.Append(multiErr, errors.New("multiselect cannot be used with checkbox_group elements"))
}
if e.DataSource != "" {
multiErr = multierror.Append(multiErr, errors.New("checkbox_group elements cannot have a data source"))
}
if e.Placeholder != "" {
multiErr = multierror.Append(multiErr, errors.New("checkbox_group elements cannot have a placeholder"))
}
case "checkbox_matrix":
multiErr = multierror.Append(multiErr, validateDialogElementMatrixConfig(e)...)
multiErr = multierror.Append(multiErr, checkMaxLength("Default", e.Default, DialogElementSelectMaxLength))
if e.Default != "" {
multiErr = multierror.Append(multiErr, validateMatrixDefaultValue(e.Default, e.EffectiveMatrixConfig())...)
}
if len(e.Options) > 0 {
multiErr = multierror.Append(multiErr, errors.New("checkbox_matrix elements cannot have options"))
}
if e.MultiSelect {
multiErr = multierror.Append(multiErr, errors.New("multiselect cannot be used with checkbox_matrix elements"))
}
if e.DataSource != "" {
multiErr = multierror.Append(multiErr, errors.New("checkbox_matrix elements cannot have a data source"))
}
if e.Placeholder != "" {
multiErr = multierror.Append(multiErr, errors.New("checkbox_matrix elements cannot have a placeholder"))
}
case "date":
cfg := e.EffectiveDateTimeConfig()
multiErr = multierror.Append(multiErr, checkMaxLength("Default", e.Default, DialogElementTextMaxLength))
multiErr = multierror.Append(multiErr, checkMaxLength("Placeholder", e.Placeholder, DialogElementTextMaxLength))
multiErr = multierror.Append(multiErr, validateDateFormat(e.Default))
multiErr = multierror.Append(multiErr, validateDateFormat(cfg.MinDate))
multiErr = multierror.Append(multiErr, validateDateFormat(cfg.MaxDate))
case "datetime":
cfg := e.EffectiveDateTimeConfig()
multiErr = multierror.Append(multiErr, checkMaxLength("Default", e.Default, DialogElementTextMaxLength))
multiErr = multierror.Append(multiErr, checkMaxLength("Placeholder", e.Placeholder, DialogElementTextMaxLength))
multiErr = multierror.Append(multiErr, validateDateTimeFormat(e.Default))
multiErr = multierror.Append(multiErr, validateDateOrDateTimeFormat(cfg.MinDate))
multiErr = multierror.Append(multiErr, validateDateOrDateTimeFormat(cfg.MaxDate))
// Validate time_interval for datetime fields (0 means omitted — treated as default)
timeInterval := cfg.TimeInterval
if timeInterval != 0 {
if timeInterval < 1 || timeInterval > 1440 {
multiErr = multierror.Append(multiErr, errors.Errorf("time_interval must be between 1 and 1440 minutes, got %d", timeInterval))
} else if 1440%timeInterval != 0 {
multiErr = multierror.Append(multiErr, errors.Errorf("time_interval must be a divisor of 1440 (24 hours * 60 minutes) to create valid time intervals, got %d", timeInterval))
}
}
case "collapsible":
multiErr = multierror.Append(multiErr, e.validateCollapsible(1, seen))
case "file":
multiErr = multierror.Append(multiErr, checkMaxLength("Placeholder", e.Placeholder, DialogElementFileMaxLength))
multiErr = multierror.Append(multiErr, checkMaxLength("Default", e.Default, DialogElementFileMaxLength))
if e.Default != "" {
ids := strings.Split(e.Default, ",")
parsedIds := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if !IsValidId(id) {
multiErr = multierror.Append(multiErr, errors.Errorf("default file ID %q is not a valid ID", id))
continue
}
parsedIds = append(parsedIds, id)
}
if !e.AllowMultiple && len(parsedIds) > 1 {
multiErr = multierror.Append(multiErr, errors.New("default may not contain more than one file ID when allow_multiple is false"))
}
if len(parsedIds) > MaxDialogFileIds {
multiErr = multierror.Append(multiErr, errors.Errorf("default may not contain more than %d file IDs, got %d", MaxDialogFileIds, len(parsedIds)))
}
}
if len(e.Options) > 0 {
multiErr = multierror.Append(multiErr, errors.New("file elements cannot have options"))
}
if e.DataSource != "" {
multiErr = multierror.Append(multiErr, errors.New("file elements cannot have a data source"))
}
case "action_button":
if e.ActionButton == nil {
multiErr = multierror.Append(multiErr, errors.New("action_button element requires action_button configuration"))
} else if e.ActionButton.URL == "" {
multiErr = multierror.Append(multiErr, errors.New("action_button requires a non-empty URL"))
} else if !IsValidLookupURL(e.ActionButton.URL) {
multiErr = multierror.Append(multiErr, errors.Wrap(errors.New("invalid URL"), "invalid action_button URL"))
}
default:
multiErr = multierror.Append(multiErr, errors.Errorf("invalid element type: %q", e.Type))
}
return multiErr.ErrorOrNil()
}
// validateCommon runs the field-level checks that apply to every element type,
// independent of any type-specific rules. It is shared by both leaf elements
// (via isValid) and nested collapsible sections (via validateCollapsible) so
// that DisplayName/Name/HelpText, MinLength/MaxLength, and MultiSelect are
// validated for every element in the tree.
func (e *DialogElement) validateCommon() error {
var multiErr *multierror.Error
if e.MinLength < 0 {
multiErr = multierror.Append(multiErr, errors.Errorf("min length cannot be a negative number, got %d", e.MinLength))
}
if e.MinLength > e.MaxLength {
multiErr = multierror.Append(multiErr, errors.Errorf("min length should be less than max length, got %d > %d", e.MinLength, e.MaxLength))
}
multiErr = multierror.Append(multiErr, checkMaxLength("DisplayName", e.DisplayName, DialogElementDisplayNameMaxLength))
multiErr = multierror.Append(multiErr, checkMaxLength("Name", e.Name, DialogElementNameMaxLength))
multiErr = multierror.Append(multiErr, checkMaxLength("HelpText", e.HelpText, DialogElementHelpTextMaxLength))
if e.MultiSelect && e.Type != "select" {
multiErr = multierror.Append(multiErr, errors.Errorf("multiselect can only be used with select elements, got type %q", e.Type))
}
return multiErr.ErrorOrNil()
}
// validateCollapsible validates a collapsible element and its children, enforcing
// the maximum nesting depth. depth is the level of this collapsible, where a
// top-level collapsible is depth 1.
func (e *DialogElement) validateCollapsible(depth int, seen map[string]bool) error {
var multiErr *multierror.Error
cfg := e.CollapsibleConfig
if cfg == nil || len(cfg.Elements) == 0 {
multiErr = multierror.Append(multiErr, errors.New("collapsible element must have at least one child element"))
}
var children []DialogElement
if cfg != nil {
children = cfg.Elements
}
for i, child := range children {
if seen[child.Name] {
multiErr = multierror.Append(multiErr, errors.Errorf("duplicate dialog element %q", child.Name))
}
seen[child.Name] = true
if child.Type == "collapsible" {
// Nested collapsibles are validated directly (rather than via
// isValid) to preserve depth tracking, so run the common
// field-level checks here that isValid would otherwise apply.
if err := child.validateCommon(); err != nil {
multiErr = multierror.Append(multiErr, errors.Wrapf(err, "%q child element is not valid", child.Name))
}
if depth >= DialogCollapsibleMaxDepth {
multiErr = multierror.Append(multiErr, errors.Errorf("collapsible nesting exceeds maximum depth of %d (child %q at index %d)", DialogCollapsibleMaxDepth, child.Name, i))
continue
}
if err := child.validateCollapsible(depth+1, seen); err != nil {
multiErr = multierror.Append(multiErr, errors.Wrapf(err, "%q child element is not valid", child.Name))
}
continue
}
if err := child.isValid(seen); err != nil {
multiErr = multierror.Append(multiErr, errors.Wrapf(err, "%q child element is not valid", child.Name))
}
}
return multiErr.ErrorOrNil()
}
func isDefaultInOptions(defaultValue string, options []*PostActionOptions) bool {
if defaultValue == "" {
return true
}
for _, option := range options {
if option != nil && defaultValue == option.Value {
return true
}
}
return false
}
func isMultiSelectDefaultInOptions(defaultValue string, options []*PostActionOptions) bool {
if defaultValue == "" {
return true
}
for value := range strings.SplitSeq(defaultValue, ",") {
// Trim whitespace around each comma-separated token so defaults like
// "a, b" match, without stripping spaces inside an option value (e.g.
// an option whose value is legitimately "high risk").
value = strings.TrimSpace(value)
if value == "" {
continue
}
found := false
for _, option := range options {
if option != nil && value == option.Value {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func validateDialogElementLabelPosition(e *DialogElement) error {
if e.LabelPosition == "" {
return nil
}
if e.LabelPosition != DialogLabelPositionBefore && e.LabelPosition != DialogLabelPositionAfter {
return errors.Errorf("invalid label_position %q, must be %q or %q", e.LabelPosition, DialogLabelPositionBefore, DialogLabelPositionAfter)
}
switch e.Type {
case "bool", "radio", "checkbox_group":
return nil
default:
return errors.Errorf("label_position cannot be used with type %q", e.Type)
}
}
func validateDialogElementMatrixConfigPlacement(e *DialogElement) error {
if e.MatrixConfig == nil {
return nil
}
if e.Type != "checkbox_matrix" {
return errors.Errorf("matrix_config can only be used with checkbox_matrix elements, got type %q", e.Type)
}
return nil
}
func validateDialogElementOptions(options []*PostActionOptions, minOptions, maxOptions int, fieldName, disallowedChars string) []error {
var errs []error
if len(options) < minOptions {
errs = append(errs, errors.Errorf("%s must contain at least %d option(s), got %d", fieldName, minOptions, len(options)))
return errs
}
if len(options) > maxOptions {
errs = append(errs, errors.Errorf("%s may not contain more than %d option(s), got %d", fieldName, maxOptions, len(options)))
}
seen := make(map[string]bool, len(options))
for i, option := range options {
if option == nil {
errs = append(errs, errors.Errorf("%s[%d] is nil", fieldName, i))
continue
}
if err := option.IsValid(); err != nil {
errs = append(errs, errors.Wrapf(err, "%s[%d] is invalid", fieldName, i))
}
if disallowedChars != "" && strings.ContainsAny(option.Value, disallowedChars) {
errs = append(errs, errors.Errorf("%s[%d] value %q must not contain any of %q", fieldName, i, option.Value, disallowedChars))
}
if seen[option.Value] {
errs = append(errs, errors.Errorf("%s[%d] duplicate value %q", fieldName, i, option.Value))
}
seen[option.Value] = true
}
return errs
}
func validateDialogElementMatrixConfig(e *DialogElement) []error {
var errs []error
if e.MatrixConfig == nil {
errs = append(errs, errors.New("matrix_config is required for checkbox_matrix elements"))
return errs
}
cfg := e.MatrixConfig
if cfg.RowSelection != "" && cfg.RowSelection != DialogMatrixRowSelectionMultiple && cfg.RowSelection != DialogMatrixRowSelectionSingle {
errs = append(errs, errors.Errorf("invalid row_selection %q, must be %q or %q", cfg.RowSelection, DialogMatrixRowSelectionMultiple, DialogMatrixRowSelectionSingle))
}
// Disallow the three characters used to encode the matrix default value:
// ";" separates row entries, ":" separates a row from its columns, and ","
// separates columns. Allowing them in a row/column value would corrupt
// parsing of the default string in validateMatrixDefaultValue.
errs = append(errs, validateDialogElementOptions(cfg.Rows, 1, MaxDialogMatrixRows, "matrix_config.rows", ":,;")...)
errs = append(errs, validateDialogElementOptions(cfg.Columns, 1, MaxDialogMatrixColumns, "matrix_config.columns", ":,;")...)
return errs
}
// validateMatrixDefaultValue checks that defaultValue is a valid encoded matrix
// selection. The format is semicolon-separated row entries, each of the form
// "rowValue:col1,col2". For example:
//
// multiple: "row1:colA,colB;row2:colA"
// single: "row1:colA;row2:colB"
func validateMatrixDefaultValue(defaultValue string, cfg DialogMatrixConfig) []error {
var errs []error
rowValues := make(map[string]bool, len(cfg.Rows))
for _, row := range cfg.Rows {
if row != nil {
rowValues[row.Value] = true
}
}
columnValues := make(map[string]bool, len(cfg.Columns))
for _, col := range cfg.Columns {
if col != nil {
columnValues[col.Value] = true
}
}
seenRows := make(map[string]bool)
for rowEntry := range strings.SplitSeq(defaultValue, ";") {
rowEntry = strings.TrimSpace(rowEntry)
if rowEntry == "" {
continue
}
rowValue, columnsPart, ok := strings.Cut(rowEntry, ":")
if !ok || rowValue == "" || columnsPart == "" {
errs = append(errs, errors.Errorf("invalid matrix default entry %q, expected row:col1,col2", rowEntry))
continue
}
if !rowValues[rowValue] {
errs = append(errs, errors.Errorf("matrix default row %q is not in matrix_config.rows", rowValue))
}
if seenRows[rowValue] {
errs = append(errs, errors.Errorf("duplicate row %q in matrix default", rowValue))
}
seenRows[rowValue] = true
columnCount := 0
for colValue := range strings.SplitSeq(columnsPart, ",") {
colValue = strings.TrimSpace(colValue)
if colValue == "" {
errs = append(errs, errors.Errorf("invalid empty column in matrix default entry %q", rowEntry))
continue
}
columnCount++
if !columnValues[colValue] {
errs = append(errs, errors.Errorf("matrix default column %q is not in matrix_config.columns", colValue))
}
}
if cfg.RowSelection == DialogMatrixRowSelectionSingle && columnCount > 1 {
errs = append(errs, errors.Errorf("matrix default entry %q has multiple columns but row_selection is single", rowEntry))
}
}
return errs
}
// validateRelativePattern validates relative date patterns like +1d, +2w, +1m, +2H, +30M, +90S
// Case-sensitive: d=days, w=weeks, m=months, H=hours, M=minutes, S=seconds
func validateRelativePattern(value string) bool {
if len(value) < 3 || len(value) > 5 || (value[0] != '+' && value[0] != '-') {
return false
}
lastChar := value[len(value)-1]
if !strings.ContainsRune("dwmHMS", rune(lastChar)) {
return false
}
numberPart := value[1 : len(value)-1]
_, err := strconv.Atoi(numberPart)
return err == nil
}
// isValidRelativeFormat checks if a string matches relative date patterns
func isValidRelativeFormat(value string) bool {
relativeFormats := []string{"today", "tomorrow", "yesterday"}
return slices.Contains(relativeFormats, value) || validateRelativePattern(value)
}
// validateDateFormat validates date strings: ISO date, datetime (with warning), or relative formats
func validateDateFormat(dateStr string) error {
if dateStr == "" {
return nil
}
if isValidRelativeFormat(dateStr) {
return nil
}
if _, err := time.Parse(ISODateFormat, dateStr); err == nil {
return nil
}
for _, format := range commonDateTimeFormats {
if parsedTime, err := time.Parse(format, dateStr); err == nil {
dateOnly := parsedTime.Format(ISODateFormat)
return fmt.Errorf("date field received datetime format %q, only date portion %q will be used. Consider using date format instead", dateStr, dateOnly)
}
}
return fmt.Errorf("invalid date format: %q, expected ISO format (YYYY-MM-DD), datetime format, or relative format", dateStr)
}
// validateDateTimeFormat validates datetime strings: ISO datetime or relative formats
func validateDateTimeFormat(dateTimeStr string) error {
if dateTimeStr == "" || isValidRelativeFormat(dateTimeStr) {
return nil
}
for _, format := range commonDateTimeFormats {
if _, err := time.Parse(format, dateTimeStr); err == nil {
return nil
}
}
return fmt.Errorf("invalid datetime format: %q, expected ISO format (YYYY-MM-DDTHH:MM:SSZ) or relative format", dateTimeStr)
}
func validateDateOrDateTimeFormat(value string) error {
dateErr := validateDateFormat(value)
if dateErr == nil {
return nil
}
dateTimeErr := validateDateTimeFormat(value)
if dateTimeErr == nil {
return nil
}
return fmt.Errorf("invalid date or datetime format: %q, expected ISO date (YYYY-MM-DD), datetime (YYYY-MM-DDTHH:MM:SSZ), or relative format", value)
}
func checkMaxLength(fieldName string, field string, maxLength int) error {
// DisplayName and Name are required fields
if fieldName == "DisplayName" || fieldName == "Name" {
if len(field) == 0 {
return errors.Errorf("%v cannot be empty", fieldName)
}
}
if len(field) > maxLength {
return errors.Errorf("%v cannot be longer than %d characters, got %d", fieldName, maxLength, len(field))
}
return nil
}
func (o *Post) StripActionIntegrations() {
attachments := o.Attachments()
if o.GetProp(PostPropsAttachments) != nil {
o.AddProp(PostPropsAttachments, attachments)
}
for _, attachment := range attachments {
for _, action := range attachment.Actions {
action.Integration = nil
}
}
o.StripMmBlocksActionSecrets()
}
func (o *Post) GetAction(id string) *PostAction {
for _, attachment := range o.Attachments() {
for _, action := range attachment.Actions {
if action != nil && action.Id == id {
return action
}
}
}
if spec := o.GetMmBlocksActionSpec(id); spec != nil && spec.Type == MmBlocksActionTypeExternal && spec.URL != "" {
// Synthesize a PostAction so the existing click pipeline can
// dispatch without branching on action source. Pre-merge the
// spec's static per-action query into the URL here; per-click
// query (from DoPostActionRequest.Query) is merged on top by the
// caller via MergeQueryIntoURL, with per-click overriding static
// values on overlapping keys.
url := spec.URL
if len(spec.Query) > 0 {
merged, err := MergeQueryIntoURL(spec.URL, spec.Query)
if err != nil {
// Spec URL is malformed. ValidateMmBlocksActions
// should have rejected it at save time, so this is a
// belt-and-suspenders guard. Returning nil routes the
// caller through the standard "action not found"
// 404 path rather than firing a request to a URL
// that's missing the static query params.
return nil
}
url = merged
}
return &PostAction{
Id: id,
Type: PostActionTypeButton,
Integration: &PostActionIntegration{
URL: url,
Context: spec.Context,
},
}
}
return nil
}
var mmBlocksActionIDRegex = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// ValidateMmBlocksActions verifies the post's mm_blocks_actions prop has the
// expected shape and bounds. Each entry must coerce to a valid spec via
// mmBlocksEntryMapToSpec.
func ValidateMmBlocksActions(o *Post) error {
referenced := CollectInteractiveActionIDsFromPost(o)
raw := o.GetProp(PostPropsMmBlocksActions)
if raw == nil {
if len(referenced) > 0 {
return fmt.Errorf("interactive content requires mm_blocks_actions")
}
return nil
}
actions, ok := coerceToStringAnyMap(raw)
if !ok {
return fmt.Errorf("mm_blocks_actions must be a map")
}
if len(actions) > MaxMmBlocksActionsPerPost {
return fmt.Errorf("mm_blocks_actions exceeds maximum of %d entries", MaxMmBlocksActionsPerPost)
}
for key, entry := range actions {
if len(key) > MaxMmBlocksActionKeyLength {
return fmt.Errorf("mm_blocks_actions key exceeds %d chars", MaxMmBlocksActionKeyLength)
}
if !mmBlocksActionIDRegex.MatchString(key) {
return fmt.Errorf("mm_blocks_actions key %q must contain only letters, numbers, underscores, or hyphens", key)
}
entryMap, ok := coerceToStringAnyMap(entry)
if !ok {
return fmt.Errorf("mm_blocks_actions entry %q must be an object", key)
}
spec := mmBlocksEntryMapToSpec(entryMap)
if spec == nil {
return fmt.Errorf("mm_blocks_actions entry %q has invalid type or shape", key)
}
switch spec.Type {
case MmBlocksActionTypeExternal:
if err := validateIntegrationURL(spec.URL); err != nil {
return fmt.Errorf("mm_blocks_actions entry %q: %w", key, err)
}
// Bound the per-spec static query so a bot cannot stash
// unbounded data in the post that gets merged into the
// outgoing URL on every click.
if err := ValidateActionQuery(spec.Query); err != nil {
return fmt.Errorf("mm_blocks_actions entry %q static query: %w", key, err)
}
// Bound entry count and key length on the static context.
// Values are arbitrary JSON, so size is constrained by the
// outer post-size limit; we cap entries to prevent crafted
// posts from inflating GetAction's clone cost.
if len(spec.Context) > MaxActionQueryEntries {
return fmt.Errorf("mm_blocks_actions entry %q context exceeds maximum of %d entries", key, MaxActionQueryEntries)
}
for k := range spec.Context {
if len(k) > MaxActionQueryKeyLength {
return fmt.Errorf("mm_blocks_actions entry %q context key exceeds %d chars", key, MaxActionQueryKeyLength)
}
}
case MmBlocksActionTypeOpenURL:
if err := validateOpenURL(spec.URL); err != nil {
return fmt.Errorf("mm_blocks_actions entry %q: %w", key, err)
}
if err := ValidateActionQuery(spec.Query); err != nil {
return fmt.Errorf("mm_blocks_actions entry %q static query: %w", key, err)
}
}
}
return validateMmBlocksActionsPairing(o, actions)
}
// ValidateActionQuery bounds the size of user-supplied per-click query
// parameters so a crafted post cannot trigger unbounded memory use in the
// plugin-request path.
func ValidateActionQuery(q map[string]string) error {
if len(q) > MaxActionQueryEntries {
return fmt.Errorf("query exceeds maximum of %d entries", MaxActionQueryEntries)
}
for key, value := range q {
if len(key) > MaxActionQueryKeyLength {
return fmt.Errorf("query key exceeds %d chars", MaxActionQueryKeyLength)
}
if len(value) > MaxActionQueryValueLength {
return fmt.Errorf("query value for %q exceeds %d chars", key, MaxActionQueryValueLength)
}
}
return nil
}
func validateIntegrationURL(rawURL string) error {
if rawURL == "" {
return fmt.Errorf("must have a non-empty URL")
}
if !(strings.HasPrefix(rawURL, "/plugins/") || strings.HasPrefix(rawURL, "plugins/") || IsValidHTTPURL(rawURL)) {
return fmt.Errorf("must have a valid integration URL")
}
// Reject path-traversal segments. /plugins/ URLs are routed by the
// local server, so a `..` segment can escape the plugin namespace and
// hit unrelated server routes. url.Parse decodes percent-encoded path
// bytes into u.Path, which is the same single decode pass that
// doPluginRequest performs at dispatch — so encoded forms like
// %2e%2e%2f are caught here symmetrically with how the router would
// resolve them.
u, parseErr := url.Parse(rawURL)
if parseErr != nil {
return fmt.Errorf("must have a valid integration URL: %w", parseErr)
}
if pathHasTraversalSegment(u.Path) {
return fmt.Errorf("integration URL must not contain path traversal segments")
}
return nil
}
// validateOpenURL bounds openURL goto targets. Relative paths (/) are used
// for in-app navigation via applyIntegrationGotoLocation; http(s) URLs open in
// a new tab. Plugin integration paths are not allowed here — those belong on
// external actions.
func validateOpenURL(rawURL string) error {
if rawURL == "" {
return fmt.Errorf("must have a non-empty URL")
}
if strings.HasPrefix(rawURL, "/") {
if strings.HasPrefix(rawURL, "//") {
return fmt.Errorf("must not be a protocol-relative URL")
}
if strings.HasPrefix(rawURL, "/plugins/") {
return fmt.Errorf("plugin paths are not allowed for openURL actions")
}
u, parseErr := url.Parse(rawURL)
if parseErr != nil {
return fmt.Errorf("must have a valid path: %w", parseErr)
}
if pathHasTraversalSegment(u.Path) {
return fmt.Errorf("must not contain path traversal segments")
}
return nil
}
if !IsValidHTTPURL(rawURL) {
return fmt.Errorf("must have a valid http or https URL, or a path starting with /")
}
return nil
}
func pathHasTraversalSegment(path string) bool {
return strings.Contains(path, "/../") || strings.HasSuffix(path, "/..") ||
path == "/.." || strings.HasPrefix(path, "/..")
}
func (o *Post) GenerateActionIds() {
if o.GetProp(PostPropsAttachments) != nil {
o.AddProp(PostPropsAttachments, o.Attachments())
}
if attachments, ok := o.GetProp(PostPropsAttachments).([]*MessageAttachment); ok {
for _, attachment := range attachments {
for _, action := range attachment.Actions {
if action != nil && action.Id == "" {
action.Id = NewId()
}
}
}
}
}
func AddPostActionCookies(o *Post, secret []byte) *Post {
p := o.Clone()
// retainedProps carry over their value from the old post, including no value
retainProps := map[string]any{}
removeProps := []string{}
for _, key := range PostActionRetainPropKeys {
value, ok := p.GetProps()[key]
if ok {
retainProps[key] = value
} else {
removeProps = append(removeProps, key)
}
}
attachments := p.Attachments()
for _, attachment := range attachments {
for _, action := range attachment.Actions {
c := &PostActionCookie{
Type: action.Type,
ChannelId: p.ChannelId,
DataSource: action.DataSource,
Integration: action.Integration,
RetainProps: retainProps,
RemoveProps: removeProps,
}
c.PostId = p.Id
if p.RootId == "" {
c.RootPostId = p.Id
} else {
c.RootPostId = p.RootId
}
b, _ := json.Marshal(c)
action.Cookie, _ = EncryptPostActionCookie(string(b), secret)
}
}
AddMmBlocksActionCookies(p, secret)
return p
}
// EncryptPostActionCookie encrypts a plaintext post action cookie payload.
// Exposed for testing.
func EncryptPostActionCookie(plain string, secret []byte) (string, error) {
if len(secret) == 0 {
return plain, nil
}
block, err := aes.NewCipher(secret)
if err != nil {
return "", err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, aesgcm.NonceSize())
_, err = io.ReadFull(rand.Reader, nonce)
if err != nil {
return "", err
}
sealed := aesgcm.Seal(nil, nonce, []byte(plain), nil)
combined := append(nonce, sealed...) //nolint:makezero
encoded := make([]byte, base64.StdEncoding.EncodedLen(len(combined)))
base64.StdEncoding.Encode(encoded, combined)
return string(encoded), nil
}
func DecryptPostActionCookie(encoded string, secret []byte) (string, error) {
if len(secret) == 0 {
return encoded, nil
}
block, err := aes.NewCipher(secret)
if err != nil {
return "", err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
decoded := make([]byte, base64.StdEncoding.DecodedLen(len(encoded)))
n, err := base64.StdEncoding.Decode(decoded, []byte(encoded))
if err != nil {
return "", err
}
decoded = decoded[:n]
nonceSize := aesgcm.NonceSize()
if len(decoded) < nonceSize {
return "", fmt.Errorf("cookie too short")
}
nonce, decoded := decoded[:nonceSize], decoded[nonceSize:]
plain, err := aesgcm.Open(nil, nonce, decoded, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
// IsValidLookupURL validates if a URL is safe for lookup operations
func IsValidLookupURL(url string) bool {
if url == "" {
return false
}
// Allow plugin paths that start with /plugins/
if strings.HasPrefix(url, "/plugins/") {
// Additional validation for plugin paths - ensure no path traversal
if strings.Contains(url, "..") || strings.Contains(url, "//") {
return false
}
return true
}
// For external URLs, use the same basic validation as other models
return IsValidHTTPURL(url)
}