fix(security): apply user allowlist and escape delimiters in Discord channel backfill to prevent prompt injection - #29230
Conversation
|
Cross-referencing recent Discord auth hardening PRs for reviewer
All three are needed to make the Discord trust boundary uniform |
2c1bb4d to
084dfab
Compare
|
Rebased onto current main — adapter migrated to Verified the bug is still present in the post-refactor code Added 3 regression tests in
All three fail without the fix and pass with it; the 5 existing |
|
merge conflicts This PR does not merge cleanly with the base branch. Please rebase or merge current Signed: GPT-5.5-low in Codex |
…channel backfill to prevent prompt injection DiscordAdapter._fetch_channel_context reads the last N messages from a channel and prepends them to the model's context when an allowlisted user triggers a response. Two gaps in that path let a non-allowlisted guild member turn the channel into an indirect prompt-injection surface against the authorized user: 1. Backfilled human messages bypassed the allowlist. The same _is_allowed_user() gate applied at message-receipt time wasn't applied to backfill, so any guild member — including users explicitly excluded from DISCORD_ALLOWED_USERS / DISCORD_ALLOWED_ROLES — could plant text the bot would later read into the model's context. 2. Structural [ ] delimiters in display_name and content weren't escaped. Hostile values like display_name='System]' could fake header rows and slip instructions past the channel-context boundary. Fix: * Apply _is_allowed_user(...) to backfilled human messages, mirroring on_message receipt-time behavior. Bots keep their existing include_other_bots gate. _fetch_channel_context is only called from non-DM paths (guarded by 'not _is_dm' at the call site), so is_dm=False is correct. * Backslash-escape [ and ] in both display_name and message content before formatting into the '[name] content' rows. Backwards compatible: when neither DISCORD_ALLOWED_USERS nor DISCORD_ALLOWED_ROLES is configured, _is_allowed_user returns True for everyone — same as existing on_message behavior. The bracket escape is purely additive. Regression tests added in tests/gateway/test_discord_free_response.py: * test_fetch_channel_context_skips_non_allowlisted_users — verifies Mallory's injection attempt is dropped when she's not in the allowlist (the core bug). * test_fetch_channel_context_includes_allowlisted_users — verifies allowlisted humans still appear (positive case). * test_fetch_channel_context_escapes_brackets_in_name_and_content — verifies hostile '[Trusted]' headers can no longer appear bare. All three tests fail without the fix and pass with it; the five existing _fetch_channel_context tests continue to pass. Rebased onto plugins/platforms/discord/adapter.py (Discord adapter migrated to bundled plugin in NousResearch#30591). Logic unchanged from original PR; only the file path moved.
084dfab to
dc2d708
Compare
|
Rebased onto current main and resolved conflicts in adapter.py + the test file. Main had added a reply-window backfill path (a second channel.history loop) that my original diff didn't cover. Rather than duplicate the allowlist/escape checks, I moved them into the shared _keep() helper, so both the primary and reply windows are now gated by the receipt-time allowlist and bracket-escaping — no unguarded backfill path remains. Kept all four tests (HEAD's boundary test + the allowlist skip/include + escape regressions); 46/46 pass. |
|
looks mergeable The current PR head closes the Discord history-backfill trust-boundary issue for both recent-channel and reply-anchored context. I reproduced the vulnerable behavior on current main, verified that the PR drops non-allowlisted human backfill messages and escapes bracket delimiters before model-context formatting, and the focused Discord backfill test file passes on the PR head. Security evidence:
Signed: GPT-5.5-xhigh in Codex |
|
Thanks for the detailed writeup and the clean threat model — both gaps are real on current main (the backfill path never applies _is_allowed_user to human authors, and [ ] in display_name/content go unescaped into the model context). We're not going to take this change right now, so closing for the time being. The analysis here is solid and we may revisit the backfill trust boundary later; appreciate the contribution. |
What does this PR do?
DiscordAdapter._fetch_channel_context(added in #25984, default-onin v0.14.0) reads the last N messages from a channel and prepends them
to the model's context whenever an allowlisted user triggers a
response. Two gaps in that path let a non-allowlisted guild member
turn the channel into an indirect prompt-injection surface against
the authorized user:
The two missing controls:
Backfilled human messages bypass the allowlist. The same
_is_allowed_user(...)gate thaton_messageenforces at receipttime isn't applied here. Any guild member — including users
explicitly excluded from
DISCORD_ALLOWED_USERS/DISCORD_ALLOWED_ROLES— can plant text in the channel that thebot will later read into the model's context.
Structural delimiters in
display_nameand message contentaren't escaped. The backfill block is wrapped in
[Recent channel messages]\n[Username] content\n..., but[and]in user-controlled fields aren't escaped, so a hostile actorcan fake their own header rows.
Attack scenario
Server has Hermes deployed with:
Mallory is a regular server member (NOT in the allowlist). Mallory
posts in a channel Hermes can read:
Later, Alice (allowlisted) mentions the bot:
_fetch_channel_contextpulls the last N messages — includingMallory's — and prepends:
The model treats Mallory's content as authoritative context. The
display_namevariant (Mallorysets her name toSystem]) letsher drop the
[Mallory]wrapper entirely:Renders as:
— defeating any "context inside
[...]is just channel history"heuristic the model might use to bound the trusted region.
Why this is significant in v0.14.0
The release notes explicitly call out:
So every Hermes-on-Discord deployment is now affected by default,
not just deployments that opted into multi-user channel context.
CVSS 3.1 estimate
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N→ 8.7 (HIGH)an arbitrary internet user
context to reach the model
agent session: read files Alice can read, call tools on Alice's
behalf, exfiltrate via subsequent bot messages
Fix
Two small changes at the trust boundary:
Non-allowlisted human messages are now dropped from backfill — the
same way they'd be dropped if they'd hit
on_messagedirectly. Botskeep their existing
include_other_botsgate (this PR doesn't changebot-handling semantics).
[and]indisplay_nameand message content are backslash-escapedso hostile values can no longer synthesize fake
[Recent channel messages]headers or fake[Trusted]rows.Why this shape
Mirrors the defense-in-depth pattern in the codebase:
#22432— sanitize Google Chatsender_typefrom relay#22435— drop caller-controlledauthorinkanban_comment#27825— sanitize LSP diagnostic fields (in review)#28173— strip directory components from Teams recording filename#26823— sanitize tool error strings before re-injectionAll apply the same principle: data crossing a trust boundary into
model context gets either dropped (if it's from an unauthorized
source) or escaped (if its structural delimiters could be abused).
Backwards compatibility
DISCORD_ALLOWED_USERSnorDISCORD_ALLOWED_ROLESis configured,
_is_allowed_userreturnsTruefor everyone —same as the existing
on_messagebehavior. So deployments thatintentionally allow all users see no change.
expecting non-allowlisted users to be ignored — this PR makes
backfill match that expectation.
brackets just get rendered with
\[/\], still readable tothe model.
Type of Change
Checklist
on_messageallowlist, doesn't replace it