Skip to content

Commit db1397f

Browse files
committed
fix(slack): scope channel action tokens
1 parent da81503 commit db1397f

6 files changed

Lines changed: 241 additions & 25 deletions

File tree

docs/2026-04-26-channel-delivery-feedback-architecture.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,9 @@ Slack requirements:
199199
- existing Slack installs must be reauthorized before reaction feedback works
200200
- the provider action/tool surface must expose reaction add/remove operations
201201
when users should be able to ask the agent to react to messages
202+
- provider-action tokens must be scoped to the channel or installation they are
203+
allowed to mutate; a shared gateway token must not authorize arbitrary
204+
caller-supplied Slack channel ids
202205
- reaction failures such as missing scope, already-reacted, or no-reaction must
203206
be logged but must not block runtime delivery
204207
- gateway retries must be idempotent
@@ -242,6 +245,12 @@ endpoint, and the gateway performs the Slack API call with its stored provider
242245
auth. The runtime receives tool success or failure, but it never receives the
243246
Slack bot token.
244247

248+
The gateway must validate both the action token and the requested target. A
249+
valid token should carry authority only for specific Slack team/channel targets,
250+
or for a specific installation whose channel set is resolved by the gateway.
251+
That prevents a runtime attached to one channel from asking the gateway to react
252+
inside another installed channel just because it knows the Slack ids.
253+
245254
The important boundary is:
246255

