Skip to content

fix(teams): bind approval card actions to conversation, enforce render permissions - #75800

Open
spfcraze wants to merge 2 commits into
NousResearch:mainfrom
spfcraze:fix/teams-approval-idor
Open

spfcraze wants to merge 2 commits into
NousResearch:mainfrom
spfcraze:fix/teams-approval-idor

Conversation

@spfcraze

@spfcraze spfcraze commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes two approval-escalation holes in the Teams card-action handler. The Action.Execute invoke payload round-trips through the Teams client and is untrusted (Microsoft's own guidance), but the handler trusted both session_key and hermes_action from it: a crafted invoke could approve a DIFFERENT session's pending dangerous command (cross-session IDOR — QQ and Feishu bind the clicker to the session; Teams bound nothing), and could resubmit choices the render side withheld (approve_always despite allow_permanent=False or smart_denied) — which writes the dangerous pattern into the permanent on-disk allowlist. The session_key must now contain the invoking conversation's id, and every choice is enforced server-side against the pending approval's recorded permissions (allow_session / allow_permanent / smart_denied) via a new get_blocking_approval_data() helper in tools/approval.py.

Related Issue

No GitHub issue — discovered via code review and reproduced live (see below). Happy to file one first if preferred.

Changes Made

  • fix/teams-approval-idor — 3 file(s) changed vs base:
    • plugins/platforms/teams/adapter.py
    • tests/gateway/test_teams.py
    • tools/approval.py

How to Test

Validation completed (recorded by prp):

  1. Sabotage check: pre-fix code fails the regression tests (3 failed), with the fix all pass (24 passed, 0 failed) — target tests/gateway/test_teams.py.
  2. Suite tests/gateway/test_teams.py tests/tools/test_approval.py tests/tools/test_approval_heartbeat.py: branch 114 passed / 0 failed vs baseline 110 passed / 0 failed — zero branch-only failures.
  3. uvx ruff clean; git diff --check clean; live repro verified both vectors pre-fix (cross-session approved, withheld always honored) and post-fix (both blocked), legit owner path unaffected
  4. The full repo-wide suite was not run for this change; GitHub CI owns full-suite validation.

Logs

Sabotage verification output:

# base leg (pre-fix code + branch tests):
#   tests: 21 passed, 3 failed
# head leg (with fix):
#   tests: 24 passed, 0 failed

…r permissions

The Teams card-action handler trusted both session_key and hermes_action
from the Action.Execute invoke payload, which round-trips through the
Teams client and is untrusted. Two holes: a crafted invoke could approve
a DIFFERENT session's pending dangerous command (cross-session IDOR —
every sibling adapter binds one of clicker/session, Teams bound
neither), and could resubmit choices the render side withheld
(approve_always despite allow_permanent=False / smart_denied) — writing
the dangerous pattern to the permanent allowlist on disk. Now: the
session_key must contain the invoking conversation's id, and choices
are enforced against the pending approval's recorded permissions via a
new get_blocking_approval_data() helper.
@alt-glitch alt-glitch added type/security Security vulnerability or hardening comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have labels Aug 1, 2026
@teknium1

teknium1 commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Thanks for addressing a real Teams approval-boundary gap. Current main accepts session_key and hermes_action from the invoke payload and resolves the queue without a conversation or server-side render-permission check (plugins/platforms/teams/adapter.py:1015-1077); the card renderer does withhold scoped choices (plugins/platforms/teams/adapter.py:1129-1138).

Problems

  • The proposed conversation_id in session_key test in 1817e86e2470431d250272056fde8a3684df7501 is not an exact binding. Teams uses conversation.id as chat_id (plugins/platforms/teams/adapter.py:888), and session keys are structured components (gateway/session.py:1079-1086); substring matching can match unrelated portions of the opaque key.
  • The proposed read of approval permissions and later resolve are not atomic. The queue explicitly allows concurrent entries (tools/approval.py:2149-2152), while resolution separately pops FIFO (tools/approval.py:2214-2224), so concurrent invokes can validate one entry and resolve another.

Suggested changes

  • Bind cards to an opaque approval ID and atomically validate conversation, allowed choice, and target entry before consuming it.
  • Add a two-entry/concurrent-invoke regression test with differing permission flags.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Aug 1, 2026
Addresses review on 1817e86: the conversation binding was a substring
test (session_key contains conversation.id), which can match unrelated
portions of the opaque key — Teams conversation ids themselves contain
':' so the structured key segments are ambiguous. And the permission
read (get_blocking_approval_data) + later FIFO resolve were two separate
lock holds, so concurrent invokes against a multi-entry queue could
validate one entry and pop another.

