138 lines
16 KiB
Markdown
138 lines
16 KiB
Markdown
# Discord ingress security
|
|
|
|
> **Status:** Current Discord behavior only. Telegram shared-contract parity, Matrix channel ingress, and a gateway-wide shared adapter registry are not implemented or are not proven by the current source/tests.
|
|
>
|
|
> **Last verified:** 2026-08-10 against the Discord plugin, gateway ingress/authentication code, and focused tests linked in [Evidence](#evidence).
|
|
>
|
|
> **Audience:** Administrators provisioning the Discord remote-control boundary.
|
|
|
|
This page documents the security boundary that exists today. It is not a deployment recipe for Telegram or Matrix, and it does not turn the gateway's lifecycle plugin list into a universal channel registry.
|
|
|
|
## Security model
|
|
|
|
Discord ingress has two current layers:
|
|
|
|
1. **Native Discord admission** in `@mosaicstack/discord-plugin` applies guild/channel/user allowlists, pairing, role, rate, and thread rules before a thread is created or a message is dispatched.
|
|
2. **Gateway compatibility admission** authenticates the Discord Socket.IO service, verifies the signed envelope again, re-checks the allowlists and binding, validates the conversation route, rejects replayed native message IDs, and then dispatches the message to the trusted agent configuration.
|
|
|
|
The current gateway namespace is `/chat`. The Discord plugin connects with a Socket.IO handshake value named `discordServiceToken`; this is distinct from the environment variable name `DISCORD_SERVICE_TOKEN` that supplies the value to the plugin and gateway.
|
|
|
|
### Admission and authorization order
|
|
|
|
For an inbound guild message, the current implementation:
|
|
|
|
1. Ignores bot-authored messages and messages without a guild. Discord DMs are therefore not handled by this ingress path, even though the client requests a direct-message intent.
|
|
2. Uses the configured thread parent as the authorization channel for a thread. A normal Discord category parent is never substituted for a text channel.
|
|
3. Requires the guild, authorization channel, and user to appear in their respective allowlists.
|
|
4. Resolves a configuration-owned binding and paired user. `viewer` cannot send a turn. Ordinary `send` requires `operator` or `admin`; `approve` and `stop` require `admin`.
|
|
5. Applies the message and mention-thread rate limits before any thread creation or gateway dispatch.
|
|
6. Derives the route from the binding's logical-agent instance and the response channel/thread. The route does not accept a provider, model, harness, process, or runtime-session selector from Discord.
|
|
7. Creates or reuses a thread only after the checks above pass.
|
|
|
|
The gateway then verifies the HMAC-SHA-256 envelope with `DISCORD_SERVICE_TOKEN`, re-applies the allowlists and binding/role check, requires the conversation ID to match the bound logical agent and channel/thread, and claims the native Discord message ID in a bounded replay cache. The default replay cache is in-process, retains IDs for 15 minutes, and is bounded at 10,000 entries; it is not a durable inbox.
|
|
|
|
For ordinary chat, the gateway attempts persistence and dispatch using `DISCORD_SERVICE_USER_ID`; `DISCORD_SERVICE_TENANT_ID` is used when configured and otherwise ordinary chat falls back to the service user ID as its tenant. The Discord external route is not a UUID, while persisted conversation IDs are UUIDs; no current route-to-UUID mapping proves that ordinary Discord persistence succeeds. The gateway may continue dispatch after a persistence/binding failure, so successful live delivery is not durable-history evidence.
|
|
|
|
Privileged approval and stop additionally require a configured tenant, a paired `mosaicUserId`, and a previously enrolled durable session. The durable session's logical agent must match the binding before approval or stop is accepted. The approval is consumed once against the exact runtime target; the Discord service account is not substituted for the approving paired user.
|
|
|
|
The trusted `agentConfigId` in each binding is resolved by the gateway. Its provisioned agent name must exactly equal `instanceId`. Discord cannot choose an arbitrary agent, provider, or model in the message payload, and Discord ingress does not use the general routing engine for a new session.
|
|
|
|
## Required configuration
|
|
|
|
The gateway's current plugin factory is in [`plugin.module.ts`](../../../apps/gateway/src/plugin/plugin.module.ts). When `DISCORD_BOT_TOKEN` is present, the following Discord values are required or validated as shown:
|
|
|
|
| Name | Required/current behavior |
|
|
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `DISCORD_BOT_TOKEN` | Enables the Discord plugin and supplies the Discord bot credential. |
|
|
| `DISCORD_SERVICE_TOKEN` | Required when the bot is enabled. Authenticates the Socket.IO service handshake and signs/verifies ingress envelopes. Treat as a high-entropy secret. |
|
|
| `DISCORD_SERVICE_USER_ID` | Required when the bot is enabled. Provisioned Mosaic service principal used for ordinary Discord dispatch and attempted persistence; durable history is not guaranteed. |
|
|
| `DISCORD_SERVICE_TENANT_ID` | Not required to start ordinary Discord chat, but required for the `/approve` and `/stop <approval>` control path. Use the provisioned tenant for the service boundary. |
|
|
| `DISCORD_GATEWAY_URL` | Base gateway URL. The plugin connects to `${DISCORD_GATEWAY_URL}/chat`; the gateway factory default is `http://localhost:14242`. |
|
|
| `DISCORD_GUILD_ID` | Optional guild ID used only by the current project-channel provisioning helper. It is not the message authorization allowlist. |
|
|
| `DISCORD_ALLOWED_GUILD_IDS` | Required, comma-separated guild IDs. Empty or missing values fail closed during plugin creation. |
|
|
| `DISCORD_ALLOWED_CHANNEL_IDS` | Required, comma-separated parent text-channel IDs. Thread messages are checked against their configured parent. |
|
|
| `DISCORD_ALLOWED_USER_IDS` | Required, comma-separated Discord user IDs. This allowlist is checked in addition to `pairedUsers`. |
|
|
| `DISCORD_INTERACTION_BINDINGS` | Required, non-empty JSON array of configuration-owned bindings. Malformed or empty data fails plugin creation. |
|
|
| `DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE` | Optional positive integer; default is `30` authorized turns per guild/channel/user window. Zero, negative, and non-integer values are rejected. |
|
|
| `DISCORD_THREAD_RATE_LIMIT_PER_MINUTE` | Optional positive integer; default is `5` mention-triggered thread routes per guild/channel/user window. Invalid values are rejected. |
|
|
|
|
`MOSAIC_AGENT_NAME` and `MOSAIC_AGENT_CONFIG_ID` are not substitutes for a Discord binding. The current Discord binding uses `instanceId` and `agentConfigId` inside `DISCORD_INTERACTION_BINDINGS`; do not invent a different environment-based routing contract.
|
|
|
|
### Binding shape
|
|
|
|
Use placeholders for identifiers and keep credentials out of the JSON:
|
|
|
|
```json
|
|
[
|
|
{
|
|
"instanceId": "interaction-agent",
|
|
"agentConfigId": "provisioned-agent-config-id",
|
|
"guildId": "guild-id",
|
|
"channelId": "parent-channel-id",
|
|
"pairedUsers": {
|
|
"discord-user-id": {
|
|
"role": "operator",
|
|
"mosaicUserId": "provisioned-mosaic-user-id"
|
|
}
|
|
}
|
|
}
|
|
]
|
|
```
|
|
|
|
Each binding requires `instanceId`, `agentConfigId`, `guildId`, `channelId`, and a non-empty `pairedUsers` object. Pairing roles are `viewer`, `operator`, and `admin`. A role-only pairing remains accepted for ordinary non-privileged compatibility, but it has no `mosaicUserId` and cannot authorize the privileged approval/stop path. The guild and parent channel must also be present in their allowlists.
|
|
|
|
The bot needs permission to view and send messages in the configured channels and to create and send public threads. A category parent is not an authorization boundary. A thread inherits authorization only from its configured parent text channel.
|
|
|
|
### Secret handling
|
|
|
|
Supply `DISCORD_BOT_TOKEN` and `DISCORD_SERVICE_TOKEN` through the approved runtime secret mechanism. Do not commit them, put them in binding JSON, or pass them on a command line.
|
|
|
|
The current `mosaic gateway config --set KEY=VALUE` implementation writes the gateway `.env` file and prints the value in its confirmation; its mask list does not include `DISCORD_SERVICE_TOKEN`. Do **not** use that command for the service token. `mosaic gateway config --edit` exists for local configuration, but production secret provisioning must remain outside the repository and follow the approved secret path.
|
|
|
|
## Applying configuration safely
|
|
|
|
These are the current CLI commands exposed by `@mosaicstack/mosaic`; they manage the gateway daemon and do not constitute a Discord protocol:
|
|
|
|
```bash
|
|
mosaic gateway install
|
|
mosaic gateway config --edit
|
|
mosaic gateway status
|
|
mosaic gateway verify
|
|
mosaic gateway restart
|
|
mosaic gateway logs --lines 50
|
|
```
|
|
|
|
The daemon reads its environment from `~/.config/mosaic/gateway/.env` by default; `MOSAIC_GATEWAY_HOME` can change that home. `mosaic gateway config --set KEY=VALUE` and `--unset KEY` are also implemented for non-secret values. After changing Discord configuration, restart the gateway so the plugin factory is rebuilt. `mosaic gateway status` and `mosaic gateway verify` check the gateway daemon/health surfaces; the current lifecycle host does not expose a channel-specific `healthAll` command, so a green gateway check alone is not proof that Discord is connected.
|
|
|
|
## Failure and abuse behavior
|
|
|
|
| Condition | Current result |
|
|
| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
| Missing service token/user, allowlist, or interaction bindings when Discord is enabled | Gateway plugin creation fails rather than enabling an unconfigured remote-control surface. |
|
|
| Invalid optional rate limit | Plugin creation fails; values must be positive integers. |
|
|
| Unallowlisted guild/channel/user, unpaired user, or insufficient role | Message is ignored before thread creation and gateway dispatch. |
|
|
| Mentioned message cannot create or fetch its requested thread | The message is not dispatched because its response target cannot be honored. |
|
|
| Invalid HMAC, malformed envelope, route mismatch, wrong binding, or replayed native message ID | Gateway rejects the ingress without dispatch. |
|
|
| Unsafe attachment metadata or URL | Gateway rejects the message before acknowledgement/dispatch. Current bounds include at most 10 attachments, HTTPS URLs without credentials, query strings, or fragments, and bounded ID/name/URL/metadata lengths. |
|
|
| Missing `DISCORD_SERVICE_TENANT_ID` for approval/stop | The privileged control handler returns without creating or consuming an approval. |
|
|
| Agent configuration ID does not resolve or its name differs from `instanceId` | Gateway refuses to create the Discord-bound session. |
|
|
| External route cannot be persisted as a UUID conversation | Gateway may still dispatch live output; durable history and restart/resume continuity are not guaranteed and must not be inferred from delivery. |
|
|
|
|
## Explicitly not current
|
|
|
|
- **Telegram:** `TELEGRAM_BOT_TOKEN` and `TELEGRAM_GATEWAY_URL` can instantiate the raw legacy Telegram plugin, but that plugin does not use the shared channel DTOs, Discord-style service authentication, allowlists, pairing, route validation, or a tested gateway security boundary. Do not treat these variables as a secured Telegram equivalent of the Discord configuration above.
|
|
- **Matrix:** No current gateway channel adapter, binding, authentication path, or focused channel test establishes Matrix ingress. Matrix-related fleet/runtime code is not evidence of a Matrix channel deployment procedure.
|
|
- **Shared registry parity:** The current gateway `PLUGIN_REGISTRY` hosts lifecycle wrappers (`name`, `start`, `stop`, and optional project provisioning). It does not expose a universal channel health/ingress/egress registry. That is follow-up work, not an administrator capability today.
|
|
|
|
## Evidence
|
|
|
|
- [`plugins/discord/src/index.ts`](../../../plugins/discord/src/index.ts) — Discord allowlists, bindings, roles, thread routing, signed envelope, typed ingress/egress, limits, retry, and health.
|
|
- [`plugins/discord/src/index.test.ts`](../../../plugins/discord/src/index.test.ts) — authorization ordering, thread behavior, attachments, stable routes, egress, rate limits, and health.
|
|
- [`apps/gateway/src/chat/chat.gateway.ts`](../../../apps/gateway/src/chat/chat.gateway.ts) — `/chat` authentication, envelope validation, replay, trusted agent selection, ordinary dispatch, approval, and stop.
|
|
- [`apps/gateway/src/chat/chat.gateway-auth.ts`](../../../apps/gateway/src/chat/chat.gateway-auth.ts) — timing-safe service-token and BetterAuth session validation.
|
|
- [`apps/gateway/src/plugin/discord-ingress.security.spec.ts`](../../../apps/gateway/src/plugin/discord-ingress.security.spec.ts) — signature, allowlist, replay, attachment, binding, approval, stop, and logical-agent checks.
|
|
- [`apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts`](../../../apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts) — control-flow evidence with explicit durable-session pre-enrollment, not proof of ordinary Discord persistence.
|
|
- [`packages/mosaic/src/commands/gateway.ts`](../../../packages/mosaic/src/commands/gateway.ts) and [`gateway/config.ts`](../../../packages/mosaic/src/commands/gateway/config.ts) — verified gateway CLI command names and configuration behavior.
|
|
- [Channel protocol architecture](../../DEVELOPER-GUIDE/architecture/channel-protocol.md) — canonical shared-contract and parity boundary.
|
|
- [Discord conversation workflow](../../USER-GUIDE/workflows/discord-conversations.md) — end-user behavior.
|