247256
- automatic delivery feedback is gateway-owned
@@ -343,6 +352,19 @@ the box without adding deployment-specific values:
343352
}
344353
```
345354

355+
Deployments that enable the channel-action MCP server must give the gateway a
356+
matching scoped token binding. The portable single-token form is:
357+
358+
```env
359+
SPRITZ_SLACK_CHANNEL_ACTIONS_TOKEN=change-me
360+
SPRITZ_SLACK_CHANNEL_ACTIONS_TARGETS=T_example:C_example
361+
```
362+
363+
For multiple independent runtimes or owners, deployments should mint distinct
364+
tokens and bind each token only to the team/channel targets that runtime may
365+
mutate. A gateway may also accept structured token bindings, but the same rule
366+
holds: authorization is token plus target, not token alone.
367+
346368
These defaults are safe for Spritz because they are:
347369

348370
- provider-neutral OpenClaw settings

images/examples/openclaw/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ configuration.
118118
The channel-action MCP server expects `SPRITZ_CHANNEL_ACTIONS_BASE_URL` and
119119
`SPRITZ_CHANNEL_ACTIONS_TOKEN` when provider actions should be enabled. It calls
120120
the Spritz channel gateway action API, so provider tokens remain outside the
121-
runtime.
121+
runtime. The gateway side must bind that action token to the provider targets it
122+
may mutate, for example with `SPRITZ_SLACK_CHANNEL_ACTIONS_TARGETS` in the Slack
123+
gateway.
122124

123125
## Spritz Open Integration
124126

integrations/slack-gateway/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ SPRITZ_SLACK_BOT_SCOPES=app_mentions:read,channels:history,chat:write,im:history
1717
SPRITZ_SLACK_ACK_REACTION=eyes
1818
SPRITZ_SLACK_REMOVE_ACK_AFTER_REPLY=true
1919
SPRITZ_SLACK_CHANNEL_ACTIONS_TOKEN=fill-me
20+
SPRITZ_SLACK_CHANNEL_ACTIONS_TARGETS=T_example:C_example
2021
SPRITZ_SLACK_PRESET_ID=zeno
2122

2223
SPRITZ_SLACK_BACKEND_BASE_URL=https://backend.example.com

integrations/slack-gateway/channel_actions.go

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,10 @@ func (g *slackGateway) handleSlackReactionAction(w http.ResponseWriter, r *http.
2121
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "method_not_allowed"})
2222
return
2323
}
24-
if strings.TrimSpace(g.cfg.ChannelActionsToken) == "" {
24+
if len(g.cfg.ChannelActionTokens) == 0 {
2525
writeJSON(w, http.StatusNotFound, map[string]any{"error": "channel_actions_disabled"})
2626
return
2727
}
28-
if !g.authorizeChannelActionRequest(r) {
29-
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
30-
return
31-
}
3228
defer r.Body.Close()
3329
var payload slackReactionActionRequest
3430
decoder := json.NewDecoder(r.Body)
@@ -45,6 +41,10 @@ func (g *slackGateway) handleSlackReactionAction(w http.ResponseWriter, r *http.
4541
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "missing_required_field"})
4642
return
4743
}
44+
if !g.authorizeChannelActionRequest(r, payload.TeamID, payload.ChannelID) {
45+
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
46+
return
47+
}
4848

4949
ctx, cancel := context.WithTimeout(r.Context(), g.cfg.HTTPTimeout)
5050
defer cancel()
@@ -86,16 +86,28 @@ func (g *slackGateway) handleSlackReactionAction(w http.ResponseWriter, r *http.
8686
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
8787
}
8888

89-
func (g *slackGateway) authorizeChannelActionRequest(r *http.Request) bool {
89+
func (g *slackGateway) authorizeChannelActionRequest(r *http.Request, teamID, channelID string) bool {
9090
header := strings.TrimSpace(r.Header.Get("Authorization"))
9191
token, ok := strings.CutPrefix(header, "Bearer ")
9292
if !ok {
9393
return false
9494
}
95-
expected := strings.TrimSpace(g.cfg.ChannelActionsToken)
9695
token = strings.TrimSpace(token)
97-
if token == "" || expected == "" || len(token) != len(expected) {
96+
if token == "" || strings.TrimSpace(teamID) == "" || strings.TrimSpace(channelID) == "" {
9897
return false
9998
}
100-
return subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1
99+
for _, candidate := range g.cfg.ChannelActionTokens {
100+
expected := strings.TrimSpace(candidate.Token)
101+
if expected == "" || len(token) != len(expected) {
102+
continue
103+
}
104+
if subtle.ConstantTimeCompare([]byte(token), []byte(expected)) != 1 {
105+
continue
106+
}
107+
if strings.TrimSpace(candidate.Target.TeamID) == strings.TrimSpace(teamID) &&
108+
strings.TrimSpace(candidate.Target.ChannelID) == strings.TrimSpace(channelID) {
109+
return true
110+
}
111+
}
112+
return false
101113
}

integrations/slack-gateway/config.go

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"net/url"
67
"os"
@@ -21,7 +22,7 @@ type config struct {
2122
SlackBotScopes []string
2223
AckReaction string
2324
RemoveAckAfterReply bool
24-
ChannelActionsToken string
25+
ChannelActionTokens []channelActionToken
2526
PresetID string
2627
BackendBaseURL string
2728
BackendFastAPIBaseURL string
@@ -42,6 +43,16 @@ type config struct {
4243
InstallationPolicyCacheTTL time.Duration
4344
}
4445

46+
type channelActionToken struct {
47+
Token string
48+
Target channelActionTarget
49+
}
50+
51+
type channelActionTarget struct {
52+
TeamID string
53+
ChannelID string
54+
}
55+
4556
func loadConfig() (config, error) {
4657
cfg := config{
4758
Addr: envOrDefault("SPRITZ_SLACK_GATEWAY_ADDR", ":8080"),
@@ -56,7 +67,6 @@ func loadConfig() (config, error) {
5667
SlackBotScopes: splitCSV(envOrDefault("SPRITZ_SLACK_BOT_SCOPES", "app_mentions:read,channels:history,chat:write,im:history,mpim:history,reactions:write")),
5768
AckReaction: normalizeSlackReactionName(envOrDefault("SPRITZ_SLACK_ACK_REACTION", "eyes")),
5869
RemoveAckAfterReply: parseBoolEnv("SPRITZ_SLACK_REMOVE_ACK_AFTER_REPLY", true),
59-
ChannelActionsToken: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_CHANNEL_ACTIONS_TOKEN")),
6070
PresetID: strings.TrimSpace(envOrDefault("SPRITZ_SLACK_PRESET_ID", defaultSlackPresetID)),
6171
BackendBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_BASE_URL")), "/"),
6272
BackendFastAPIBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_FASTAPI_BASE_URL")), "/"),
@@ -76,6 +86,15 @@ func loadConfig() (config, error) {
7686
PromptRetryTimeout: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_TIMEOUT", 8*time.Second),
7787
InstallationPolicyCacheTTL: parseDurationEnv("SPRITZ_SLACK_INSTALLATION_POLICY_CACHE_TTL", 10*time.Second),
7888
}
89+
channelActionTokens, err := parseChannelActionTokens(
90+
os.Getenv("SPRITZ_SLACK_CHANNEL_ACTIONS_TOKEN"),
91+
os.Getenv("SPRITZ_SLACK_CHANNEL_ACTIONS_TARGETS"),
92+
os.Getenv("SPRITZ_SLACK_CHANNEL_ACTIONS_TOKEN_BINDINGS"),
93+
)
94+
if err != nil {
95+
return config{}, err
96+
}
97+
cfg.ChannelActionTokens = channelActionTokens
7998

8099
if cfg.PublicURL == "" {
81100
return config{}, fmt.Errorf("SPRITZ_SLACK_GATEWAY_PUBLIC_URL is required")
@@ -130,6 +149,78 @@ func loadConfig() (config, error) {
130149
return cfg, nil
131150
}
132151

152+
func parseChannelActionTokens(legacyToken, legacyTargets, bindingsRaw string) ([]channelActionToken, error) {
153+
tokens := make([]channelActionToken, 0)
154+
legacyToken = strings.TrimSpace(legacyToken)
155+
for _, rawTarget := range splitCSV(legacyTargets) {
156+
if legacyToken == "" {
157+
return nil, fmt.Errorf("SPRITZ_SLACK_CHANNEL_ACTIONS_TARGETS requires SPRITZ_SLACK_CHANNEL_ACTIONS_TOKEN")
158+
}
159+
target, err := parseChannelActionTarget(rawTarget)
160+
if err != nil {
161+
return nil, fmt.Errorf("SPRITZ_SLACK_CHANNEL_ACTIONS_TARGETS is invalid: %w", err)
162+
}
163+
tokens = append(tokens, channelActionToken{Token: legacyToken, Target: target})
164+
}
165+
166+
bindingsRaw = strings.TrimSpace(bindingsRaw)
167+
if bindingsRaw == "" {
168+
return tokens, nil
169+
}
170+
var bindings []struct {
171+
Token string `json:"token"`
172+
Targets []struct {
173+
TeamID string `json:"teamId"`
174+
ChannelID string `json:"channelId"`
175+
} `json:"targets"`
176+
}
177+
if err := json.Unmarshal([]byte(bindingsRaw), &bindings); err != nil {
178+
return nil, fmt.Errorf("SPRITZ_SLACK_CHANNEL_ACTIONS_TOKEN_BINDINGS is invalid JSON: %w", err)
179+
}
180+
for index, binding := range bindings {
181+
token := strings.TrimSpace(binding.Token)
182+
if token == "" {
183+
return nil, fmt.Errorf("SPRITZ_SLACK_CHANNEL_ACTIONS_TOKEN_BINDINGS[%d].token is required", index)
184+
}
185+
if len(binding.Targets) == 0 {
186+
return nil, fmt.Errorf("SPRITZ_SLACK_CHANNEL_ACTIONS_TOKEN_BINDINGS[%d].targets is required", index)
187+
}
188+
for targetIndex, rawTarget := range binding.Targets {
189+
target := channelActionTarget{
190+
TeamID: strings.TrimSpace(rawTarget.TeamID),
191+
ChannelID: strings.TrimSpace(rawTarget.ChannelID),
192+
}
193+
if target.TeamID == "" || target.ChannelID == "" {
194+
return nil, fmt.Errorf("SPRITZ_SLACK_CHANNEL_ACTIONS_TOKEN_BINDINGS[%d].targets[%d] is invalid", index, targetIndex)
195+
}
196+
tokens = append(tokens, channelActionToken{Token: token, Target: target})
197+
}
198+
}
199+
return tokens, nil
200+
}
201+
202+
func parseChannelActionTarget(raw string) (channelActionTarget, error) {
203+
raw = strings.TrimSpace(raw)
204+
if raw == "" {
205+
return channelActionTarget{}, fmt.Errorf("target is empty")
206+
}
207+
separators := []string{":", "/"}
208+
for _, separator := range separators {
209+
teamID, channelID, ok := strings.Cut(raw, separator)
210+
if ok {
211+
target := channelActionTarget{
212+
TeamID: strings.TrimSpace(teamID),
213+
ChannelID: strings.TrimSpace(channelID),
214+
}
215+
if target.TeamID == "" || target.ChannelID == "" {
216+
return channelActionTarget{}, fmt.Errorf("target %q must be TEAM%sCHANNEL", raw, separator)
217+
}
218+
return target, nil
219+
}
220+
}
221+
return channelActionTarget{}, fmt.Errorf("target %q must be TEAM:CHANNEL or TEAM/CHANNEL", raw)
222+
}
223+
133224
func defaultReactBaseURL(publicURL string, spritzBaseURL string) string {
134225
spritzBaseURL = strings.TrimRight(strings.TrimSpace(spritzBaseURL), "/")
135226
if !isPrivateServiceBaseURL(spritzBaseURL) {

0 commit comments

Comments
 (0)