fix(slack): neutralize prompt injection in thread-context backfill - #66742
fix(slack): neutralize prompt injection in thread-context backfill#66742Frowtek wants to merge 1 commit into
Conversation
`SlackAdapter._fetch_thread_context` formats each prior thread message as
`{name}: {msg_text}` and joins them with newlines into the block the call
site prepends *raw* into the model turn (`text = thread_context + text`).
Both fields are attacker-influenceable — any thread participant sets their
own Slack display name and message text — and neither was neutralized, so
an embedded newline let a thread message break out of its line and pose as
a fresh markdown section (a fake "## SYSTEM" / "## Override" heading) inside
the context the model reads when first mentioned mid-thread.
This is the same indirect-prompt-injection vector already closed for the
sibling untrusted sinks: the sender-name prefix
(`neutralize_untrusted_inline_text`), the reply quote, and the relay
channel-context renderer. The Slack thread-context backfill — the default
whenever the bot is mentioned in a thread with no active session — was the
missed sink. (The existing `[unverified]` tagging marks *who* a message is
from; it does nothing about newline structure, so an authorized sender can
inject just as easily.)
Flatten both fields with `neutralize_untrusted_inline_text` before
interpolation. The body uses `max_chars=0` so message text is not truncated
(thread context caps the message *count*, never per-message length); the
display name keeps the default bound. `parent_text` keeps the raw message
(its own reply-context sink neutralizes separately). A well-behaved message
is preserved byte-for-byte.
Adds a regression test covering a hostile display name, a hostile message
body, benign passthrough, and the no-truncation guarantee.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for identifying the raw Slack thread-context renderer; the context_parts change correctly targets the verified current-main sink at plugins/platforms/slack/adapter.py:4420.
Problems
- The same hostile thread-parent text remains raw on the reply-context route. PR head stores
parent_text = msg_textatplugins/platforms/slack/adapter.py:4433; current main returns that value from_fetch_thread_parent_textatplugins/platforms/slack/adapter.py:4477-4479and interpolatesevent.reply_to_textwithout neutralization atgateway/run.py:11508-11515. A parent containing\n## ...can still create a standalone model-visible heading.
Suggested changes
- Neutralize the reply snippet at
gateway/run.py:11508with the existingneutralize_untrusted_inline_texthelper and the current 500-character bound, then add a regression covering a hostile Slack thread parent through reply-context injection.
Automated hermes-sweeper review.
| # length). The trusted ``prefix``/``trust_tag`` we add ourselves | ||
| # stay outside the neutralized fields. | ||
| safe_name = neutralize_untrusted_inline_text(name) | ||
| safe_text = neutralize_untrusted_inline_text(msg_text, max_chars=0) |
There was a problem hiding this comment.
This protects the backfill line, but the raw msg_text is still cached as parent_text immediately below. Current gateway/run.py:11508-11515 interpolates that value via reply_to_text without newline neutralization, so a hostile thread parent can still inject a standalone heading. Please neutralize the reply snippet at that generic sink (retaining its 500-character bound) and add a regression for this route.
…ds, widen token-file perms warning Follow-up hardening on top of the C14 cherry-picks (#57860/#44026/#66742/#60009): - Slack file downloads (_download_slack_file/_download_slack_file_bytes) now require an https URL on a Slack CDN host (files.slack.com, *.slack.com Enterprise Grid, *.slack-files.com legacy shares) before attaching the bot token. url_private/url_private_download only ever point at the Slack CDN, so a forged file object from a malicious workspace app or compromised event stream pointing the Bearer-token download at an arbitrary PUBLIC host (token exfiltration) is now refused — a hole #44026's generic private-IP SSRF check alone could not close. - The same two download paths now use create_ssrf_safe_async_client (from #57860) so the preflight-validated hostname is resolved once, validated, and dialed by IP — closing the DNS-rebinding TOCTOU window for the token-bearing inbound fetches as well. - #60009's slack_tokens.json permission warning is generalized into utils.warn_if_credential_file_broadly_readable() (POSIX-only, fail-quiet) and wired into the other read path with the same gap: google_chat's load_user_credentials(). google_chat already writes 0o600 via _write_private_json; the read-time warning covers hand-provisioned/legacy files. Nothing in-repo writes slack_tokens.json (user/OAuth-provisioned), so there is no write path to chmod for Slack. Security tests both directions: non-CDN/lookalike/http URLs and connect-time DNS rebinds are blocked before any TCP connect; real files.slack.com, Enterprise Grid, and slack-files.com URLs still reach the network layer; 0o600 files stay silent while 0o644/0o640 warn with a chmod hint. A/B: all 10 new download-guard tests fail with the hardening reverted and pass with it applied.
…ds, widen token-file perms warning Follow-up hardening on top of the C14 cherry-picks (#57860/#44026/#66742/#60009): - Slack file downloads (_download_slack_file/_download_slack_file_bytes) now require an https URL on a Slack CDN host (files.slack.com, *.slack.com Enterprise Grid, *.slack-files.com legacy shares) before attaching the bot token. url_private/url_private_download only ever point at the Slack CDN, so a forged file object from a malicious workspace app or compromised event stream pointing the Bearer-token download at an arbitrary PUBLIC host (token exfiltration) is now refused — a hole #44026's generic private-IP SSRF check alone could not close. - The same two download paths now use create_ssrf_safe_async_client (from #57860) so the preflight-validated hostname is resolved once, validated, and dialed by IP — closing the DNS-rebinding TOCTOU window for the token-bearing inbound fetches as well. - #60009's slack_tokens.json permission warning is generalized into utils.warn_if_credential_file_broadly_readable() (POSIX-only, fail-quiet) and wired into the other read path with the same gap: google_chat's load_user_credentials(). google_chat already writes 0o600 via _write_private_json; the read-time warning covers hand-provisioned/legacy files. Nothing in-repo writes slack_tokens.json (user/OAuth-provisioned), so there is no write path to chmod for Slack. Security tests both directions: non-CDN/lookalike/http URLs and connect-time DNS rebinds are blocked before any TCP connect; real files.slack.com, Enterprise Grid, and slack-files.com URLs still reach the network layer; 0o600 files stay silent while 0o644/0o640 warn with a chmod hint. A/B: all 10 new download-guard tests fail with the hardening reverted and pass with it applied.
|
Merged via #70193 — your commit was cherry-picked/reapplied onto current main with your authorship preserved in git history: your backfill injection neutralization was cherry-picked onto the current _format_thread_context, additive to the wave-1 trust tags. Thanks for the contribution! |
…ds, widen token-file perms warning Follow-up hardening on top of the C14 cherry-picks (NousResearch#57860/NousResearch#44026/NousResearch#66742/NousResearch#60009): - Slack file downloads (_download_slack_file/_download_slack_file_bytes) now require an https URL on a Slack CDN host (files.slack.com, *.slack.com Enterprise Grid, *.slack-files.com legacy shares) before attaching the bot token. url_private/url_private_download only ever point at the Slack CDN, so a forged file object from a malicious workspace app or compromised event stream pointing the Bearer-token download at an arbitrary PUBLIC host (token exfiltration) is now refused — a hole NousResearch#44026's generic private-IP SSRF check alone could not close. - The same two download paths now use create_ssrf_safe_async_client (from NousResearch#57860) so the preflight-validated hostname is resolved once, validated, and dialed by IP — closing the DNS-rebinding TOCTOU window for the token-bearing inbound fetches as well. - NousResearch#60009's slack_tokens.json permission warning is generalized into utils.warn_if_credential_file_broadly_readable() (POSIX-only, fail-quiet) and wired into the other read path with the same gap: google_chat's load_user_credentials(). google_chat already writes 0o600 via _write_private_json; the read-time warning covers hand-provisioned/legacy files. Nothing in-repo writes slack_tokens.json (user/OAuth-provisioned), so there is no write path to chmod for Slack. Security tests both directions: non-CDN/lookalike/http URLs and connect-time DNS rebinds are blocked before any TCP connect; real files.slack.com, Enterprise Grid, and slack-files.com URLs still reach the network layer; 0o600 files stay silent while 0o644/0o640 warn with a chmod hint. A/B: all 10 new download-guard tests fail with the hardening reverted and pass with it applied.
What does this PR do?
SlackAdapter._fetch_thread_contextfetches recent thread messages when the bot is first mentioned mid-thread, formats each as{name}: {msg_text}, and joins them with newlines into a block the call site prepends raw into the model turn (text = thread_context + text). Both fields are attacker-influenceable — any thread participant sets their own Slack display name and message text — and neither was neutralized.Because the lines are newline-joined, an embedded newline in a message body (or display name) lets a thread message break out of its
name: textline and pose as a fresh markdown section (a fake## SYSTEM/## Overrideheading) inside the context the model reads. A user simply posts a thread message like:and, the next time the bot is mentioned in that thread with no active session, the injected heading appears on its own line in the prepended context block.
The existing
[unverified]tagging marks who a message is from, but does nothing about newline structure — so even an allowlisted (authorized) sender can inject. This is the same indirect-prompt-injection vector already closed for the sibling untrusted sinks: the sender-name prefix (neutralize_untrusted_inline_text), the reply quote, and the relay channel-context renderer (_render_relay_context). The Slack thread-context backfill was the missed sink.The fix flattens both fields with
neutralize_untrusted_inline_textbefore interpolation. The message body usesmax_chars=0so text is not truncated (thread context caps the message count, never per-message length); the display name keeps the default bound.parent_textkeeps the raw message — its own reply-context sink neutralizes separately. A well-behaved message is preserved byte-for-byte.Related Issue
Fixes #
Type of Change
Changes Made
plugins/platforms/slack/adapter.py— in_fetch_thread_context, neutralizenameandmsg_textvianeutralize_untrusted_inline_textbefore building the{name}: {msg_text}context line (max_chars=0on the body to preserve full length). Adds a local import of the helper, matching the existingSessionSource/build_session_keyimport pattern in this adapter.tests/gateway/test_slack.py— addTestThreadContextUnverifiedTagging::test_neutralizes_prompt_injection_in_name_and_text: a hostile display name and a hostile message body each carrying an embedded## …heading, asserting no injected line/heading survives, that benign messages render as before, and that a 300-char body is not truncated.How to Test
Reproduce on the current code — a hostile thread message breaks onto its own line:
Apply the fix; the name/body collapse to a single inert line, so
"\n## SYSTEM" not in content.Run:
The new test passes with the fix and fails without it; full file: 244 passed.
Checklist
Code
fix(slack):)pytest tests/gateway/test_slack.py -qand all tests passDocumentation & Housekeeping
cli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/A