mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(security): make allowFrom id-only by default with dangerous name opt-in (#24907)
* fix(channels): default allowFrom to id-only; add dangerous name opt-in * docs(security): align channel allowFrom docs with id-only default
This commit is contained in:
committed by
GitHub
parent
41b0568b35
commit
cfa44ea6b4
@@ -95,6 +95,7 @@ OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boun
|
|||||||
- Deployments where mutually untrusted/adversarial operators share one gateway host and config (for example, reports expecting per-operator isolation for `sessions.list`, `sessions.preview`, `chat.history`, or similar control-plane reads)
|
- Deployments where mutually untrusted/adversarial operators share one gateway host and config (for example, reports expecting per-operator isolation for `sessions.list`, `sessions.preview`, `chat.history`, or similar control-plane reads)
|
||||||
- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass)
|
- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass)
|
||||||
- Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`)
|
- Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`)
|
||||||
|
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
|
||||||
- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses.
|
- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses.
|
||||||
- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact
|
- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact
|
||||||
|
|
||||||
|
|||||||
@@ -397,7 +397,8 @@ Example:
|
|||||||
`allowlist` behavior:
|
`allowlist` behavior:
|
||||||
|
|
||||||
- guild must match `channels.discord.guilds` (`id` preferred, slug accepted)
|
- guild must match `channels.discord.guilds` (`id` preferred, slug accepted)
|
||||||
- optional sender allowlists: `users` (IDs or names) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles`
|
- optional sender allowlists: `users` (stable IDs recommended) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles`
|
||||||
|
- direct name/tag matching is disabled by default; enable `channels.discord.dangerouslyAllowNameMatching: true` only as break-glass compatibility mode
|
||||||
- names/tags are supported for `users`, but IDs are safer; `openclaw security audit` warns when name/tag entries are used
|
- names/tags are supported for `users`, but IDs are safer; `openclaw security audit` warns when name/tag entries are used
|
||||||
- if a guild has `channels` configured, non-listed channels are denied
|
- if a guild has `channels` configured, non-listed channels are denied
|
||||||
- if a guild has no `channels` block, all channels in that allowlisted guild are allowed
|
- if a guild has no `channels` block, all channels in that allowlisted guild are allowed
|
||||||
@@ -768,7 +769,7 @@ Default slash command settings:
|
|||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- allowlists can use `pk:<memberId>`
|
- allowlists can use `pk:<memberId>`
|
||||||
- member display names are matched by name/slug
|
- member display names are matched by name/slug only when `channels.discord.dangerouslyAllowNameMatching: true`
|
||||||
- lookups use original message ID and are time-window constrained
|
- lookups use original message ID and are time-window constrained
|
||||||
- if lookup fails, proxied messages are treated as bot messages and dropped unless `allowBots=true`
|
- if lookup fails, proxied messages are treated as bot messages and dropped unless `allowBots=true`
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,8 @@ Configure your tunnel's ingress rules to only route the webhook path:
|
|||||||
|
|
||||||
Use these identifiers for delivery and allowlists:
|
Use these identifiers for delivery and allowlists:
|
||||||
|
|
||||||
- Direct messages: `users/<userId>` (recommended) or raw email `name@example.com` (mutable principal).
|
- Direct messages: `users/<userId>` (recommended).
|
||||||
|
- Raw email `name@example.com` is mutable and only used for direct allowlist matching when `channels.googlechat.dangerouslyAllowNameMatching: true`.
|
||||||
- Deprecated: `users/<email>` is treated as a user id, not an email allowlist.
|
- Deprecated: `users/<email>` is treated as a user id, not an email allowlist.
|
||||||
- Spaces: `spaces/<spaceId>`.
|
- Spaces: `spaces/<spaceId>`.
|
||||||
|
|
||||||
@@ -171,7 +172,7 @@ Use these identifiers for delivery and allowlists:
|
|||||||
botUser: "users/1234567890", // optional; helps mention detection
|
botUser: "users/1234567890", // optional; helps mention detection
|
||||||
dm: {
|
dm: {
|
||||||
policy: "pairing",
|
policy: "pairing",
|
||||||
allowFrom: ["users/1234567890", "name@example.com"],
|
allowFrom: ["users/1234567890"],
|
||||||
},
|
},
|
||||||
groupPolicy: "allowlist",
|
groupPolicy: "allowlist",
|
||||||
groups: {
|
groups: {
|
||||||
@@ -194,6 +195,7 @@ Notes:
|
|||||||
|
|
||||||
- Service account credentials can also be passed inline with `serviceAccount` (JSON string).
|
- Service account credentials can also be passed inline with `serviceAccount` (JSON string).
|
||||||
- Default webhook path is `/googlechat` if `webhookPath` isn’t set.
|
- Default webhook path is `/googlechat` if `webhookPath` isn’t set.
|
||||||
|
- `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode).
|
||||||
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
|
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
|
||||||
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
|
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
|
||||||
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).
|
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ Config keys:
|
|||||||
- Per-channel controls (channel + sender + mention rules): `channels.irc.groups["#channel"]`
|
- Per-channel controls (channel + sender + mention rules): `channels.irc.groups["#channel"]`
|
||||||
- `channels.irc.groupPolicy="open"` allows unconfigured channels (**still mention-gated by default**)
|
- `channels.irc.groupPolicy="open"` allows unconfigured channels (**still mention-gated by default**)
|
||||||
|
|
||||||
Allowlist entries can use nick or `nick!user@host` forms.
|
Allowlist entries should use stable sender identities (`nick!user@host`).
|
||||||
|
Bare nick matching is mutable and only enabled when `channels.irc.dangerouslyAllowNameMatching: true`.
|
||||||
|
|
||||||
### Common gotcha: `allowFrom` is for DMs, not channels
|
### Common gotcha: `allowFrom` is for DMs, not channels
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ Notes:
|
|||||||
## Channels (groups)
|
## Channels (groups)
|
||||||
|
|
||||||
- Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated).
|
- Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated).
|
||||||
- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`).
|
- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs recommended).
|
||||||
|
- `@username` matching is mutable and only enabled when `channels.mattermost.dangerouslyAllowNameMatching: true`.
|
||||||
- Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated).
|
- Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated).
|
||||||
- Runtime note: if `channels.mattermost` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set).
|
- Runtime note: if `channels.mattermost` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set).
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ Disable with:
|
|||||||
**DM access**
|
**DM access**
|
||||||
|
|
||||||
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
|
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
|
||||||
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names. The wizard resolves names to IDs via Microsoft Graph when credentials allow.
|
- `channels.msteams.allowFrom` should use stable AAD object IDs.
|
||||||
|
- UPNs/display names are mutable; direct matching is disabled by default and only enabled with `channels.msteams.dangerouslyAllowNameMatching: true`.
|
||||||
|
- The wizard can resolve names to IDs via Microsoft Graph when credentials allow.
|
||||||
|
|
||||||
**Group access**
|
**Group access**
|
||||||
|
|
||||||
@@ -454,7 +456,8 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
|||||||
- `channels.msteams.webhook.port` (default `3978`)
|
- `channels.msteams.webhook.port` (default `3978`)
|
||||||
- `channels.msteams.webhook.path` (default `/api/messages`)
|
- `channels.msteams.webhook.path` (default `/api/messages`)
|
||||||
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
||||||
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
|
- `channels.msteams.allowFrom`: DM allowlist (AAD object IDs recommended). The wizard resolves names to IDs during setup when Graph access is available.
|
||||||
|
- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching.
|
||||||
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
||||||
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||||
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ For actions/directory reads, user token can be preferred when configured. For wr
|
|||||||
|
|
||||||
- channel allowlist entries and DM allowlist entries are resolved at startup when token access allows
|
- channel allowlist entries and DM allowlist entries are resolved at startup when token access allows
|
||||||
- unresolved entries are kept as configured
|
- unresolved entries are kept as configured
|
||||||
|
- inbound authorization matching is ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true`
|
||||||
|
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|
||||||
@@ -513,6 +514,7 @@ Primary reference:
|
|||||||
High-signal Slack fields:
|
High-signal Slack fields:
|
||||||
- mode/auth: `mode`, `botToken`, `appToken`, `signingSecret`, `webhookPath`, `accounts.*`
|
- mode/auth: `mode`, `botToken`, `appToken`, `signingSecret`, `webhookPath`, `accounts.*`
|
||||||
- DM access: `dm.enabled`, `dmPolicy`, `allowFrom` (legacy: `dm.policy`, `dm.allowFrom`), `dm.groupEnabled`, `dm.groupChannels`
|
- DM access: `dm.enabled`, `dmPolicy`, `allowFrom` (legacy: `dm.policy`, `dm.allowFrom`), `dm.groupEnabled`, `dm.groupChannels`
|
||||||
|
- compatibility toggle: `dangerouslyAllowNameMatching` (break-glass; keep off unless needed)
|
||||||
- channel access: `groupPolicy`, `channels.*`, `channels.*.users`, `channels.*.requireMention`
|
- channel access: `groupPolicy`, `channels.*`, `channels.*.users`, `channels.*.requireMention`
|
||||||
- threading/history: `replyToMode`, `replyToModeByChatType`, `thread.*`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
|
- threading/history: `replyToMode`, `replyToModeByChatType`, `thread.*`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
|
||||||
- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `streaming`, `nativeStreaming`
|
- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `streaming`, `nativeStreaming`
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxie
|
|||||||
It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`.
|
It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`.
|
||||||
It also warns when existing sandbox browser Docker containers have missing/stale hash labels (for example pre-migration containers missing `openclaw.browserConfigEpoch`) and recommends `openclaw sandbox recreate --browser --all`.
|
It also warns when existing sandbox browser Docker containers have missing/stale hash labels (for example pre-migration containers missing `openclaw.browserConfigEpoch`) and recommends `openclaw sandbox recreate --browser --all`.
|
||||||
It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions.
|
It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions.
|
||||||
It warns when Discord allowlists (`channels.discord.allowFrom`, `channels.discord.guilds.*.users`, pairing store) use name or tag entries instead of stable IDs.
|
It warns when channel allowlists rely on mutable names/emails/tags instead of stable IDs (Discord, Slack, Google Chat, MS Teams, Mattermost, IRC scopes where applicable).
|
||||||
It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint).
|
It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint).
|
||||||
|
Settings prefixed with `dangerous`/`dangerously` are explicit break-glass operator overrides; enabling one is not, by itself, a security vulnerability report.
|
||||||
|
|
||||||
## JSON output
|
## JSON output
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
|
|||||||
discord: {
|
discord: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
token: "YOUR_DISCORD_BOT_TOKEN",
|
token: "YOUR_DISCORD_BOT_TOKEN",
|
||||||
dm: { enabled: true, allowFrom: ["steipete"] },
|
dm: { enabled: true, allowFrom: ["123456789012345678"] },
|
||||||
guilds: {
|
guilds: {
|
||||||
"123456789012345678": {
|
"123456789012345678": {
|
||||||
slug: "friends-of-openclaw",
|
slug: "friends-of-openclaw",
|
||||||
@@ -317,7 +317,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
|
|||||||
allowFrom: {
|
allowFrom: {
|
||||||
whatsapp: ["+15555550123"],
|
whatsapp: ["+15555550123"],
|
||||||
telegram: ["123456789"],
|
telegram: ["123456789"],
|
||||||
discord: ["steipete"],
|
discord: ["123456789012345678"],
|
||||||
slack: ["U123"],
|
slack: ["U123"],
|
||||||
signal: ["+15555550123"],
|
signal: ["+15555550123"],
|
||||||
imessage: ["user@example.com"],
|
imessage: ["user@example.com"],
|
||||||
@@ -461,7 +461,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
|
|||||||
discord: {
|
discord: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
token: "YOUR_TOKEN",
|
token: "YOUR_TOKEN",
|
||||||
dm: { allowFrom: ["yourname"] },
|
dm: { allowFrom: ["123456789012345678"] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -487,12 +487,15 @@ If more than one person can DM your bot (multiple entries in `allowFrom`, pairin
|
|||||||
discord: {
|
discord: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
token: "YOUR_DISCORD_BOT_TOKEN",
|
token: "YOUR_DISCORD_BOT_TOKEN",
|
||||||
dm: { enabled: true, allowFrom: ["alice", "bob"] },
|
dm: { enabled: true, allowFrom: ["123456789012345678", "987654321098765432"] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For Discord/Slack/Google Chat/MS Teams/Mattermost/IRC, sender authorization is ID-first by default.
|
||||||
|
Only enable direct mutable name/email/nick matching with each channel's `dangerouslyAllowNameMatching: true` if you explicitly accept that risk.
|
||||||
|
|
||||||
### OAuth with API key failover
|
### OAuth with API key failover
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
|||||||
},
|
},
|
||||||
replyToMode: "off", // off | first | all
|
replyToMode: "off", // off | first | all
|
||||||
dmPolicy: "pairing",
|
dmPolicy: "pairing",
|
||||||
allowFrom: ["1234567890", "steipete"],
|
allowFrom: ["1234567890", "123456789012345678"],
|
||||||
dm: { enabled: true, groupEnabled: false, groupChannels: ["openclaw-dm"] },
|
dm: { enabled: true, groupEnabled: false, groupChannels: ["openclaw-dm"] },
|
||||||
guilds: {
|
guilds: {
|
||||||
"123456789012345678": {
|
"123456789012345678": {
|
||||||
@@ -283,6 +283,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
|||||||
- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers.
|
- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers.
|
||||||
- `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides.
|
- `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides.
|
||||||
- `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated.
|
- `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated.
|
||||||
|
- `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode).
|
||||||
|
|
||||||
**Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from `guilds.<id>.users` on all messages).
|
**Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from `guilds.<id>.users` on all messages).
|
||||||
|
|
||||||
@@ -317,7 +318,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
|||||||
|
|
||||||
- Service account JSON: inline (`serviceAccount`) or file-based (`serviceAccountFile`).
|
- Service account JSON: inline (`serviceAccount`) or file-based (`serviceAccountFile`).
|
||||||
- Env fallbacks: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE`.
|
- Env fallbacks: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE`.
|
||||||
- Use `spaces/<spaceId>` or `users/<userId|email>` for delivery targets.
|
- Use `spaces/<spaceId>` or `users/<userId>` for delivery targets.
|
||||||
|
- `channels.googlechat.dangerouslyAllowNameMatching` re-enables mutable email principal matching (break-glass compatibility mode).
|
||||||
|
|
||||||
### Slack
|
### Slack
|
||||||
|
|
||||||
@@ -1490,7 +1492,7 @@ Controls elevated (host) exec access:
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
allowFrom: {
|
allowFrom: {
|
||||||
whatsapp: ["+15555550123"],
|
whatsapp: ["+15555550123"],
|
||||||
discord: ["steipete", "1234567890123"],
|
discord: ["1234567890123", "987654321098765432"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { isSenderAllowed } from "./monitor.js";
|
import { isSenderAllowed } from "./monitor.js";
|
||||||
|
|
||||||
describe("isSenderAllowed", () => {
|
describe("isSenderAllowed", () => {
|
||||||
it("matches allowlist entries with raw email", () => {
|
it("matches raw email entries only when dangerous name matching is enabled", () => {
|
||||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(true);
|
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(false);
|
||||||
|
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"], true)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not treat users/<email> entries as email allowlist (deprecated form)", () => {
|
it("does not treat users/<email> entries as email allowlist (deprecated form)", () => {
|
||||||
@@ -17,6 +18,8 @@ describe("isSenderAllowed", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects non-matching raw email entries", () => {
|
it("rejects non-matching raw email entries", () => {
|
||||||
expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"])).toBe(false);
|
expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"], true)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ export function isSenderAllowed(
|
|||||||
senderId: string,
|
senderId: string,
|
||||||
senderEmail: string | undefined,
|
senderEmail: string | undefined,
|
||||||
allowFrom: string[],
|
allowFrom: string[],
|
||||||
|
allowNameMatching = false,
|
||||||
) {
|
) {
|
||||||
if (allowFrom.includes("*")) {
|
if (allowFrom.includes("*")) {
|
||||||
return true;
|
return true;
|
||||||
@@ -305,8 +306,8 @@ export function isSenderAllowed(
|
|||||||
return normalizeUserId(withoutPrefix) === normalizedSenderId;
|
return normalizeUserId(withoutPrefix) === normalizedSenderId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw email allowlist entries remain supported for usability.
|
// Raw email allowlist entries are a break-glass override.
|
||||||
if (normalizedEmail && isEmailLike(withoutPrefix)) {
|
if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) {
|
||||||
return withoutPrefix === normalizedEmail;
|
return withoutPrefix === normalizedEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,6 +410,7 @@ async function processMessageWithPipeline(params: {
|
|||||||
const senderId = sender?.name ?? "";
|
const senderId = sender?.name ?? "";
|
||||||
const senderName = sender?.displayName ?? "";
|
const senderName = sender?.displayName ?? "";
|
||||||
const senderEmail = sender?.email ?? undefined;
|
const senderEmail = sender?.email ?? undefined;
|
||||||
|
const allowNameMatching = account.config.dangerouslyAllowNameMatching === true;
|
||||||
|
|
||||||
const allowBots = account.config.allowBots === true;
|
const allowBots = account.config.allowBots === true;
|
||||||
if (!allowBots) {
|
if (!allowBots) {
|
||||||
@@ -489,6 +491,7 @@ async function processMessageWithPipeline(params: {
|
|||||||
senderId,
|
senderId,
|
||||||
senderEmail,
|
senderEmail,
|
||||||
groupUsers.map((v) => String(v)),
|
groupUsers.map((v) => String(v)),
|
||||||
|
allowNameMatching,
|
||||||
);
|
);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`);
|
logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`);
|
||||||
@@ -508,7 +511,12 @@ async function processMessageWithPipeline(params: {
|
|||||||
warnDeprecatedUsersEmailEntries(core, runtime, effectiveAllowFrom);
|
warnDeprecatedUsersEmailEntries(core, runtime, effectiveAllowFrom);
|
||||||
const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom;
|
const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom;
|
||||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||||
const senderAllowedForCommands = isSenderAllowed(senderId, senderEmail, commandAllowFrom);
|
const senderAllowedForCommands = isSenderAllowed(
|
||||||
|
senderId,
|
||||||
|
senderEmail,
|
||||||
|
commandAllowFrom,
|
||||||
|
allowNameMatching,
|
||||||
|
);
|
||||||
const commandAuthorized = shouldComputeAuth
|
const commandAuthorized = shouldComputeAuth
|
||||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const IrcAccountSchemaBase = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
dangerouslyAllowNameMatching: z.boolean().optional(),
|
||||||
host: z.string().optional(),
|
host: z.string().optional(),
|
||||||
port: z.number().int().min(1).max(65535).optional(),
|
port: z.number().int().min(1).max(65535).optional(),
|
||||||
tls: z.boolean().optional(),
|
tls: z.boolean().optional(),
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export async function handleIrcInbound(params: {
|
|||||||
const senderDisplay = message.senderHost
|
const senderDisplay = message.senderHost
|
||||||
? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}`
|
? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}`
|
||||||
: message.senderNick;
|
: message.senderNick;
|
||||||
|
const allowNameMatching = account.config.dangerouslyAllowNameMatching === true;
|
||||||
|
|
||||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
|
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
|
||||||
@@ -132,6 +133,7 @@ export async function handleIrcInbound(params: {
|
|||||||
const senderAllowedForCommands = resolveIrcAllowlistMatch({
|
const senderAllowedForCommands = resolveIrcAllowlistMatch({
|
||||||
allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
|
allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
|
||||||
message,
|
message,
|
||||||
|
allowNameMatching,
|
||||||
}).allowed;
|
}).allowed;
|
||||||
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
|
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
|
||||||
const commandGate = resolveControlCommandGate({
|
const commandGate = resolveControlCommandGate({
|
||||||
@@ -153,6 +155,7 @@ export async function handleIrcInbound(params: {
|
|||||||
message,
|
message,
|
||||||
outerAllowFrom: effectiveGroupAllowFrom,
|
outerAllowFrom: effectiveGroupAllowFrom,
|
||||||
innerAllowFrom: groupAllowFrom,
|
innerAllowFrom: groupAllowFrom,
|
||||||
|
allowNameMatching,
|
||||||
});
|
});
|
||||||
if (!senderAllowed) {
|
if (!senderAllowed) {
|
||||||
runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
|
runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
|
||||||
@@ -167,6 +170,7 @@ export async function handleIrcInbound(params: {
|
|||||||
const dmAllowed = resolveIrcAllowlistMatch({
|
const dmAllowed = resolveIrcAllowlistMatch({
|
||||||
allowFrom: effectiveAllowFrom,
|
allowFrom: effectiveAllowFrom,
|
||||||
message,
|
message,
|
||||||
|
allowNameMatching,
|
||||||
}).allowed;
|
}).allowed;
|
||||||
if (!dmAllowed) {
|
if (!dmAllowed) {
|
||||||
if (dmPolicy === "pairing") {
|
if (dmPolicy === "pairing") {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ describe("irc normalize", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org");
|
expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org");
|
||||||
|
expect(buildIrcAllowlistCandidates(message)).not.toContain("alice");
|
||||||
|
expect(buildIrcAllowlistCandidates(message, { allowNameMatching: true })).toContain("alice");
|
||||||
expect(
|
expect(
|
||||||
resolveIrcAllowlistMatch({
|
resolveIrcAllowlistMatch({
|
||||||
allowFrom: ["alice!ident@example.org"],
|
allowFrom: ["alice!ident@example.org"],
|
||||||
@@ -38,9 +40,16 @@ describe("irc normalize", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
resolveIrcAllowlistMatch({
|
resolveIrcAllowlistMatch({
|
||||||
allowFrom: ["bob"],
|
allowFrom: ["alice"],
|
||||||
message,
|
message,
|
||||||
}).allowed,
|
}).allowed,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
resolveIrcAllowlistMatch({
|
||||||
|
allowFrom: ["alice"],
|
||||||
|
message,
|
||||||
|
allowNameMatching: true,
|
||||||
|
}).allowed,
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,12 +77,15 @@ export function formatIrcSenderId(message: IrcInboundMessage): string {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[] {
|
export function buildIrcAllowlistCandidates(
|
||||||
|
message: IrcInboundMessage,
|
||||||
|
params?: { allowNameMatching?: boolean },
|
||||||
|
): string[] {
|
||||||
const nick = message.senderNick.trim().toLowerCase();
|
const nick = message.senderNick.trim().toLowerCase();
|
||||||
const user = message.senderUser?.trim().toLowerCase();
|
const user = message.senderUser?.trim().toLowerCase();
|
||||||
const host = message.senderHost?.trim().toLowerCase();
|
const host = message.senderHost?.trim().toLowerCase();
|
||||||
const candidates = new Set<string>();
|
const candidates = new Set<string>();
|
||||||
if (nick) {
|
if (nick && params?.allowNameMatching === true) {
|
||||||
candidates.add(nick);
|
candidates.add(nick);
|
||||||
}
|
}
|
||||||
if (nick && user) {
|
if (nick && user) {
|
||||||
@@ -100,6 +103,7 @@ export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[
|
|||||||
export function resolveIrcAllowlistMatch(params: {
|
export function resolveIrcAllowlistMatch(params: {
|
||||||
allowFrom: string[];
|
allowFrom: string[];
|
||||||
message: IrcInboundMessage;
|
message: IrcInboundMessage;
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}): { allowed: boolean; source?: string } {
|
}): { allowed: boolean; source?: string } {
|
||||||
const allowFrom = new Set(
|
const allowFrom = new Set(
|
||||||
params.allowFrom.map((entry) => entry.trim().toLowerCase()).filter(Boolean),
|
params.allowFrom.map((entry) => entry.trim().toLowerCase()).filter(Boolean),
|
||||||
@@ -107,7 +111,9 @@ export function resolveIrcAllowlistMatch(params: {
|
|||||||
if (allowFrom.has("*")) {
|
if (allowFrom.has("*")) {
|
||||||
return { allowed: true, source: "wildcard" };
|
return { allowed: true, source: "wildcard" };
|
||||||
}
|
}
|
||||||
const candidates = buildIrcAllowlistCandidates(params.message);
|
const candidates = buildIrcAllowlistCandidates(params.message, {
|
||||||
|
allowNameMatching: params.allowNameMatching,
|
||||||
|
});
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (allowFrom.has(candidate)) {
|
if (allowFrom.has(candidate)) {
|
||||||
return { allowed: true, source: candidate };
|
return { allowed: true, source: candidate };
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ describe("irc policy", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveIrcGroupSenderAllowed({
|
||||||
|
groupPolicy: "allowlist",
|
||||||
|
message,
|
||||||
|
outerAllowFrom: ["alice!ident@example.org"],
|
||||||
|
innerAllowFrom: [],
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
resolveIrcGroupSenderAllowed({
|
resolveIrcGroupSenderAllowed({
|
||||||
groupPolicy: "allowlist",
|
groupPolicy: "allowlist",
|
||||||
@@ -57,6 +65,15 @@ describe("irc policy", () => {
|
|||||||
outerAllowFrom: ["alice"],
|
outerAllowFrom: ["alice"],
|
||||||
innerAllowFrom: [],
|
innerAllowFrom: [],
|
||||||
}),
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
resolveIrcGroupSenderAllowed({
|
||||||
|
groupPolicy: "allowlist",
|
||||||
|
message,
|
||||||
|
outerAllowFrom: ["alice"],
|
||||||
|
innerAllowFrom: [],
|
||||||
|
allowNameMatching: true,
|
||||||
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -142,16 +142,25 @@ export function resolveIrcGroupSenderAllowed(params: {
|
|||||||
message: IrcInboundMessage;
|
message: IrcInboundMessage;
|
||||||
outerAllowFrom: string[];
|
outerAllowFrom: string[];
|
||||||
innerAllowFrom: string[];
|
innerAllowFrom: string[];
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const policy = params.groupPolicy ?? "allowlist";
|
const policy = params.groupPolicy ?? "allowlist";
|
||||||
const inner = normalizeIrcAllowlist(params.innerAllowFrom);
|
const inner = normalizeIrcAllowlist(params.innerAllowFrom);
|
||||||
const outer = normalizeIrcAllowlist(params.outerAllowFrom);
|
const outer = normalizeIrcAllowlist(params.outerAllowFrom);
|
||||||
|
|
||||||
if (inner.length > 0) {
|
if (inner.length > 0) {
|
||||||
return resolveIrcAllowlistMatch({ allowFrom: inner, message: params.message }).allowed;
|
return resolveIrcAllowlistMatch({
|
||||||
|
allowFrom: inner,
|
||||||
|
message: params.message,
|
||||||
|
allowNameMatching: params.allowNameMatching,
|
||||||
|
}).allowed;
|
||||||
}
|
}
|
||||||
if (outer.length > 0) {
|
if (outer.length > 0) {
|
||||||
return resolveIrcAllowlistMatch({ allowFrom: outer, message: params.message }).allowed;
|
return resolveIrcAllowlistMatch({
|
||||||
|
allowFrom: outer,
|
||||||
|
message: params.message,
|
||||||
|
allowNameMatching: params.allowNameMatching,
|
||||||
|
}).allowed;
|
||||||
}
|
}
|
||||||
return policy === "open";
|
return policy === "open";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export type IrcNickServConfig = {
|
|||||||
export type IrcAccountConfig = {
|
export type IrcAccountConfig = {
|
||||||
name?: string;
|
name?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/**
|
||||||
|
* Break-glass override: allow nick-only allowlist matching.
|
||||||
|
* Default behavior requires host/user-qualified identities.
|
||||||
|
*/
|
||||||
|
dangerouslyAllowNameMatching?: boolean;
|
||||||
host?: string;
|
host?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
tls?: boolean;
|
tls?: boolean;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const MattermostAccountSchemaBase = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
|
dangerouslyAllowNameMatching: z.boolean().optional(),
|
||||||
markdown: MarkdownConfigSchema,
|
markdown: MarkdownConfigSchema,
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ function isSenderAllowed(params: {
|
|||||||
senderId: string;
|
senderId: string;
|
||||||
senderName?: string;
|
senderName?: string;
|
||||||
allowFrom: string[];
|
allowFrom: string[];
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const allowFrom = params.allowFrom;
|
const allowFrom = params.allowFrom;
|
||||||
if (allowFrom.length === 0) {
|
if (allowFrom.length === 0) {
|
||||||
@@ -162,10 +163,15 @@ function isSenderAllowed(params: {
|
|||||||
}
|
}
|
||||||
const normalizedSenderId = normalizeAllowEntry(params.senderId);
|
const normalizedSenderId = normalizeAllowEntry(params.senderId);
|
||||||
const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
|
const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
|
||||||
return allowFrom.some(
|
return allowFrom.some((entry) => {
|
||||||
(entry) =>
|
if (entry === normalizedSenderId) {
|
||||||
entry === normalizedSenderId || (normalizedSenderName && entry === normalizedSenderName),
|
return true;
|
||||||
);
|
}
|
||||||
|
if (params.allowNameMatching !== true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return normalizedSenderName ? entry === normalizedSenderName : false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type MattermostMediaInfo = {
|
type MattermostMediaInfo = {
|
||||||
@@ -206,6 +212,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
cfg,
|
cfg,
|
||||||
accountId: opts.accountId,
|
accountId: opts.accountId,
|
||||||
});
|
});
|
||||||
|
const allowNameMatching = account.config.dangerouslyAllowNameMatching === true;
|
||||||
const botToken = opts.botToken?.trim() || account.botToken?.trim();
|
const botToken = opts.botToken?.trim() || account.botToken?.trim();
|
||||||
if (!botToken) {
|
if (!botToken) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -416,11 +423,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
senderId,
|
senderId,
|
||||||
senderName,
|
senderName,
|
||||||
allowFrom: effectiveAllowFrom,
|
allowFrom: effectiveAllowFrom,
|
||||||
|
allowNameMatching,
|
||||||
});
|
});
|
||||||
const groupAllowedForCommands = isSenderAllowed({
|
const groupAllowedForCommands = isSenderAllowed({
|
||||||
senderId,
|
senderId,
|
||||||
senderName,
|
senderName,
|
||||||
allowFrom: effectiveGroupAllowFrom,
|
allowFrom: effectiveGroupAllowFrom,
|
||||||
|
allowNameMatching,
|
||||||
});
|
});
|
||||||
const commandGate = resolveControlCommandGate({
|
const commandGate = resolveControlCommandGate({
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
@@ -892,6 +901,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
senderId: userId,
|
senderId: userId,
|
||||||
senderName,
|
senderName,
|
||||||
allowFrom: effectiveAllowFrom,
|
allowFrom: effectiveAllowFrom,
|
||||||
|
allowNameMatching,
|
||||||
});
|
});
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
logVerboseMessage(
|
logVerboseMessage(
|
||||||
@@ -927,6 +937,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
senderId: userId,
|
senderId: userId,
|
||||||
senderName,
|
senderName,
|
||||||
allowFrom: effectiveGroupAllowFrom,
|
allowFrom: effectiveGroupAllowFrom,
|
||||||
|
allowNameMatching,
|
||||||
});
|
});
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
logVerboseMessage(`mattermost: drop reaction (groupPolicy=allowlist sender=${userId})`);
|
logVerboseMessage(`mattermost: drop reaction (groupPolicy=allowlist sender=${userId})`);
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export type MattermostAccountConfig = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
|
/**
|
||||||
|
* Break-glass override: allow mutable identity matching (@username/display name) in allowlists.
|
||||||
|
* Default behavior is ID-only matching.
|
||||||
|
*/
|
||||||
|
dangerouslyAllowNameMatching?: boolean;
|
||||||
/** Allow channel-initiated config writes (default: true). */
|
/** Allow channel-initiated config writes (default: true). */
|
||||||
configWrites?: boolean;
|
configWrites?: boolean;
|
||||||
/** If false, do not start this Mattermost account. Default: true. */
|
/** If false, do not start this Mattermost account. Default: true. */
|
||||||
|
|||||||
@@ -145,10 +145,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
|
|
||||||
if (dmPolicy !== "open") {
|
if (dmPolicy !== "open") {
|
||||||
const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom];
|
const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom];
|
||||||
|
const allowNameMatching = msteamsCfg.dangerouslyAllowNameMatching === true;
|
||||||
const allowMatch = resolveMSTeamsAllowlistMatch({
|
const allowMatch = resolveMSTeamsAllowlistMatch({
|
||||||
allowFrom: effectiveAllowFrom,
|
allowFrom: effectiveAllowFrom,
|
||||||
senderId,
|
senderId,
|
||||||
senderName,
|
senderName,
|
||||||
|
allowNameMatching,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!allowMatch.allowed) {
|
if (!allowMatch.allowed) {
|
||||||
@@ -226,10 +228,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (effectiveGroupAllowFrom.length > 0) {
|
if (effectiveGroupAllowFrom.length > 0) {
|
||||||
|
const allowNameMatching = msteamsCfg.dangerouslyAllowNameMatching === true;
|
||||||
const allowMatch = resolveMSTeamsAllowlistMatch({
|
const allowMatch = resolveMSTeamsAllowlistMatch({
|
||||||
allowFrom: effectiveGroupAllowFrom,
|
allowFrom: effectiveGroupAllowFrom,
|
||||||
senderId,
|
senderId,
|
||||||
senderName,
|
senderName,
|
||||||
|
allowNameMatching,
|
||||||
});
|
});
|
||||||
if (!allowMatch.allowed) {
|
if (!allowMatch.allowed) {
|
||||||
log.debug?.("dropping group message (not in groupAllowFrom)", {
|
log.debug?.("dropping group message (not in groupAllowFrom)", {
|
||||||
@@ -248,12 +252,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
allowFrom: effectiveDmAllowFrom,
|
allowFrom: effectiveDmAllowFrom,
|
||||||
senderId,
|
senderId,
|
||||||
senderName,
|
senderName,
|
||||||
|
allowNameMatching: msteamsCfg?.dangerouslyAllowNameMatching === true,
|
||||||
});
|
});
|
||||||
const groupAllowedForCommands = isMSTeamsGroupAllowed({
|
const groupAllowedForCommands = isMSTeamsGroupAllowed({
|
||||||
groupPolicy: "allowlist",
|
groupPolicy: "allowlist",
|
||||||
allowFrom: effectiveGroupAllowFrom,
|
allowFrom: effectiveGroupAllowFrom,
|
||||||
senderId,
|
senderId,
|
||||||
senderName,
|
senderName,
|
||||||
|
allowNameMatching: msteamsCfg?.dangerouslyAllowNameMatching === true,
|
||||||
});
|
});
|
||||||
const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg);
|
const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg);
|
||||||
const commandGate = resolveControlCommandGate({
|
const commandGate = resolveControlCommandGate({
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export function resolveMSTeamsAllowlistMatch(params: {
|
|||||||
allowFrom: Array<string | number>;
|
allowFrom: Array<string | number>;
|
||||||
senderId: string;
|
senderId: string;
|
||||||
senderName?: string | null;
|
senderName?: string | null;
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}): MSTeamsAllowlistMatch {
|
}): MSTeamsAllowlistMatch {
|
||||||
return resolveAllowlistMatchSimple(params);
|
return resolveAllowlistMatchSimple(params);
|
||||||
}
|
}
|
||||||
@@ -245,6 +246,7 @@ export function isMSTeamsGroupAllowed(params: {
|
|||||||
allowFrom: Array<string | number>;
|
allowFrom: Array<string | number>;
|
||||||
senderId: string;
|
senderId: string;
|
||||||
senderName?: string | null;
|
senderName?: string | null;
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const { groupPolicy } = params;
|
const { groupPolicy } = params;
|
||||||
if (groupPolicy === "disabled") {
|
if (groupPolicy === "disabled") {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function resolveAllowlistMatchSimple(params: {
|
|||||||
allowFrom: Array<string | number>;
|
allowFrom: Array<string | number>;
|
||||||
senderId: string;
|
senderId: string;
|
||||||
senderName?: string | null;
|
senderName?: string | null;
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}): AllowlistMatch<"wildcard" | "id" | "name"> {
|
}): AllowlistMatch<"wildcard" | "id" | "name"> {
|
||||||
const allowFrom = params.allowFrom
|
const allowFrom = params.allowFrom
|
||||||
.map((entry) => String(entry).trim().toLowerCase())
|
.map((entry) => String(entry).trim().toLowerCase())
|
||||||
@@ -44,7 +45,7 @@ export function resolveAllowlistMatchSimple(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const senderName = params.senderName?.toLowerCase();
|
const senderName = params.senderName?.toLowerCase();
|
||||||
if (senderName && allowFrom.includes(senderName)) {
|
if (params.allowNameMatching === true && senderName && allowFrom.includes(senderName)) {
|
||||||
return { allowed: true, matchKey: senderName, matchSource: "name" };
|
return { allowed: true, matchKey: senderName, matchSource: "name" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -578,6 +578,400 @@ function maybeRepairDiscordNumericIds(cfg: OpenClawConfig): {
|
|||||||
return { config: next, changes };
|
return { config: next, changes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MutableAllowlistHit = {
|
||||||
|
channel: string;
|
||||||
|
path: string;
|
||||||
|
entry: string;
|
||||||
|
dangerousFlagPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function collectProviderAccountScopes(
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
provider: string,
|
||||||
|
): Array<{ prefix: string; account: Record<string, unknown> }> {
|
||||||
|
const scopes: Array<{ prefix: string; account: Record<string, unknown> }> = [];
|
||||||
|
const channels = asObjectRecord(cfg.channels);
|
||||||
|
if (!channels) {
|
||||||
|
return scopes;
|
||||||
|
}
|
||||||
|
const providerCfg = asObjectRecord(channels[provider]);
|
||||||
|
if (!providerCfg) {
|
||||||
|
return scopes;
|
||||||
|
}
|
||||||
|
scopes.push({ prefix: `channels.${provider}`, account: providerCfg });
|
||||||
|
const accounts = asObjectRecord(providerCfg.accounts);
|
||||||
|
if (!accounts) {
|
||||||
|
return scopes;
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(accounts)) {
|
||||||
|
const account = asObjectRecord(accounts[key]);
|
||||||
|
if (!account) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
scopes.push({ prefix: `channels.${provider}.accounts.${key}`, account });
|
||||||
|
}
|
||||||
|
return scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDiscordMutableAllowEntry(raw: string): boolean {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (!text || text === "*") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const maybeMentionId = text.replace(/^<@!?/, "").replace(/>$/, "");
|
||||||
|
if (/^\d+$/.test(maybeMentionId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const prefix of ["discord:", "user:", "pk:"]) {
|
||||||
|
if (!text.startsWith(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return text.slice(prefix.length).trim().length === 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSlackMutableAllowEntry(raw: string): boolean {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (!text || text === "*") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const mentionMatch = text.match(/^<@([A-Z0-9]+)>$/i);
|
||||||
|
if (mentionMatch && /^[A-Z0-9]{8,}$/i.test(mentionMatch[1] ?? "")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const withoutPrefix = text.replace(/^(slack|user):/i, "").trim();
|
||||||
|
if (/^[UWBCGDT][A-Z0-9]{2,}$/.test(withoutPrefix)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^[A-Z0-9]{8,}$/i.test(withoutPrefix)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGoogleChatMutableAllowEntry(raw: string): boolean {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (!text || text === "*") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const withoutPrefix = text.replace(/^(googlechat|google-chat|gchat):/i, "").trim();
|
||||||
|
if (!withoutPrefix) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const withoutUsers = withoutPrefix.replace(/^users\//i, "");
|
||||||
|
return withoutUsers.includes("@");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMSTeamsMutableAllowEntry(raw: string): boolean {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (!text || text === "*") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const withoutPrefix = text.replace(/^(msteams|user):/i, "").trim();
|
||||||
|
return /\s/.test(withoutPrefix) || withoutPrefix.includes("@");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMattermostMutableAllowEntry(raw: string): boolean {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (!text || text === "*") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const normalized = text
|
||||||
|
.replace(/^(mattermost|user):/i, "")
|
||||||
|
.replace(/^@/, "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
// Mattermost user IDs are stable 26-char lowercase/number tokens.
|
||||||
|
if (/^[a-z0-9]{26}$/.test(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIrcMutableAllowEntry(raw: string): boolean {
|
||||||
|
const text = raw.trim().toLowerCase();
|
||||||
|
if (!text || text === "*") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const normalized = text
|
||||||
|
.replace(/^irc:/, "")
|
||||||
|
.replace(/^user:/, "")
|
||||||
|
.trim();
|
||||||
|
return !normalized.includes("!") && !normalized.includes("@");
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMutableAllowlistHits(params: {
|
||||||
|
hits: MutableAllowlistHit[];
|
||||||
|
pathLabel: string;
|
||||||
|
list: unknown;
|
||||||
|
detector: (entry: string) => boolean;
|
||||||
|
channel: string;
|
||||||
|
dangerousFlagPath: string;
|
||||||
|
}) {
|
||||||
|
if (!Array.isArray(params.list)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of params.list) {
|
||||||
|
const text = String(entry).trim();
|
||||||
|
if (!text || text === "*") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!params.detector(text)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
params.hits.push({
|
||||||
|
channel: params.channel,
|
||||||
|
path: params.pathLabel,
|
||||||
|
entry: text,
|
||||||
|
dangerousFlagPath: params.dangerousFlagPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanMutableAllowlistEntries(cfg: OpenClawConfig): MutableAllowlistHit[] {
|
||||||
|
const hits: MutableAllowlistHit[] = [];
|
||||||
|
|
||||||
|
for (const scope of collectProviderAccountScopes(cfg, "discord")) {
|
||||||
|
const dangerousFlagPath = `${scope.prefix}.dangerouslyAllowNameMatching`;
|
||||||
|
if (scope.account.dangerouslyAllowNameMatching === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.allowFrom`,
|
||||||
|
list: scope.account.allowFrom,
|
||||||
|
detector: isDiscordMutableAllowEntry,
|
||||||
|
channel: "discord",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
const dm = asObjectRecord(scope.account.dm);
|
||||||
|
if (dm) {
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.dm.allowFrom`,
|
||||||
|
list: dm.allowFrom,
|
||||||
|
detector: isDiscordMutableAllowEntry,
|
||||||
|
channel: "discord",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const guilds = asObjectRecord(scope.account.guilds);
|
||||||
|
if (!guilds) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [guildId, guildRaw] of Object.entries(guilds)) {
|
||||||
|
const guild = asObjectRecord(guildRaw);
|
||||||
|
if (!guild) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.guilds.${guildId}.users`,
|
||||||
|
list: guild.users,
|
||||||
|
detector: isDiscordMutableAllowEntry,
|
||||||
|
channel: "discord",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
const channels = asObjectRecord(guild.channels);
|
||||||
|
if (!channels) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [channelId, channelRaw] of Object.entries(channels)) {
|
||||||
|
const channel = asObjectRecord(channelRaw);
|
||||||
|
if (!channel) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.guilds.${guildId}.channels.${channelId}.users`,
|
||||||
|
list: channel.users,
|
||||||
|
detector: isDiscordMutableAllowEntry,
|
||||||
|
channel: "discord",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const scope of collectProviderAccountScopes(cfg, "slack")) {
|
||||||
|
const dangerousFlagPath = `${scope.prefix}.dangerouslyAllowNameMatching`;
|
||||||
|
if (scope.account.dangerouslyAllowNameMatching === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.allowFrom`,
|
||||||
|
list: scope.account.allowFrom,
|
||||||
|
detector: isSlackMutableAllowEntry,
|
||||||
|
channel: "slack",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
const dm = asObjectRecord(scope.account.dm);
|
||||||
|
if (dm) {
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.dm.allowFrom`,
|
||||||
|
list: dm.allowFrom,
|
||||||
|
detector: isSlackMutableAllowEntry,
|
||||||
|
channel: "slack",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const channels = asObjectRecord(scope.account.channels);
|
||||||
|
if (!channels) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [channelKey, channelRaw] of Object.entries(channels)) {
|
||||||
|
const channel = asObjectRecord(channelRaw);
|
||||||
|
if (!channel) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.channels.${channelKey}.users`,
|
||||||
|
list: channel.users,
|
||||||
|
detector: isSlackMutableAllowEntry,
|
||||||
|
channel: "slack",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const scope of collectProviderAccountScopes(cfg, "googlechat")) {
|
||||||
|
const dangerousFlagPath = `${scope.prefix}.dangerouslyAllowNameMatching`;
|
||||||
|
if (scope.account.dangerouslyAllowNameMatching === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.groupAllowFrom`,
|
||||||
|
list: scope.account.groupAllowFrom,
|
||||||
|
detector: isGoogleChatMutableAllowEntry,
|
||||||
|
channel: "googlechat",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
const dm = asObjectRecord(scope.account.dm);
|
||||||
|
if (dm) {
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.dm.allowFrom`,
|
||||||
|
list: dm.allowFrom,
|
||||||
|
detector: isGoogleChatMutableAllowEntry,
|
||||||
|
channel: "googlechat",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const groups = asObjectRecord(scope.account.groups);
|
||||||
|
if (!groups) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [groupKey, groupRaw] of Object.entries(groups)) {
|
||||||
|
const group = asObjectRecord(groupRaw);
|
||||||
|
if (!group) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.groups.${groupKey}.users`,
|
||||||
|
list: group.users,
|
||||||
|
detector: isGoogleChatMutableAllowEntry,
|
||||||
|
channel: "googlechat",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const scope of collectProviderAccountScopes(cfg, "msteams")) {
|
||||||
|
const dangerousFlagPath = `${scope.prefix}.dangerouslyAllowNameMatching`;
|
||||||
|
if (scope.account.dangerouslyAllowNameMatching === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.allowFrom`,
|
||||||
|
list: scope.account.allowFrom,
|
||||||
|
detector: isMSTeamsMutableAllowEntry,
|
||||||
|
channel: "msteams",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.groupAllowFrom`,
|
||||||
|
list: scope.account.groupAllowFrom,
|
||||||
|
detector: isMSTeamsMutableAllowEntry,
|
||||||
|
channel: "msteams",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const scope of collectProviderAccountScopes(cfg, "mattermost")) {
|
||||||
|
const dangerousFlagPath = `${scope.prefix}.dangerouslyAllowNameMatching`;
|
||||||
|
if (scope.account.dangerouslyAllowNameMatching === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.allowFrom`,
|
||||||
|
list: scope.account.allowFrom,
|
||||||
|
detector: isMattermostMutableAllowEntry,
|
||||||
|
channel: "mattermost",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.groupAllowFrom`,
|
||||||
|
list: scope.account.groupAllowFrom,
|
||||||
|
detector: isMattermostMutableAllowEntry,
|
||||||
|
channel: "mattermost",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const scope of collectProviderAccountScopes(cfg, "irc")) {
|
||||||
|
const dangerousFlagPath = `${scope.prefix}.dangerouslyAllowNameMatching`;
|
||||||
|
if (scope.account.dangerouslyAllowNameMatching === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.allowFrom`,
|
||||||
|
list: scope.account.allowFrom,
|
||||||
|
detector: isIrcMutableAllowEntry,
|
||||||
|
channel: "irc",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.groupAllowFrom`,
|
||||||
|
list: scope.account.groupAllowFrom,
|
||||||
|
detector: isIrcMutableAllowEntry,
|
||||||
|
channel: "irc",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
const groups = asObjectRecord(scope.account.groups);
|
||||||
|
if (!groups) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [groupKey, groupRaw] of Object.entries(groups)) {
|
||||||
|
const group = asObjectRecord(groupRaw);
|
||||||
|
if (!group) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addMutableAllowlistHits({
|
||||||
|
hits,
|
||||||
|
pathLabel: `${scope.prefix}.groups.${groupKey}.allowFrom`,
|
||||||
|
list: group.allowFrom,
|
||||||
|
detector: isIrcMutableAllowEntry,
|
||||||
|
channel: "irc",
|
||||||
|
dangerousFlagPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan all channel configs for dmPolicy="open" without allowFrom including "*".
|
* Scan all channel configs for dmPolicy="open" without allowFrom including "*".
|
||||||
* This configuration is rejected by the schema validator but can easily occur when
|
* This configuration is rejected by the schema validator but can easily occur when
|
||||||
@@ -1209,6 +1603,34 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mutableAllowlistHits = scanMutableAllowlistEntries(candidate);
|
||||||
|
if (mutableAllowlistHits.length > 0) {
|
||||||
|
const channels = Array.from(new Set(mutableAllowlistHits.map((hit) => hit.channel))).toSorted();
|
||||||
|
const exampleLines = mutableAllowlistHits
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((hit) => `- ${hit.path}: ${hit.entry}`)
|
||||||
|
.join("\n");
|
||||||
|
const remaining =
|
||||||
|
mutableAllowlistHits.length > 8
|
||||||
|
? `- +${mutableAllowlistHits.length - 8} more mutable allowlist entries.`
|
||||||
|
: null;
|
||||||
|
const flagPaths = Array.from(new Set(mutableAllowlistHits.map((hit) => hit.dangerousFlagPath)));
|
||||||
|
const flagHint =
|
||||||
|
flagPaths.length === 1
|
||||||
|
? flagPaths[0]
|
||||||
|
: `${flagPaths[0]} (and ${flagPaths.length - 1} other scope flags)`;
|
||||||
|
note(
|
||||||
|
[
|
||||||
|
`- Found ${mutableAllowlistHits.length} mutable allowlist ${mutableAllowlistHits.length === 1 ? "entry" : "entries"} across ${channels.join(", ")} while name matching is disabled by default.`,
|
||||||
|
exampleLines,
|
||||||
|
...(remaining ? [remaining] : []),
|
||||||
|
`- Option A (break-glass): enable ${flagHint}=true to keep name/email/nick matching.`,
|
||||||
|
"- Option B (recommended): resolve names/emails/nicks to stable sender IDs and rewrite the allowlist entries.",
|
||||||
|
].join("\n"),
|
||||||
|
"Doctor warnings",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const unknown = stripUnknownConfigKeys(candidate);
|
const unknown = stripUnknownConfigKeys(candidate);
|
||||||
if (unknown.removed.length > 0) {
|
if (unknown.removed.length > 0) {
|
||||||
const lines = unknown.removed.map((path) => `- ${path}`).join("\n");
|
const lines = unknown.removed.map((path) => `- ${path}`).join("\n");
|
||||||
|
|||||||
@@ -184,6 +184,11 @@ export type DiscordAccountConfig = {
|
|||||||
proxy?: string;
|
proxy?: string;
|
||||||
/** Allow bot-authored messages to trigger replies (default: false). */
|
/** Allow bot-authored messages to trigger replies (default: false). */
|
||||||
allowBots?: boolean;
|
allowBots?: boolean;
|
||||||
|
/**
|
||||||
|
* Break-glass override: allow mutable identity matching (names/tags/slugs) in allowlists.
|
||||||
|
* Default behavior is ID-only matching.
|
||||||
|
*/
|
||||||
|
dangerouslyAllowNameMatching?: boolean;
|
||||||
/**
|
/**
|
||||||
* Controls how guild channel messages are handled:
|
* Controls how guild channel messages are handled:
|
||||||
* - "open": guild channels bypass allowlists; mention-gating applies
|
* - "open": guild channels bypass allowlists; mention-gating applies
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ export type GoogleChatAccountConfig = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Allow bot-authored messages to trigger replies (default: false). */
|
/** Allow bot-authored messages to trigger replies (default: false). */
|
||||||
allowBots?: boolean;
|
allowBots?: boolean;
|
||||||
|
/**
|
||||||
|
* Break-glass override: allow mutable principal matching (raw email entries) in allowlists.
|
||||||
|
* Default behavior is ID-only matching.
|
||||||
|
*/
|
||||||
|
dangerouslyAllowNameMatching?: boolean;
|
||||||
/** Default mention requirement for space messages (default: true). */
|
/** Default mention requirement for space messages (default: true). */
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export type MSTeamsConfig = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
|
/**
|
||||||
|
* Break-glass override: allow mutable identity matching (display names/UPNs) in allowlists.
|
||||||
|
* Default behavior is ID-only matching.
|
||||||
|
*/
|
||||||
|
dangerouslyAllowNameMatching?: boolean;
|
||||||
/** Markdown formatting overrides (tables). */
|
/** Markdown formatting overrides (tables). */
|
||||||
markdown?: MarkdownConfig;
|
markdown?: MarkdownConfig;
|
||||||
/** Allow channel-initiated config writes (default: true). */
|
/** Allow channel-initiated config writes (default: true). */
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ export type SlackAccountConfig = {
|
|||||||
userTokenReadOnly?: boolean;
|
userTokenReadOnly?: boolean;
|
||||||
/** Allow bot-authored messages to trigger replies (default: false). */
|
/** Allow bot-authored messages to trigger replies (default: false). */
|
||||||
allowBots?: boolean;
|
allowBots?: boolean;
|
||||||
|
/**
|
||||||
|
* Break-glass override: allow mutable identity matching (name/slug) in allowlists.
|
||||||
|
* Default behavior is ID-only matching.
|
||||||
|
*/
|
||||||
|
dangerouslyAllowNameMatching?: boolean;
|
||||||
/** Default mention requirement for channel messages (default: true). */
|
/** Default mention requirement for channel messages (default: true). */
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ export const DiscordAccountSchema = z
|
|||||||
token: z.string().optional().register(sensitive),
|
token: z.string().optional().register(sensitive),
|
||||||
proxy: z.string().optional(),
|
proxy: z.string().optional(),
|
||||||
allowBots: z.boolean().optional(),
|
allowBots: z.boolean().optional(),
|
||||||
|
dangerouslyAllowNameMatching: z.boolean().optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||||
historyLimit: z.number().int().min(0).optional(),
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||||
@@ -516,6 +517,7 @@ export const GoogleChatAccountSchema = z
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
allowBots: z.boolean().optional(),
|
allowBots: z.boolean().optional(),
|
||||||
|
dangerouslyAllowNameMatching: z.boolean().optional(),
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
@@ -612,6 +614,7 @@ export const SlackAccountSchema = z
|
|||||||
userToken: z.string().optional().register(sensitive),
|
userToken: z.string().optional().register(sensitive),
|
||||||
userTokenReadOnly: z.boolean().optional().default(true),
|
userTokenReadOnly: z.boolean().optional().default(true),
|
||||||
allowBots: z.boolean().optional(),
|
allowBots: z.boolean().optional(),
|
||||||
|
dangerouslyAllowNameMatching: z.boolean().optional(),
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional(),
|
groupPolicy: GroupPolicySchema.optional(),
|
||||||
historyLimit: z.number().int().min(0).optional(),
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
@@ -1059,6 +1062,7 @@ export const MSTeamsConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
|
dangerouslyAllowNameMatching: z.boolean().optional(),
|
||||||
markdown: MarkdownConfigSchema,
|
markdown: MarkdownConfigSchema,
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
appId: z.string().optional(),
|
appId: z.string().optional(),
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ describe("discord allowlist helpers", () => {
|
|||||||
expect(normalizeDiscordSlug("Dev__Chat")).toBe("dev-chat");
|
expect(normalizeDiscordSlug("Dev__Chat")).toBe("dev-chat");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches ids or names", () => {
|
it("matches ids by default and names only when enabled", () => {
|
||||||
const allow = normalizeDiscordAllowList(
|
const allow = normalizeDiscordAllowList(
|
||||||
["123", "steipete", "Friends of OpenClaw"],
|
["123", "steipete", "Friends of OpenClaw"],
|
||||||
["discord:", "user:", "guild:", "channel:"],
|
["discord:", "user:", "guild:", "channel:"],
|
||||||
@@ -194,8 +194,12 @@ describe("discord allowlist helpers", () => {
|
|||||||
throw new Error("Expected allow list to be normalized");
|
throw new Error("Expected allow list to be normalized");
|
||||||
}
|
}
|
||||||
expect(allowListMatches(allow, { id: "123" })).toBe(true);
|
expect(allowListMatches(allow, { id: "123" })).toBe(true);
|
||||||
expect(allowListMatches(allow, { name: "steipete" })).toBe(true);
|
expect(allowListMatches(allow, { name: "steipete" })).toBe(false);
|
||||||
expect(allowListMatches(allow, { name: "friends-of-openclaw" })).toBe(true);
|
expect(allowListMatches(allow, { name: "friends-of-openclaw" })).toBe(false);
|
||||||
|
expect(allowListMatches(allow, { name: "steipete" }, { allowNameMatching: true })).toBe(true);
|
||||||
|
expect(
|
||||||
|
allowListMatches(allow, { name: "friends-of-openclaw" }, { allowNameMatching: true }),
|
||||||
|
).toBe(true);
|
||||||
expect(allowListMatches(allow, { name: "other" })).toBe(false);
|
expect(allowListMatches(allow, { name: "other" })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -750,6 +754,31 @@ describe("discord reaction notification gating", () => {
|
|||||||
},
|
},
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist mode does not match usernames by default",
|
||||||
|
input: {
|
||||||
|
mode: "allowlist" as const,
|
||||||
|
botId: "bot-1",
|
||||||
|
messageAuthorId: "user-1",
|
||||||
|
userId: "999",
|
||||||
|
userName: "trusted-user",
|
||||||
|
allowlist: ["trusted-user"] as string[],
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist mode matches usernames when explicitly enabled",
|
||||||
|
input: {
|
||||||
|
mode: "allowlist" as const,
|
||||||
|
botId: "bot-1",
|
||||||
|
messageAuthorId: "user-1",
|
||||||
|
userId: "999",
|
||||||
|
userName: "trusted-user",
|
||||||
|
allowlist: ["trusted-user"] as string[],
|
||||||
|
allowNameMatching: true,
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for (const testCase of cases) {
|
for (const testCase of cases) {
|
||||||
@@ -870,6 +899,7 @@ function makeReactionClient(options?: {
|
|||||||
|
|
||||||
function makeReactionListenerParams(overrides?: {
|
function makeReactionListenerParams(overrides?: {
|
||||||
botUserId?: string;
|
botUserId?: string;
|
||||||
|
allowNameMatching?: boolean;
|
||||||
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
@@ -877,6 +907,7 @@ function makeReactionListenerParams(overrides?: {
|
|||||||
accountId: "acc-1",
|
accountId: "acc-1",
|
||||||
runtime: {} as import("../runtime.js").RuntimeEnv,
|
runtime: {} as import("../runtime.js").RuntimeEnv,
|
||||||
botUserId: overrides?.botUserId ?? "bot-1",
|
botUserId: overrides?.botUserId ?? "bot-1",
|
||||||
|
allowNameMatching: overrides?.allowNameMatching ?? false,
|
||||||
guildEntries: overrides?.guildEntries,
|
guildEntries: overrides?.guildEntries,
|
||||||
logger: {
|
logger: {
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ async function ensureGuildComponentMemberAllowed(params: {
|
|||||||
replyOpts: { ephemeral?: boolean };
|
replyOpts: { ephemeral?: boolean };
|
||||||
componentLabel: string;
|
componentLabel: string;
|
||||||
unauthorizedReply: string;
|
unauthorizedReply: string;
|
||||||
|
allowNameMatching: boolean;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const {
|
const {
|
||||||
interaction,
|
interaction,
|
||||||
@@ -275,6 +276,7 @@ async function ensureGuildComponentMemberAllowed(params: {
|
|||||||
name: user.username,
|
name: user.username,
|
||||||
tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
|
tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
|
||||||
},
|
},
|
||||||
|
allowNameMatching: params.allowNameMatching,
|
||||||
});
|
});
|
||||||
if (memberAllowed) {
|
if (memberAllowed) {
|
||||||
return true;
|
return true;
|
||||||
@@ -299,6 +301,7 @@ async function ensureComponentUserAllowed(params: {
|
|||||||
replyOpts: { ephemeral?: boolean };
|
replyOpts: { ephemeral?: boolean };
|
||||||
componentLabel: string;
|
componentLabel: string;
|
||||||
unauthorizedReply: string;
|
unauthorizedReply: string;
|
||||||
|
allowNameMatching: boolean;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [
|
const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [
|
||||||
"discord:",
|
"discord:",
|
||||||
@@ -315,6 +318,7 @@ async function ensureComponentUserAllowed(params: {
|
|||||||
name: params.user.username,
|
name: params.user.username,
|
||||||
tag: formatDiscordUserTag(params.user),
|
tag: formatDiscordUserTag(params.user),
|
||||||
},
|
},
|
||||||
|
allowNameMatching: params.allowNameMatching,
|
||||||
});
|
});
|
||||||
if (match.allowed) {
|
if (match.allowed) {
|
||||||
return true;
|
return true;
|
||||||
@@ -361,6 +365,7 @@ async function ensureAgentComponentInteractionAllowed(params: {
|
|||||||
replyOpts: params.replyOpts,
|
replyOpts: params.replyOpts,
|
||||||
componentLabel: params.componentLabel,
|
componentLabel: params.componentLabel,
|
||||||
unauthorizedReply: params.unauthorizedReply,
|
unauthorizedReply: params.unauthorizedReply,
|
||||||
|
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
|
||||||
});
|
});
|
||||||
if (!memberAllowed) {
|
if (!memberAllowed) {
|
||||||
return null;
|
return null;
|
||||||
@@ -476,6 +481,7 @@ async function ensureDmComponentAuthorized(params: {
|
|||||||
name: user.username,
|
name: user.username,
|
||||||
tag: formatDiscordUserTag(user),
|
tag: formatDiscordUserTag(user),
|
||||||
},
|
},
|
||||||
|
allowNameMatching: ctx.discordConfig?.dangerouslyAllowNameMatching === true,
|
||||||
})
|
})
|
||||||
: { allowed: false };
|
: { allowed: false };
|
||||||
if (allowMatch.allowed) {
|
if (allowMatch.allowed) {
|
||||||
@@ -778,6 +784,7 @@ async function dispatchDiscordComponentEvent(params: {
|
|||||||
channelConfig,
|
channelConfig,
|
||||||
guildInfo,
|
guildInfo,
|
||||||
sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag },
|
sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag },
|
||||||
|
allowNameMatching: ctx.discordConfig?.dangerouslyAllowNameMatching === true,
|
||||||
});
|
});
|
||||||
const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId });
|
const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId });
|
||||||
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
|
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
|
||||||
@@ -975,6 +982,7 @@ async function handleDiscordComponentEvent(params: {
|
|||||||
replyOpts,
|
replyOpts,
|
||||||
componentLabel: params.componentLabel,
|
componentLabel: params.componentLabel,
|
||||||
unauthorizedReply,
|
unauthorizedReply,
|
||||||
|
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
|
||||||
});
|
});
|
||||||
if (!memberAllowed) {
|
if (!memberAllowed) {
|
||||||
return;
|
return;
|
||||||
@@ -987,6 +995,7 @@ async function handleDiscordComponentEvent(params: {
|
|||||||
replyOpts,
|
replyOpts,
|
||||||
componentLabel: params.componentLabel,
|
componentLabel: params.componentLabel,
|
||||||
unauthorizedReply,
|
unauthorizedReply,
|
||||||
|
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
|
||||||
});
|
});
|
||||||
if (!componentAllowed) {
|
if (!componentAllowed) {
|
||||||
return;
|
return;
|
||||||
@@ -1125,6 +1134,7 @@ async function handleDiscordModalTrigger(params: {
|
|||||||
replyOpts,
|
replyOpts,
|
||||||
componentLabel: "form",
|
componentLabel: "form",
|
||||||
unauthorizedReply,
|
unauthorizedReply,
|
||||||
|
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
|
||||||
});
|
});
|
||||||
if (!memberAllowed) {
|
if (!memberAllowed) {
|
||||||
return;
|
return;
|
||||||
@@ -1137,6 +1147,7 @@ async function handleDiscordModalTrigger(params: {
|
|||||||
replyOpts,
|
replyOpts,
|
||||||
componentLabel: "form",
|
componentLabel: "form",
|
||||||
unauthorizedReply,
|
unauthorizedReply,
|
||||||
|
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
|
||||||
});
|
});
|
||||||
if (!componentAllowed) {
|
if (!componentAllowed) {
|
||||||
return;
|
return;
|
||||||
@@ -1572,6 +1583,7 @@ class DiscordComponentModal extends Modal {
|
|||||||
replyOpts,
|
replyOpts,
|
||||||
componentLabel: "form",
|
componentLabel: "form",
|
||||||
unauthorizedReply: "You are not authorized to use this form.",
|
unauthorizedReply: "You are not authorized to use this form.",
|
||||||
|
allowNameMatching: this.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
|
||||||
});
|
});
|
||||||
if (!memberAllowed) {
|
if (!memberAllowed) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export function normalizeDiscordSlug(value: string) {
|
|||||||
export function allowListMatches(
|
export function allowListMatches(
|
||||||
list: DiscordAllowList,
|
list: DiscordAllowList,
|
||||||
candidate: { id?: string; name?: string; tag?: string },
|
candidate: { id?: string; name?: string; tag?: string },
|
||||||
|
params?: { allowNameMatching?: boolean },
|
||||||
) {
|
) {
|
||||||
if (list.allowAll) {
|
if (list.allowAll) {
|
||||||
return true;
|
return true;
|
||||||
@@ -105,12 +106,14 @@ export function allowListMatches(
|
|||||||
if (candidate.id && list.ids.has(candidate.id)) {
|
if (candidate.id && list.ids.has(candidate.id)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const slug = candidate.name ? normalizeDiscordSlug(candidate.name) : "";
|
if (params?.allowNameMatching === true) {
|
||||||
if (slug && list.names.has(slug)) {
|
const slug = candidate.name ? normalizeDiscordSlug(candidate.name) : "";
|
||||||
return true;
|
if (slug && list.names.has(slug)) {
|
||||||
}
|
return true;
|
||||||
if (candidate.tag && list.names.has(normalizeDiscordSlug(candidate.tag))) {
|
}
|
||||||
return true;
|
if (candidate.tag && list.names.has(normalizeDiscordSlug(candidate.tag))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -118,6 +121,7 @@ export function allowListMatches(
|
|||||||
export function resolveDiscordAllowListMatch(params: {
|
export function resolveDiscordAllowListMatch(params: {
|
||||||
allowList: DiscordAllowList;
|
allowList: DiscordAllowList;
|
||||||
candidate: { id?: string; name?: string; tag?: string };
|
candidate: { id?: string; name?: string; tag?: string };
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}): DiscordAllowListMatch {
|
}): DiscordAllowListMatch {
|
||||||
const { allowList, candidate } = params;
|
const { allowList, candidate } = params;
|
||||||
if (allowList.allowAll) {
|
if (allowList.allowAll) {
|
||||||
@@ -126,13 +130,15 @@ export function resolveDiscordAllowListMatch(params: {
|
|||||||
if (candidate.id && allowList.ids.has(candidate.id)) {
|
if (candidate.id && allowList.ids.has(candidate.id)) {
|
||||||
return { allowed: true, matchKey: candidate.id, matchSource: "id" };
|
return { allowed: true, matchKey: candidate.id, matchSource: "id" };
|
||||||
}
|
}
|
||||||
const nameSlug = candidate.name ? normalizeDiscordSlug(candidate.name) : "";
|
if (params.allowNameMatching === true) {
|
||||||
if (nameSlug && allowList.names.has(nameSlug)) {
|
const nameSlug = candidate.name ? normalizeDiscordSlug(candidate.name) : "";
|
||||||
return { allowed: true, matchKey: nameSlug, matchSource: "name" };
|
if (nameSlug && allowList.names.has(nameSlug)) {
|
||||||
}
|
return { allowed: true, matchKey: nameSlug, matchSource: "name" };
|
||||||
const tagSlug = candidate.tag ? normalizeDiscordSlug(candidate.tag) : "";
|
}
|
||||||
if (tagSlug && allowList.names.has(tagSlug)) {
|
const tagSlug = candidate.tag ? normalizeDiscordSlug(candidate.tag) : "";
|
||||||
return { allowed: true, matchKey: tagSlug, matchSource: "tag" };
|
if (tagSlug && allowList.names.has(tagSlug)) {
|
||||||
|
return { allowed: true, matchKey: tagSlug, matchSource: "tag" };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { allowed: false };
|
return { allowed: false };
|
||||||
}
|
}
|
||||||
@@ -142,16 +148,21 @@ export function resolveDiscordUserAllowed(params: {
|
|||||||
userId: string;
|
userId: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
userTag?: string;
|
userTag?: string;
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const allowList = normalizeDiscordAllowList(params.allowList, ["discord:", "user:", "pk:"]);
|
const allowList = normalizeDiscordAllowList(params.allowList, ["discord:", "user:", "pk:"]);
|
||||||
if (!allowList) {
|
if (!allowList) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return allowListMatches(allowList, {
|
return allowListMatches(
|
||||||
id: params.userId,
|
allowList,
|
||||||
name: params.userName,
|
{
|
||||||
tag: params.userTag,
|
id: params.userId,
|
||||||
});
|
name: params.userName,
|
||||||
|
tag: params.userTag,
|
||||||
|
},
|
||||||
|
{ allowNameMatching: params.allowNameMatching },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDiscordRoleAllowed(params: {
|
export function resolveDiscordRoleAllowed(params: {
|
||||||
@@ -176,6 +187,7 @@ export function resolveDiscordMemberAllowed(params: {
|
|||||||
userId: string;
|
userId: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
userTag?: string;
|
userTag?: string;
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const hasUserRestriction = Array.isArray(params.userAllowList) && params.userAllowList.length > 0;
|
const hasUserRestriction = Array.isArray(params.userAllowList) && params.userAllowList.length > 0;
|
||||||
const hasRoleRestriction = Array.isArray(params.roleAllowList) && params.roleAllowList.length > 0;
|
const hasRoleRestriction = Array.isArray(params.roleAllowList) && params.roleAllowList.length > 0;
|
||||||
@@ -188,6 +200,7 @@ export function resolveDiscordMemberAllowed(params: {
|
|||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
userName: params.userName,
|
userName: params.userName,
|
||||||
userTag: params.userTag,
|
userTag: params.userTag,
|
||||||
|
allowNameMatching: params.allowNameMatching,
|
||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
const roleOk = hasRoleRestriction
|
const roleOk = hasRoleRestriction
|
||||||
@@ -204,6 +217,7 @@ export function resolveDiscordMemberAccessState(params: {
|
|||||||
guildInfo?: DiscordGuildEntryResolved | null;
|
guildInfo?: DiscordGuildEntryResolved | null;
|
||||||
memberRoleIds: string[];
|
memberRoleIds: string[];
|
||||||
sender: { id: string; name?: string; tag?: string };
|
sender: { id: string; name?: string; tag?: string };
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const channelUsers = params.channelConfig?.users ?? params.guildInfo?.users;
|
const channelUsers = params.channelConfig?.users ?? params.guildInfo?.users;
|
||||||
const channelRoles = params.channelConfig?.roles ?? params.guildInfo?.roles;
|
const channelRoles = params.channelConfig?.roles ?? params.guildInfo?.roles;
|
||||||
@@ -217,6 +231,7 @@ export function resolveDiscordMemberAccessState(params: {
|
|||||||
userId: params.sender.id,
|
userId: params.sender.id,
|
||||||
userName: params.sender.name,
|
userName: params.sender.name,
|
||||||
userTag: params.sender.tag,
|
userTag: params.sender.tag,
|
||||||
|
allowNameMatching: params.allowNameMatching,
|
||||||
});
|
});
|
||||||
return { channelUsers, channelRoles, hasAccessRestrictions, memberAllowed } as const;
|
return { channelUsers, channelRoles, hasAccessRestrictions, memberAllowed } as const;
|
||||||
}
|
}
|
||||||
@@ -225,6 +240,7 @@ export function resolveDiscordOwnerAllowFrom(params: {
|
|||||||
channelConfig?: DiscordChannelConfigResolved | null;
|
channelConfig?: DiscordChannelConfigResolved | null;
|
||||||
guildInfo?: DiscordGuildEntryResolved | null;
|
guildInfo?: DiscordGuildEntryResolved | null;
|
||||||
sender: { id: string; name?: string; tag?: string };
|
sender: { id: string; name?: string; tag?: string };
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}): string[] | undefined {
|
}): string[] | undefined {
|
||||||
const rawAllowList = params.channelConfig?.users ?? params.guildInfo?.users;
|
const rawAllowList = params.channelConfig?.users ?? params.guildInfo?.users;
|
||||||
if (!Array.isArray(rawAllowList) || rawAllowList.length === 0) {
|
if (!Array.isArray(rawAllowList) || rawAllowList.length === 0) {
|
||||||
@@ -241,6 +257,7 @@ export function resolveDiscordOwnerAllowFrom(params: {
|
|||||||
name: params.sender.name,
|
name: params.sender.name,
|
||||||
tag: params.sender.tag,
|
tag: params.sender.tag,
|
||||||
},
|
},
|
||||||
|
allowNameMatching: params.allowNameMatching,
|
||||||
});
|
});
|
||||||
if (!match.allowed || !match.matchKey || match.matchKey === "*") {
|
if (!match.allowed || !match.matchKey || match.matchKey === "*") {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -253,6 +270,7 @@ export function resolveDiscordCommandAuthorized(params: {
|
|||||||
allowFrom?: string[];
|
allowFrom?: string[];
|
||||||
guildInfo?: DiscordGuildEntryResolved | null;
|
guildInfo?: DiscordGuildEntryResolved | null;
|
||||||
author: User;
|
author: User;
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (!params.isDirectMessage) {
|
if (!params.isDirectMessage) {
|
||||||
return true;
|
return true;
|
||||||
@@ -261,11 +279,15 @@ export function resolveDiscordCommandAuthorized(params: {
|
|||||||
if (!allowList) {
|
if (!allowList) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return allowListMatches(allowList, {
|
return allowListMatches(
|
||||||
id: params.author.id,
|
allowList,
|
||||||
name: params.author.username,
|
{
|
||||||
tag: formatDiscordUserTag(params.author),
|
id: params.author.id,
|
||||||
});
|
name: params.author.username,
|
||||||
|
tag: formatDiscordUserTag(params.author),
|
||||||
|
},
|
||||||
|
{ allowNameMatching: params.allowNameMatching },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDiscordGuildEntry(params: {
|
export function resolveDiscordGuildEntry(params: {
|
||||||
@@ -501,6 +523,7 @@ export function shouldEmitDiscordReactionNotification(params: {
|
|||||||
userName?: string;
|
userName?: string;
|
||||||
userTag?: string;
|
userTag?: string;
|
||||||
allowlist?: string[];
|
allowlist?: string[];
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const mode = params.mode ?? "own";
|
const mode = params.mode ?? "own";
|
||||||
if (mode === "off") {
|
if (mode === "off") {
|
||||||
@@ -517,11 +540,15 @@ export function shouldEmitDiscordReactionNotification(params: {
|
|||||||
if (!list) {
|
if (!list) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return allowListMatches(list, {
|
return allowListMatches(
|
||||||
id: params.userId,
|
list,
|
||||||
name: params.userName,
|
{
|
||||||
tag: params.userTag,
|
id: params.userId,
|
||||||
});
|
name: params.userName,
|
||||||
|
tag: params.userTag,
|
||||||
|
},
|
||||||
|
{ allowNameMatching: params.allowNameMatching },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type DiscordReactionListenerParams = {
|
|||||||
accountId: string;
|
accountId: string;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
botUserId?: string;
|
botUserId?: string;
|
||||||
|
allowNameMatching: boolean;
|
||||||
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
|
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
};
|
};
|
||||||
@@ -178,6 +179,7 @@ async function runDiscordReactionHandler(params: {
|
|||||||
cfg: params.handlerParams.cfg,
|
cfg: params.handlerParams.cfg,
|
||||||
accountId: params.handlerParams.accountId,
|
accountId: params.handlerParams.accountId,
|
||||||
botUserId: params.handlerParams.botUserId,
|
botUserId: params.handlerParams.botUserId,
|
||||||
|
allowNameMatching: params.handlerParams.allowNameMatching,
|
||||||
guildEntries: params.handlerParams.guildEntries,
|
guildEntries: params.handlerParams.guildEntries,
|
||||||
logger: params.handlerParams.logger,
|
logger: params.handlerParams.logger,
|
||||||
}),
|
}),
|
||||||
@@ -191,6 +193,7 @@ async function handleDiscordReactionEvent(params: {
|
|||||||
cfg: LoadedConfig;
|
cfg: LoadedConfig;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
botUserId?: string;
|
botUserId?: string;
|
||||||
|
allowNameMatching: boolean;
|
||||||
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
|
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}) {
|
}) {
|
||||||
@@ -292,6 +295,7 @@ async function handleDiscordReactionEvent(params: {
|
|||||||
userName: user.username,
|
userName: user.username,
|
||||||
userTag: formatDiscordUserTag(user),
|
userTag: formatDiscordUserTag(user),
|
||||||
allowlist: guildInfo?.users,
|
allowlist: guildInfo?.users,
|
||||||
|
allowNameMatching: params.allowNameMatching,
|
||||||
});
|
});
|
||||||
const emitReactionWithAuthor = (message: { author?: User } | null) => {
|
const emitReactionWithAuthor = (message: { author?: User } | null) => {
|
||||||
const { baseText } = resolveReactionBase();
|
const { baseText } = resolveReactionBase();
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export async function preflightDiscordMessage(
|
|||||||
name: sender.name,
|
name: sender.name,
|
||||||
tag: sender.tag,
|
tag: sender.tag,
|
||||||
},
|
},
|
||||||
|
allowNameMatching: params.discordConfig?.dangerouslyAllowNameMatching === true,
|
||||||
})
|
})
|
||||||
: { allowed: false };
|
: { allowed: false };
|
||||||
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
||||||
@@ -563,6 +564,7 @@ export async function preflightDiscordMessage(
|
|||||||
guildInfo,
|
guildInfo,
|
||||||
memberRoleIds,
|
memberRoleIds,
|
||||||
sender,
|
sender,
|
||||||
|
allowNameMatching: params.discordConfig?.dangerouslyAllowNameMatching === true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isDirectMessage) {
|
if (!isDirectMessage) {
|
||||||
@@ -572,11 +574,15 @@ export async function preflightDiscordMessage(
|
|||||||
"pk:",
|
"pk:",
|
||||||
]);
|
]);
|
||||||
const ownerOk = ownerAllowList
|
const ownerOk = ownerAllowList
|
||||||
? allowListMatches(ownerAllowList, {
|
? allowListMatches(
|
||||||
id: sender.id,
|
ownerAllowList,
|
||||||
name: sender.name,
|
{
|
||||||
tag: sender.tag,
|
id: sender.id,
|
||||||
})
|
name: sender.name,
|
||||||
|
tag: sender.tag,
|
||||||
|
},
|
||||||
|
{ allowNameMatching: params.discordConfig?.dangerouslyAllowNameMatching === true },
|
||||||
|
)
|
||||||
: false;
|
: false;
|
||||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||||
const commandGate = resolveControlCommandGate({
|
const commandGate = resolveControlCommandGate({
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
channelConfig,
|
channelConfig,
|
||||||
guildInfo,
|
guildInfo,
|
||||||
sender: { id: sender.id, name: sender.name, tag: sender.tag },
|
sender: { id: sender.id, name: sender.name, tag: sender.tag },
|
||||||
|
allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true,
|
||||||
});
|
});
|
||||||
const storePath = resolveStorePath(cfg.session?.store, {
|
const storePath = resolveStorePath(cfg.session?.store, {
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ describe("agent components", () => {
|
|||||||
const select = createAgentSelectMenu({
|
const select = createAgentSelectMenu({
|
||||||
cfg: createCfg(),
|
cfg: createCfg(),
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
|
discordConfig: { dangerouslyAllowNameMatching: true } as DiscordAccountConfig,
|
||||||
dmPolicy: "allowlist",
|
dmPolicy: "allowlist",
|
||||||
allowFrom: ["Alice#1234"],
|
allowFrom: ["Alice#1234"],
|
||||||
});
|
});
|
||||||
@@ -426,13 +427,20 @@ describe("resolveDiscordOwnerAllowFrom", () => {
|
|||||||
expect(result).toEqual(["123"]);
|
expect(result).toEqual(["123"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the normalized name slug for name matches", () => {
|
it("returns the normalized name slug for name matches only when enabled", () => {
|
||||||
const result = resolveDiscordOwnerAllowFrom({
|
const defaultResult = resolveDiscordOwnerAllowFrom({
|
||||||
channelConfig: { allowed: true, users: ["Some User"] } as DiscordChannelConfigResolved,
|
channelConfig: { allowed: true, users: ["Some User"] } as DiscordChannelConfigResolved,
|
||||||
sender: { id: "999", name: "Some User" },
|
sender: { id: "999", name: "Some User" },
|
||||||
});
|
});
|
||||||
|
expect(defaultResult).toBeUndefined();
|
||||||
|
|
||||||
expect(result).toEqual(["some-user"]);
|
const enabledResult = resolveDiscordOwnerAllowFrom({
|
||||||
|
channelConfig: { allowed: true, users: ["Some User"] } as DiscordChannelConfigResolved,
|
||||||
|
sender: { id: "999", name: "Some User" },
|
||||||
|
allowNameMatching: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(enabledResult).toEqual(["some-user"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1276,11 +1276,15 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
);
|
);
|
||||||
const ownerOk =
|
const ownerOk =
|
||||||
ownerAllowList && user
|
ownerAllowList && user
|
||||||
? allowListMatches(ownerAllowList, {
|
? allowListMatches(
|
||||||
id: sender.id,
|
ownerAllowList,
|
||||||
name: sender.name,
|
{
|
||||||
tag: sender.tag,
|
id: sender.id,
|
||||||
})
|
name: sender.name,
|
||||||
|
tag: sender.tag,
|
||||||
|
},
|
||||||
|
{ allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true },
|
||||||
|
)
|
||||||
: false;
|
: false;
|
||||||
const guildInfo = resolveDiscordGuildEntry({
|
const guildInfo = resolveDiscordGuildEntry({
|
||||||
guild: interaction.guild ?? undefined,
|
guild: interaction.guild ?? undefined,
|
||||||
@@ -1363,11 +1367,15 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
];
|
];
|
||||||
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
|
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
|
||||||
const permitted = allowList
|
const permitted = allowList
|
||||||
? allowListMatches(allowList, {
|
? allowListMatches(
|
||||||
id: sender.id,
|
allowList,
|
||||||
name: sender.name,
|
{
|
||||||
tag: sender.tag,
|
id: sender.id,
|
||||||
})
|
name: sender.name,
|
||||||
|
tag: sender.tag,
|
||||||
|
},
|
||||||
|
{ allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true },
|
||||||
|
)
|
||||||
: false;
|
: false;
|
||||||
if (!permitted) {
|
if (!permitted) {
|
||||||
commandAuthorized = false;
|
commandAuthorized = false;
|
||||||
@@ -1404,6 +1412,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
guildInfo,
|
guildInfo,
|
||||||
memberRoleIds,
|
memberRoleIds,
|
||||||
sender,
|
sender,
|
||||||
|
allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true,
|
||||||
});
|
});
|
||||||
const authorizers = useAccessGroups
|
const authorizers = useAccessGroups
|
||||||
? [
|
? [
|
||||||
@@ -1509,6 +1518,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
channelConfig,
|
channelConfig,
|
||||||
guildInfo,
|
guildInfo,
|
||||||
sender: { id: sender.id, name: sender.name, tag: sender.tag },
|
sender: { id: sender.id, name: sender.name, tag: sender.tag },
|
||||||
|
allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true,
|
||||||
});
|
});
|
||||||
const ctxPayload = finalizeInboundContext({
|
const ctxPayload = finalizeInboundContext({
|
||||||
Body: prompt,
|
Body: prompt,
|
||||||
|
|||||||
@@ -559,6 +559,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
runtime,
|
runtime,
|
||||||
botUserId,
|
botUserId,
|
||||||
|
allowNameMatching: discordCfg.dangerouslyAllowNameMatching === true,
|
||||||
guildEntries,
|
guildEntries,
|
||||||
logger,
|
logger,
|
||||||
}),
|
}),
|
||||||
@@ -570,6 +571,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
runtime,
|
runtime,
|
||||||
botUserId,
|
botUserId,
|
||||||
|
allowNameMatching: discordCfg.dangerouslyAllowNameMatching === true,
|
||||||
guildEntries,
|
guildEntries,
|
||||||
logger,
|
logger,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ async function authorizeVoiceCommand(
|
|||||||
guildInfo,
|
guildInfo,
|
||||||
memberRoleIds,
|
memberRoleIds,
|
||||||
sender,
|
sender,
|
||||||
|
allowNameMatching: params.discordConfig?.dangerouslyAllowNameMatching === true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ownerAllowList = normalizeDiscordAllowList(
|
const ownerAllowList = normalizeDiscordAllowList(
|
||||||
@@ -163,11 +164,15 @@ async function authorizeVoiceCommand(
|
|||||||
["discord:", "user:", "pk:"],
|
["discord:", "user:", "pk:"],
|
||||||
);
|
);
|
||||||
const ownerOk = ownerAllowList
|
const ownerOk = ownerAllowList
|
||||||
? allowListMatches(ownerAllowList, {
|
? allowListMatches(
|
||||||
id: sender.id,
|
ownerAllowList,
|
||||||
name: sender.name,
|
{
|
||||||
tag: sender.tag,
|
id: sender.id,
|
||||||
})
|
name: sender.name,
|
||||||
|
tag: sender.tag,
|
||||||
|
},
|
||||||
|
{ allowNameMatching: params.discordConfig?.dangerouslyAllowNameMatching === true },
|
||||||
|
)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const authorizers = params.useAccessGroups
|
const authorizers = params.useAccessGroups
|
||||||
|
|||||||
@@ -178,10 +178,25 @@ export async function collectChannelSecurityFindings(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountConfig = (account as { config?: Record<string, unknown> } | null | undefined)
|
||||||
|
?.config;
|
||||||
|
if (accountConfig?.dangerouslyAllowNameMatching === true) {
|
||||||
|
findings.push({
|
||||||
|
checkId: `channels.${plugin.id}.allowFrom.dangerous_name_matching_enabled`,
|
||||||
|
severity: "info",
|
||||||
|
title: `${plugin.meta.label ?? plugin.id} dangerous name matching is enabled`,
|
||||||
|
detail:
|
||||||
|
"dangerouslyAllowNameMatching=true re-enables mutable name/email/tag matching for sender authorization. This is a break-glass compatibility mode, not a hardened default.",
|
||||||
|
remediation:
|
||||||
|
"Prefer stable sender IDs in allowlists, then disable dangerouslyAllowNameMatching.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (plugin.id === "discord") {
|
if (plugin.id === "discord") {
|
||||||
const discordCfg =
|
const discordCfg =
|
||||||
(account as { config?: Record<string, unknown> } | null)?.config ??
|
(account as { config?: Record<string, unknown> } | null)?.config ??
|
||||||
({} as Record<string, unknown>);
|
({} as Record<string, unknown>);
|
||||||
|
const dangerousNameMatchingEnabled = discordCfg.dangerouslyAllowNameMatching === true;
|
||||||
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
|
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
|
||||||
const discordNameBasedAllowEntries = new Set<string>();
|
const discordNameBasedAllowEntries = new Set<string>();
|
||||||
addDiscordNameBasedEntries({
|
addDiscordNameBasedEntries({
|
||||||
@@ -236,13 +251,18 @@ export async function collectChannelSecurityFindings(params: {
|
|||||||
: "";
|
: "";
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "channels.discord.allowFrom.name_based_entries",
|
checkId: "channels.discord.allowFrom.name_based_entries",
|
||||||
severity: "warn",
|
severity: dangerousNameMatchingEnabled ? "info" : "warn",
|
||||||
title: "Discord allowlist contains name or tag entries",
|
title: dangerousNameMatchingEnabled
|
||||||
detail:
|
? "Discord allowlist uses break-glass name/tag matching"
|
||||||
"Discord name/tag allowlist matching uses normalized slugs and can collide across users. " +
|
: "Discord allowlist contains name or tag entries",
|
||||||
`Found: ${examples.join(", ")}${more}.`,
|
detail: dangerousNameMatchingEnabled
|
||||||
remediation:
|
? "Discord name/tag allowlist matching is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
|
||||||
"Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>) in channels.discord.allowFrom and channels.discord.guilds.*.users.",
|
`Found: ${examples.join(", ")}${more}.`
|
||||||
|
: "Discord name/tag allowlist matching uses normalized slugs and can collide across users. " +
|
||||||
|
`Found: ${examples.join(", ")}${more}.`,
|
||||||
|
remediation: dangerousNameMatchingEnabled
|
||||||
|
? "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>), then disable dangerouslyAllowNameMatching."
|
||||||
|
: "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>) in channels.discord.allowFrom and channels.discord.guilds.*.users, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept the risk.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const nativeEnabled = resolveNativeCommandsEnabled({
|
const nativeEnabled = resolveNativeCommandsEnabled({
|
||||||
|
|||||||
@@ -1500,6 +1500,43 @@ describe("security audit", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks Discord name-based allowlists as break-glass when dangerous matching is enabled", async () => {
|
||||||
|
await withChannelSecurityStateDir(async () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
channels: {
|
||||||
|
discord: {
|
||||||
|
enabled: true,
|
||||||
|
token: "t",
|
||||||
|
dangerouslyAllowNameMatching: true,
|
||||||
|
allowFrom: ["Alice#1234"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await runSecurityAudit({
|
||||||
|
config: cfg,
|
||||||
|
includeFilesystem: false,
|
||||||
|
includeChannelSecurity: true,
|
||||||
|
plugins: [discordPlugin],
|
||||||
|
});
|
||||||
|
|
||||||
|
const finding = res.findings.find(
|
||||||
|
(entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries",
|
||||||
|
);
|
||||||
|
expect(finding).toBeDefined();
|
||||||
|
expect(finding?.severity).toBe("info");
|
||||||
|
expect(finding?.detail).toContain("out-of-scope");
|
||||||
|
expect(res.findings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled",
|
||||||
|
severity: "info",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("does not warn when Discord allowlists use ID-style entries only", async () => {
|
it("does not warn when Discord allowlists use ID-style entries only", async () => {
|
||||||
await withChannelSecurityStateDir(async () => {
|
await withChannelSecurityStateDir(async () => {
|
||||||
const cfg: OpenClawConfig = {
|
const cfg: OpenClawConfig = {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe("slack/allow-list", () => {
|
|||||||
expect(normalizeSlackSlug(" #Ops.Room ")).toBe("#ops.room");
|
expect(normalizeSlackSlug(" #Ops.Room ")).toBe("#ops.room");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches wildcard, id, and prefixed name candidates", () => {
|
it("matches wildcard and id candidates by default", () => {
|
||||||
expect(resolveSlackAllowListMatch({ allowList: ["*"], id: "u1", name: "alice" })).toEqual({
|
expect(resolveSlackAllowListMatch({ allowList: ["*"], id: "u1", name: "alice" })).toEqual({
|
||||||
allowed: true,
|
allowed: true,
|
||||||
matchKey: "*",
|
matchKey: "*",
|
||||||
@@ -40,6 +40,15 @@ describe("slack/allow-list", () => {
|
|||||||
id: "u2",
|
id: "u2",
|
||||||
name: "alice",
|
name: "alice",
|
||||||
}),
|
}),
|
||||||
|
).toEqual({ allowed: false });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveSlackAllowListMatch({
|
||||||
|
allowList: ["slack:alice"],
|
||||||
|
id: "u2",
|
||||||
|
name: "alice",
|
||||||
|
allowNameMatching: true,
|
||||||
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
allowed: true,
|
allowed: true,
|
||||||
matchKey: "slack:alice",
|
matchKey: "slack:alice",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function resolveSlackAllowListMatch(params: {
|
|||||||
allowList: string[];
|
allowList: string[];
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}): SlackAllowListMatch {
|
}): SlackAllowListMatch {
|
||||||
const allowList = params.allowList;
|
const allowList = params.allowList;
|
||||||
if (allowList.length === 0) {
|
if (allowList.length === 0) {
|
||||||
@@ -40,9 +41,13 @@ export function resolveSlackAllowListMatch(params: {
|
|||||||
{ value: id, source: "id" },
|
{ value: id, source: "id" },
|
||||||
{ value: id ? `slack:${id}` : undefined, source: "prefixed-id" },
|
{ value: id ? `slack:${id}` : undefined, source: "prefixed-id" },
|
||||||
{ value: id ? `user:${id}` : undefined, source: "prefixed-user" },
|
{ value: id ? `user:${id}` : undefined, source: "prefixed-user" },
|
||||||
{ value: name, source: "name" },
|
...(params.allowNameMatching === true
|
||||||
{ value: name ? `slack:${name}` : undefined, source: "prefixed-name" },
|
? ([
|
||||||
{ value: slug, source: "slug" },
|
{ value: name, source: "name" as const },
|
||||||
|
{ value: name ? `slack:${name}` : undefined, source: "prefixed-name" as const },
|
||||||
|
{ value: slug, source: "slug" as const },
|
||||||
|
] satisfies Array<{ value?: string; source: SlackAllowListMatch["matchSource"] }>)
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (!candidate.value) {
|
if (!candidate.value) {
|
||||||
@@ -59,7 +64,12 @@ export function resolveSlackAllowListMatch(params: {
|
|||||||
return { allowed: false };
|
return { allowed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function allowListMatches(params: { allowList: string[]; id?: string; name?: string }) {
|
export function allowListMatches(params: {
|
||||||
|
allowList: string[];
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
allowNameMatching?: boolean;
|
||||||
|
}) {
|
||||||
return resolveSlackAllowListMatch(params).allowed;
|
return resolveSlackAllowListMatch(params).allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +77,7 @@ export function resolveSlackUserAllowed(params: {
|
|||||||
allowList?: Array<string | number>;
|
allowList?: Array<string | number>;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const allowList = normalizeAllowListLower(params.allowList);
|
const allowList = normalizeAllowListLower(params.allowList);
|
||||||
if (allowList.length === 0) {
|
if (allowList.length === 0) {
|
||||||
@@ -76,5 +87,6 @@ export function resolveSlackUserAllowed(params: {
|
|||||||
allowList,
|
allowList,
|
||||||
id: params.userId,
|
id: params.userId,
|
||||||
name: params.userName,
|
name: params.userName,
|
||||||
|
allowNameMatching: params.allowNameMatching,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,16 @@ export function isSlackSenderAllowListed(params: {
|
|||||||
allowListLower: string[];
|
allowListLower: string[];
|
||||||
senderId: string;
|
senderId: string;
|
||||||
senderName?: string;
|
senderName?: string;
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { allowListLower, senderId, senderName } = params;
|
const { allowListLower, senderId, senderName, allowNameMatching } = params;
|
||||||
return (
|
return (
|
||||||
allowListLower.length === 0 ||
|
allowListLower.length === 0 ||
|
||||||
allowListMatches({
|
allowListMatches({
|
||||||
allowList: allowListLower,
|
allowList: allowListLower,
|
||||||
id: senderId,
|
id: senderId,
|
||||||
name: senderName,
|
name: senderName,
|
||||||
|
allowNameMatching,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function shouldEmitSlackReactionNotification(params: {
|
|||||||
userId: string;
|
userId: string;
|
||||||
userName?: string | null;
|
userName?: string | null;
|
||||||
allowlist?: Array<string | number> | null;
|
allowlist?: Array<string | number> | null;
|
||||||
|
allowNameMatching?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { mode, botId, messageAuthorId, userId, userName, allowlist } = params;
|
const { mode, botId, messageAuthorId, userId, userName, allowlist } = params;
|
||||||
const effectiveMode = mode ?? "own";
|
const effectiveMode = mode ?? "own";
|
||||||
@@ -68,6 +69,7 @@ export function shouldEmitSlackReactionNotification(params: {
|
|||||||
allowList: users,
|
allowList: users,
|
||||||
id: userId,
|
id: userId,
|
||||||
name: userName ?? undefined,
|
name: userName ?? undefined,
|
||||||
|
allowNameMatching: params.allowNameMatching,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export type SlackMonitorContext = {
|
|||||||
dmEnabled: boolean;
|
dmEnabled: boolean;
|
||||||
dmPolicy: DmPolicy;
|
dmPolicy: DmPolicy;
|
||||||
allowFrom: string[];
|
allowFrom: string[];
|
||||||
|
allowNameMatching: boolean;
|
||||||
groupDmEnabled: boolean;
|
groupDmEnabled: boolean;
|
||||||
groupDmChannels: string[];
|
groupDmChannels: string[];
|
||||||
defaultRequireMention: boolean;
|
defaultRequireMention: boolean;
|
||||||
@@ -129,6 +130,7 @@ export function createSlackMonitorContext(params: {
|
|||||||
dmEnabled: boolean;
|
dmEnabled: boolean;
|
||||||
dmPolicy: DmPolicy;
|
dmPolicy: DmPolicy;
|
||||||
allowFrom: Array<string | number> | undefined;
|
allowFrom: Array<string | number> | undefined;
|
||||||
|
allowNameMatching: boolean;
|
||||||
groupDmEnabled: boolean;
|
groupDmEnabled: boolean;
|
||||||
groupDmChannels: Array<string | number> | undefined;
|
groupDmChannels: Array<string | number> | undefined;
|
||||||
defaultRequireMention?: boolean;
|
defaultRequireMention?: boolean;
|
||||||
@@ -391,6 +393,7 @@ export function createSlackMonitorContext(params: {
|
|||||||
dmEnabled: params.dmEnabled,
|
dmEnabled: params.dmEnabled,
|
||||||
dmPolicy: params.dmPolicy,
|
dmPolicy: params.dmPolicy,
|
||||||
allowFrom,
|
allowFrom,
|
||||||
|
allowNameMatching: params.allowNameMatching,
|
||||||
groupDmEnabled: params.groupDmEnabled,
|
groupDmEnabled: params.groupDmEnabled,
|
||||||
groupDmChannels,
|
groupDmChannels,
|
||||||
defaultRequireMention,
|
defaultRequireMention,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
|||||||
dmEnabled: true,
|
dmEnabled: true,
|
||||||
dmPolicy: "open",
|
dmPolicy: "open",
|
||||||
allowFrom: [],
|
allowFrom: [],
|
||||||
|
allowNameMatching: false,
|
||||||
groupDmEnabled: true,
|
groupDmEnabled: true,
|
||||||
groupDmChannels: [],
|
groupDmChannels: [],
|
||||||
defaultRequireMention: params.defaultRequireMention ?? true,
|
defaultRequireMention: params.defaultRequireMention ?? true,
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export async function prepareSlackMessage(params: {
|
|||||||
const allowMatch = resolveSlackAllowListMatch({
|
const allowMatch = resolveSlackAllowListMatch({
|
||||||
allowList: allowFromLower,
|
allowList: allowFromLower,
|
||||||
id: directUserId,
|
id: directUserId,
|
||||||
|
allowNameMatching: ctx.allowNameMatching,
|
||||||
});
|
});
|
||||||
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
||||||
if (!allowMatch.allowed) {
|
if (!allowMatch.allowed) {
|
||||||
@@ -244,6 +245,7 @@ export async function prepareSlackMessage(params: {
|
|||||||
allowList: channelConfig?.users,
|
allowList: channelConfig?.users,
|
||||||
userId: senderId,
|
userId: senderId,
|
||||||
userName: senderName,
|
userName: senderName,
|
||||||
|
allowNameMatching: ctx.allowNameMatching,
|
||||||
})
|
})
|
||||||
: true;
|
: true;
|
||||||
if (isRoom && !channelUserAuthorized) {
|
if (isRoom && !channelUserAuthorized) {
|
||||||
@@ -263,6 +265,7 @@ export async function prepareSlackMessage(params: {
|
|||||||
allowList: allowFromLower,
|
allowList: allowFromLower,
|
||||||
id: senderId,
|
id: senderId,
|
||||||
name: senderName,
|
name: senderName,
|
||||||
|
allowNameMatching: ctx.allowNameMatching,
|
||||||
}).allowed;
|
}).allowed;
|
||||||
const channelUsersAllowlistConfigured =
|
const channelUsersAllowlistConfigured =
|
||||||
isRoom && Array.isArray(channelConfig?.users) && channelConfig.users.length > 0;
|
isRoom && Array.isArray(channelConfig?.users) && channelConfig.users.length > 0;
|
||||||
@@ -272,6 +275,7 @@ export async function prepareSlackMessage(params: {
|
|||||||
allowList: channelConfig?.users,
|
allowList: channelConfig?.users,
|
||||||
userId: senderId,
|
userId: senderId,
|
||||||
userName: senderName,
|
userName: senderName,
|
||||||
|
allowNameMatching: ctx.allowNameMatching,
|
||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
const commandGate = resolveControlCommandGate({
|
const commandGate = resolveControlCommandGate({
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const baseParams = () => ({
|
|||||||
dmEnabled: true,
|
dmEnabled: true,
|
||||||
dmPolicy: "open" as const,
|
dmPolicy: "open" as const,
|
||||||
allowFrom: [],
|
allowFrom: [],
|
||||||
|
allowNameMatching: false,
|
||||||
groupDmEnabled: true,
|
groupDmEnabled: true,
|
||||||
groupDmChannels: [],
|
groupDmChannels: [],
|
||||||
defaultRequireMention: true,
|
defaultRequireMention: true,
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
dmEnabled,
|
dmEnabled,
|
||||||
dmPolicy,
|
dmPolicy,
|
||||||
allowFrom,
|
allowFrom,
|
||||||
|
allowNameMatching: slackCfg.dangerouslyAllowNameMatching === true,
|
||||||
groupDmEnabled,
|
groupDmEnabled,
|
||||||
groupDmChannels,
|
groupDmChannels,
|
||||||
defaultRequireMention: slackCfg.requireMention,
|
defaultRequireMention: slackCfg.requireMention,
|
||||||
|
|||||||
@@ -361,6 +361,7 @@ export async function registerSlackMonitorSlashCommands(params: {
|
|||||||
allowList: effectiveAllowFromLower,
|
allowList: effectiveAllowFromLower,
|
||||||
id: command.user_id,
|
id: command.user_id,
|
||||||
name: senderName,
|
name: senderName,
|
||||||
|
allowNameMatching: ctx.allowNameMatching,
|
||||||
});
|
});
|
||||||
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
||||||
if (!allowMatch.allowed) {
|
if (!allowMatch.allowed) {
|
||||||
@@ -446,6 +447,7 @@ export async function registerSlackMonitorSlashCommands(params: {
|
|||||||
allowList: channelConfig?.users,
|
allowList: channelConfig?.users,
|
||||||
userId: command.user_id,
|
userId: command.user_id,
|
||||||
userName: senderName,
|
userName: senderName,
|
||||||
|
allowNameMatching: ctx.allowNameMatching,
|
||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
if (channelUsersAllowlistConfigured && !channelUserAllowed) {
|
if (channelUsersAllowlistConfigured && !channelUserAllowed) {
|
||||||
@@ -460,6 +462,7 @@ export async function registerSlackMonitorSlashCommands(params: {
|
|||||||
allowList: effectiveAllowFromLower,
|
allowList: effectiveAllowFromLower,
|
||||||
id: command.user_id,
|
id: command.user_id,
|
||||||
name: senderName,
|
name: senderName,
|
||||||
|
allowNameMatching: ctx.allowNameMatching,
|
||||||
}).allowed;
|
}).allowed;
|
||||||
// DMs: allow chatting in dmPolicy=open, but keep privileged command gating intact by setting
|
// DMs: allow chatting in dmPolicy=open, but keep privileged command gating intact by setting
|
||||||
// CommandAuthorized based on allowlists/access-groups (downstream decides which commands need it).
|
// CommandAuthorized based on allowlists/access-groups (downstream decides which commands need it).
|
||||||
|
|||||||
Reference in New Issue
Block a user