Now: _ApprovalEntry carries an unguessable uuid (stamped into
approval_data for the notify path), the card payload embeds ONLY that
approval id (session_key no longer round-trips through the Teams
client), and send_exec_approval binds the entry to the render-time
conversation id server-side. _on_card_action calls the new
resolve_gateway_approval_by_id(), which under a single _lock hold finds
the exact entry, requires an exact conversation-id match, enforces the
render-time permissions (smart_denied / allow_session /
allow_permanent), and removes exactly that entry — concurrent invokes
cannot cross-consume. gateway/run.py forwards approval_id to adapters
that opt in via the kwarg (signature check, other adapters unaffected).

Regression tests: two-entry/concurrent-invoke with differing permission
flags, valid-id-withheld-choice rejection, substring conversation
mismatch, forged approval id, session_key-only legacy payload, and
renderer payload (approval id embedded, session_key absent).
@spfcraze

spfcraze commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Reworked in eff4239. The substring binding is gone: each _ApprovalEntry now carries an opaque uuid, the card payload embeds only that approval_id (the session key no longer round-trips through the Teams client), and send_exec_approval binds the entry to the render-time conversation.id server-side. On invoke, a new resolve_gateway_approval_by_id() holds the queue's _lock across the whole validate-and-consume: it locates the exact entry by id, requires an exact conversation-id match, enforces the render-time permissions (smart_denied / allow_session / allow_permanent), and removes exactly that entry — concurrent invokes against a multi-entry queue can no longer validate one entry and pop another.

Regression coverage added: a two-entry/concurrent-invoke test with differing permission flags (each invoke consumes only its own entry), a valid-id-withheld-choice rejection, a substring conversation-mismatch rejection, and a forged-id rejection.

Verification: tests/gateway/test_teams.py 30/30 pass; approval-adjacent suites (discord/feishu/matrix/slack/telegram/tui + approvals commands) green; revert-verified — the 10 security tests fail on pre-fix main and pass with the change.

@egilewski

Copy link
Copy Markdown
Contributor

looks mergeable

The replayed Teams approval change closes the prior cross-session and withheld-choice paths: card invokes carry only an opaque queue-entry id, the server binds it to the render-time conversation, and one locked resolver validates permissions before signaling the exact waiting entry. No additional source-backed security issue was found.

Security evidence:

  • trust boundary: Teams Adaptive Card invoke data (hermes_action, approval_id, cmd, and desc) is attacker-controlled. The sink is the pending gateway approval entry: its result/event unblocks the agent command guard and can authorize execution. The configured user authorization and in-process queue are trusted server-side state.
  • source/sink/invariant: The replay no longer accepts a client session_key. send_exec_approval binds the opaque entry id to the conversation; resolve_gateway_approval_by_id performs exact-id lookup, requires the exact bound conversation and stored allow_session/allow_permanent/smart_denied permissions, then removes only that entry before signaling it.
  • current-main reproduction: The prior implementation accepted a client-supplied session key, checked only whether that session had a pending approval, and FIFO-resolved it; the replay removes that path and uses exact opaque-id lookup, conversation binding, and stored choice permissions.
  • PR-head or patch-replay validation: Patch-replay validation passed focused Teams and queue-approval tests covering cross-conversation, forged and legacy IDs, withheld choices, concurrent entries, and render-time binding.
  • positive/negative cases: Positive: an authorized click with a matching conversation and offered choice resolves its own entry. Negative: mismatched conversation, unknown or legacy ID, withheld session/always choices, and concurrent two-entry clicks leave entries unresolved or consume only their own entry.
  • residual bypass search: Reviewed the changed Teams, gateway, and approval call sites plus sibling approval handlers. Other adapters retain their session-routed handlers, while Teams card data cannot select a queue or broaden render-time permissions. No residual Teams card bypass was found.
  • reviewer validation: Manual source review covered the relevant security logic, and focused replay tests passed.

Review setup: I reviewed a run-owned local rebase or patch replay against current GitHub main because the submitted branch is stale or conflicted; this does not mean the submitted branch itself merges cleanly.

Not checked:

  • CodeRabbit review
  • Full repository test suite
  • ruff lint

Signed: GPT-5.6-luna-max in Codex

@alt-glitch alt-glitch added comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists needs-repro Bug needs reproduction steps and removed P3 Low — cosmetic, nice to have needs-repro Bug needs reproduction steps labels Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P2 Medium — degraded but workaround exists sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants