Files
team/server/public/model/mm_blocks_actions.go
0bb221effa Add inline action buttons for bot-posted markdown (#36219)
* Add inline action buttons for bot-posted markdown

Bots, webhooks, and plugins can now embed clickable action buttons
inside markdown (including table cells) using mmaction://actionId
links, with row-specific parameters forwarded to the integration on
click. This enables use cases like a per-row "Mx Plan" button in a
fleet-status table that opens a dialog scoped to the clicked row.

Design
- New post prop inline_actions maps actionId (alphanumeric) to a
  PostActionIntegration {URL, Context}, capped at 50 entries.
- Markdown link with scheme mmaction:// emits a placeholder span that
  messageHtmlToComponent converts to the InlineActionButton component.
- Click POSTs inline_context (parsed from the URL query string) to the
  existing /posts/{id}/actions/{action_id} endpoint; the server merges
  it into the integration request as context.inline_params while
  preserving the post-level context.
- Only bot, webhook, and plugin posts render the button; non-integration
  posts have inline_actions stripped on create, update, and ephemeral
  broadcast. Hardened-mode also covers the new prop.
- Reuses the existing PostAction dialog pipeline: plugin handlers reply
  with a trigger_id and call /actions/dialogs/open as before.

Security
- InlineContext capped at 50 entries / 128-char keys / 2 KB values.
- Integration Context cloned per click so per-click inline_params and
  selected_option cannot leak into the cached post for other clickers.
- Plugin response updates cannot add inline_actions to a post that did
  not already have them; invalid entries are dropped with a warn log.
- Label content and data attributes are escaped; labels are flattened
  to plain text (tags stripped, entities decoded, then escaped).
- Malformed JSON request bodies now return 400 instead of falling
  through with an empty inline_context.

Tests
- Model: validators, normalization, GetInlineAction, strip, fallback.
- App: create strip, update guard (4 subtests including
  AllowInlineActionsUpdate bypass), ephemeral strip, inline_params
  merge, context-map isolation, plugin-response guards, from_bot and
  from_plugin retention across plugin updates.
- API: inline_context validation (size bounds + error id),
  omitempty backward compat, malformed JSON 400.
- Webapp: renderer scheme handling, allow/deny flags, size caps,
  HTML escape, tag strip, entity decode, attribute-injection defense;
  component click dispatch, double-click race guard, unmount safety,
  error-result recovery, aria state.

* lint fix

* i18n-extract

* Review fixes for inline action buttons

- renderer: preserve actionId case; reject opaque mmaction: URI
- app: require bot AND integration session to preserve inline_actions
- app: restore original inline_actions when plugin response is invalid
- i18n: rename key to ...app_error to match convention

* Tighten UpdatePost inline_actions guard; fix test seeds

- app: UpdatePost now requires AllowInlineActionsUpdate to modify
  inline_actions. Integration session alone is insufficient — a
  PAT-wielding user could otherwise inject inline_actions on any
  post they could edit.
- tests: seed bot posts with inline_actions via an integration
  session (intSeedCtx) so they survive the create-time strip.
- renderer: lint fix (blank line before comment block).

* Reject malformed inline-action authorities at render time

- renderer: enforce ^[A-Za-z0-9]+$ on actionId, mirroring the server
  regex. Authorities like mmaction://plan:443 or mmaction://user@plan
  now fall through to plain text instead of rendering a dead button.
- post: clarify in the strip comment that webhooks and plugins bypass
  CreatePostAsUser entirely (they call CreatePost / CreatePostMissingChannel
  directly), so the strip block does not apply to them.

* Tighten inline-action renderer tests

- Replace oversized-params test with boundary pair (at-cap and over-cap)
  to lock in the > vs >= behavior of the size-limit check.
- Add a "surrounding text survives" assertion for the tag-strip path so
  a future swap from regex strip to a DOM sanitizer won't silently
  drop legitimate content along with tags.

* Inline action buttons via mmaction:// markdown links

Adds inline action buttons rendered from mmaction:// links in markdown,
with the click pipeline reusing the existing post-action infrastructure.
Aligned with the broader mm_blocks_actions framework (Daniel's PR).

* fix lint, DoS hardening, fix and rename test

* Address review feedback

* lint fix

* Reject percent-encoded path traversal in validateIntegrationURL (e.g. %2e%2e%2f) by parsing the URL and checking the decoded path.

---------

Co-authored-by: Mattermost Build <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
2026-05-20 13:34:44 -06:00

155 lines
4.1 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Server-side definitions for the post.props.mm_blocks_actions registry that
// underpins the markdown-actions feature. Mirrors the canonical model
// landing in the broader mm_blocks framework PR; cookie transport
// (MmBlocksActionCookie, AddMmBlocksActionCookies, ParseDecryptedActionCookiePayload)
// is intentionally omitted here and will be filled in by that PR. Until then,
// mm_blocks_actions is resolved on click via DB lookup
// (GetMmBlocksActionSpec) and stripped from ephemeral broadcasts so dead
// buttons don't render.
package model
import (
"encoding/json"
"fmt"
"maps"
"net/url"
)
const (
MmBlocksActionTypeExternal = "external"
)
// MmBlocksActionSpec is the server-side definition for one entry in props.mm_blocks_actions.
type MmBlocksActionSpec struct {
Type string
URL string
Query map[string]string
Context map[string]any
}
// GetMmBlocksActionSpec returns the action definition for actionID from props.mm_blocks_actions, if present.
func (o *Post) GetMmBlocksActionSpec(actionID string) *MmBlocksActionSpec {
raw := o.GetProp(PostPropsMmBlocksActions)
if raw == nil || actionID == "" {
return nil
}
actionsTop, ok := coerceToStringAnyMap(raw)
if !ok {
return nil
}
entry, ok := actionsTop[actionID]
if !ok || entry == nil {
return nil
}
entryMap, ok := coerceToStringAnyMap(entry)
if !ok {
return nil
}
return mmBlocksEntryMapToSpec(entryMap)
}
// mmBlocksEntryMapToSpec maps one props.mm_blocks_actions[actionID] object to MmBlocksActionSpec.
func mmBlocksEntryMapToSpec(entryMap map[string]any) *MmBlocksActionSpec {
typ, _ := entryMap["type"].(string)
if typ == "" {
return nil
}
if typ != MmBlocksActionTypeExternal {
return nil
}
spec := &MmBlocksActionSpec{Type: typ}
spec.URL, _ = entryMap["url"].(string)
spec.Context = contextMapFromProp(entryMap["context"])
spec.Query = stringMapFromPropValue(entryMap["query"])
return spec
}
// MmBlocksContextMap parses a context JSON string or treats a non-JSON string as a single context value.
func MmBlocksContextMap(contextString string) map[string]any {
if contextString == "" {
return nil
}
var m map[string]any
if err := json.Unmarshal([]byte(contextString), &m); err == nil && m != nil {
return m
}
return map[string]any{"context": contextString}
}
// MergeQueryIntoURL merges q into rawURL's query string; existing keys are overwritten by q.
func MergeQueryIntoURL(rawURL string, q map[string]string) (string, error) {
if len(q) == 0 {
return rawURL, nil
}
u, err := url.Parse(rawURL)
if err != nil {
return "", fmt.Errorf("parse url: %w", err)
}
values := u.Query()
for k, v := range q {
values.Set(k, v)
}
u.RawQuery = values.Encode()
return u.String(), nil
}
// StripMmBlocksActionSecrets removes server-only fields from
// props.mm_blocks_actions for wire serialization. The current
// implementation deletes the prop wholesale; the cookie-transport PR will
// extend this to preserve encrypted-string cookie payloads in place.
func (o *Post) StripMmBlocksActionSecrets() {
if o.GetProp(PostPropsMmBlocksActions) == nil {
return
}
o.DelProp(PostPropsMmBlocksActions)
}
// contextMapFromProp normalizes props.mm_blocks_actions[*].context to map[string]any (JSON object or string).
func contextMapFromProp(v any) map[string]any {
if v == nil {
return nil
}
if s, ok := v.(string); ok {
return MmBlocksContextMap(s)
}
if m, ok := coerceToStringAnyMap(v); ok {
// Clone so callers cannot mutate the live post.Props map. A
// nested mutation through the returned map would otherwise race
// with concurrent post.Props readers.
return maps.Clone(m)
}
return nil
}
func stringMapFromPropValue(v any) map[string]string {
m, ok := coerceToStringAnyMap(v)
if !ok || len(m) == 0 {
return nil
}
out := make(map[string]string, len(m))
for k, val := range m {
if s, ok := val.(string); ok {
out[k] = s
}
}
if len(out) == 0 {
return nil
}
return out
}
func coerceToStringAnyMap(v any) (map[string]any, bool) {
if v == nil {
return nil, false
}
m, ok := v.(map[string]any)
if ok {
return m, true
}
return nil, false
}