Files
stack/docs/ADMIN-GUIDE/security/discord-ingress.md
T
2026-08-13 17:56:13 +00:00

16 KiB

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.

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. 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:

[
  {
    "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:

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