Skip to content

Commit 63e8329

Browse files
dolhoclaude
andauthored
fix(security): voice WebSocket + stop endpoint missing ownership check (#600) (#638)
The /ws/voice/{voice_session_id} handler decoded the JWT but threw the payload away — only the signature was checked. Any authenticated user holding a valid JWT who learned the 128-bit session id (logs, browser inspection, XSS) could attach to the audio stream, eavesdrop on the victim's transcript, and trigger tool calls audit-logged under the victim's identity. POST /api/agents/{name}/voice/stop had the same gap: the path agent was gated via get_authorized_agent, but request.voice_session_id was never cross-checked against the path agent or the caller's user_id, so the caller could end and persist a transcript onto another user's session. Fix: - WS: extract sub from the decoded JWT, look up the user, and close 4003 if user.id != session.user_id (admin role bypasses, for support). - voice_stop: load the session via get_session before mutating, assert agent_name == path name AND user_id == current_user.id (admin bypasses), raise 403 otherwise. Added tests/unit/test_voice_auth.py covering: missing token, invalid token, missing sub claim, unknown user, owner happy path, admin bypass, attacker rejected, plus voice_stop variants. Loads voice.py via importlib to avoid pulling in the full routers/__init__.py chain. Reported by /security-review on PR #599 (2026-04-30); origin commit 7d8abe8 (#581 voice tool calls). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 64e19aa commit 63e8329

2 files changed

Lines changed: 361 additions & 9 deletions

File tree

src/backend/routers/voice.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,18 @@ async def voice_stop(
120120
current_user: User = Depends(get_current_user),
121121
):
122122
"""End a voice session and save the transcript to chat messages."""
123+
# Ownership gate (#600): the path agent and the JWT user must both match
124+
# the session before any mutation happens — otherwise any authenticated
125+
# user with access to ANY agent could end and persist a transcript onto
126+
# someone else's session by passing its 128-bit id in the body.
127+
preview = voice_service.get_session(request.voice_session_id)
128+
if not preview:
129+
raise HTTPException(status_code=404, detail="Voice session not found")
130+
if preview.agent_name != name:
131+
raise HTTPException(status_code=403, detail="Voice session does not belong to this agent")
132+
if preview.user_id != current_user.id and current_user.role != "admin":
133+
raise HTTPException(status_code=403, detail="Not authorized for this voice session")
134+
123135
session = await voice_service.end_session(request.voice_session_id)
124136
if not session:
125137
raise HTTPException(status_code=404, detail="Voice session not found")
@@ -197,18 +209,40 @@ async def voice_websocket(
197209
return
198210

199211
# Authenticate via query param token (WebSocket can't use Authorization header)
200-
if token:
201-
from jose import jwt, JWTError
202-
from config import SECRET_KEY, ALGORITHM
203-
try:
204-
jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
205-
except JWTError:
206-
await websocket.close(code=4001, reason="Invalid token")
207-
return
208-
else:
212+
if not token:
209213
await websocket.close(code=4001, reason="Authentication required")
210214
return
211215

216+
from jose import jwt, JWTError
217+
from config import SECRET_KEY, ALGORITHM
218+
try:
219+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
220+
except JWTError:
221+
await websocket.close(code=4001, reason="Invalid token")
222+
return
223+
224+
username = payload.get("sub")
225+
if not username:
226+
await websocket.close(code=4001, reason="Invalid token claims")
227+
return
228+
229+
user = db.get_user_by_username(username)
230+
if not user:
231+
await websocket.close(code=4001, reason="Unknown user")
232+
return
233+
234+
# Ownership gate (#600): JWT user must own the voice session, or be admin.
235+
# Without this check, anyone holding a valid JWT who learns the 128-bit
236+
# session id (logs, browser inspection, XSS) can hijack the audio stream
237+
# and write tool calls under the victim's identity.
238+
if user["id"] != session.user_id and user.get("role") != "admin":
239+
logger.warning(
240+
"voice_ws ownership rejected: user_id=%s tried to attach to session owned by user_id=%s",
241+
user["id"], session.user_id,
242+
)
243+
await websocket.close(code=4003, reason="Not authorized for this voice session")
244+
return
245+
212246
await websocket.accept()
213247

214248
# Callbacks that forward Gemini output to the browser WebSocket

tests/unit/test_voice_auth.py

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
"""
2+
Unit tests for voice WebSocket + REST ownership gates (#600).
3+
4+
Verifies that `/ws/voice/{voice_session_id}` and `POST .../voice/stop` reject
5+
attempts to attach to a session the JWT user does not own. The bug was
6+
introduced by #581: the WS handler validated the JWT signature but discarded
7+
the payload, so any authenticated user could hijack any session whose
8+
128-bit id they observed (logs, browser inspection, XSS).
9+
10+
Issue: https://github.com/abilityai/trinity/issues/600
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import asyncio
16+
import os
17+
import sys
18+
import tempfile
19+
import types
20+
from pathlib import Path
21+
from unittest.mock import MagicMock, AsyncMock
22+
23+
import pytest
24+
25+
26+
# Point the backend at an ephemeral SQLite file BEFORE any backend module
27+
# imports — otherwise database.py tries to mkdir /data on import.
28+
_TMP_DB = Path(tempfile.gettempdir()) / "trinity_test_voice_auth.db"
29+
os.environ.setdefault("TRINITY_DB_PATH", str(_TMP_DB))
30+
31+
_BACKEND = Path(__file__).resolve().parent.parent.parent / "src" / "backend"
32+
if str(_BACKEND) not in sys.path:
33+
sys.path.insert(0, str(_BACKEND))
34+
35+
36+
# Stub passlib so dependencies.py imports without bcrypt installed.
37+
# We never call into hashing here — only need the import path to resolve.
38+
def _stub_passlib():
39+
if "passlib" in sys.modules:
40+
return
41+
passlib = types.ModuleType("passlib")
42+
context = types.ModuleType("passlib.context")
43+
44+
class _CryptContext:
45+
def __init__(self, **kw):
46+
pass
47+
48+
def hash(self, pw):
49+
return f"stub${pw}"
50+
51+
def verify(self, pw, hashed):
52+
return hashed == f"stub${pw}"
53+
54+
context.CryptContext = _CryptContext
55+
sys.modules["passlib"] = passlib
56+
sys.modules["passlib.context"] = context
57+
58+
59+
_stub_passlib()
60+
61+
62+
def _run(coro):
63+
return asyncio.run(coro)
64+
65+
66+
# ── Stub services.gemini_voice (avoids dragging google.genai into the test) ──
67+
68+
def _stub_voice_service():
69+
mod = types.ModuleType("services.gemini_voice")
70+
71+
class _FakeVoiceSession:
72+
def __init__(self, session_id, agent_name, user_id, user_email="u@example.com"):
73+
self.session_id = session_id
74+
self.agent_name = agent_name
75+
self.user_id = user_id
76+
self.user_email = user_email
77+
self.chat_session_id = "cs_test"
78+
self.transcript = []
79+
self._duration_seconds = 0.0
80+
81+
class _FakeVoiceService:
82+
def __init__(self):
83+
self._sessions: dict = {}
84+
self.is_available = MagicMock(return_value=True)
85+
self.create_session = MagicMock()
86+
self.connect_and_stream = AsyncMock()
87+
self.send_audio = AsyncMock()
88+
self.remove_session = MagicMock(side_effect=lambda sid: self._sessions.pop(sid, None))
89+
90+
def add(self, session):
91+
self._sessions[session.session_id] = session
92+
93+
def get_session(self, sid):
94+
return self._sessions.get(sid)
95+
96+
async def end_session(self, sid):
97+
return self._sessions.get(sid)
98+
99+
mod.VoiceSession = _FakeVoiceSession
100+
mod.voice_service = _FakeVoiceService()
101+
sys.modules["services.gemini_voice"] = mod
102+
return mod.voice_service, _FakeVoiceSession
103+
104+
105+
def _stub_docker_service():
106+
mod = types.ModuleType("services.docker_service")
107+
mod.get_agent_container = MagicMock(return_value=None)
108+
sys.modules["services.docker_service"] = mod
109+
110+
111+
def _stub_platform_audit():
112+
mod = types.ModuleType("services.platform_audit_service")
113+
audit = MagicMock()
114+
audit.log = AsyncMock()
115+
mod.platform_audit_service = audit
116+
117+
class _AuditEventType:
118+
EXECUTION = "execution"
119+
120+
class _AuditActorType:
121+
USER = "user"
122+
123+
mod.AuditEventType = _AuditEventType
124+
mod.AuditActorType = _AuditActorType
125+
sys.modules["services.platform_audit_service"] = mod
126+
127+
128+
_voice_service, _FakeVoiceSession = _stub_voice_service()
129+
_stub_docker_service()
130+
_stub_platform_audit()
131+
132+
133+
# Load voice.py directly via importlib instead of `from routers import voice`.
134+
# Going through routers/__init__.py drags in 50+ unrelated routers (agents,
135+
# slack, telegram, …) which need docker_service, twilio, slack_sdk, etc.
136+
# We only need the voice handlers.
137+
import importlib.util as _ilu # noqa: E402
138+
139+
_voice_path = _BACKEND / "routers" / "voice.py"
140+
_spec = _ilu.spec_from_file_location("routers.voice", str(_voice_path))
141+
voice_router = _ilu.module_from_spec(_spec)
142+
# Pre-register so relative imports inside voice.py (none right now) would work.
143+
sys.modules["routers.voice"] = voice_router
144+
_spec.loader.exec_module(voice_router)
145+
146+
from fastapi import HTTPException # noqa: E402
147+
from jose import jwt # noqa: E402
148+
from config import SECRET_KEY, ALGORITHM # noqa: E402
149+
150+
151+
# ── Test helpers ─────────────────────────────────────────────────────────────
152+
153+
def _make_jwt(username: str) -> str:
154+
return jwt.encode({"sub": username, "mode": "prod"}, SECRET_KEY, algorithm=ALGORITHM)
155+
156+
157+
class _FakeWebSocket:
158+
"""Minimal WebSocket that records accept/close/send activity."""
159+
160+
def __init__(self, queue=None):
161+
self.accepted = False
162+
self.closed = False
163+
self.close_code = None
164+
self.close_reason = None
165+
self.sent = []
166+
self._queue = list(queue or [])
167+
168+
async def accept(self):
169+
self.accepted = True
170+
171+
async def close(self, code=1000, reason=""):
172+
self.closed = True
173+
self.close_code = code
174+
self.close_reason = reason
175+
176+
async def send_json(self, payload):
177+
self.sent.append(payload)
178+
179+
async def receive_text(self):
180+
if not self._queue:
181+
from fastapi import WebSocketDisconnect
182+
raise WebSocketDisconnect()
183+
return self._queue.pop(0)
184+
185+
186+
@pytest.fixture(autouse=True)
187+
def _reset_voice_service():
188+
_voice_service._sessions.clear()
189+
_voice_service.connect_and_stream.reset_mock()
190+
yield
191+
_voice_service._sessions.clear()
192+
193+
194+
@pytest.fixture
195+
def alice_session():
196+
s = _FakeVoiceSession("vs_alice", agent_name="alice-agent", user_id=1, user_email="alice@example.com")
197+
_voice_service.add(s)
198+
return s
199+
200+
201+
def _patch_db(monkeypatch, users_by_username):
202+
"""Stub voice_router.db.get_user_by_username to return canned dicts."""
203+
fake_db = MagicMock()
204+
fake_db.get_user_by_username = MagicMock(side_effect=lambda u: users_by_username.get(u))
205+
monkeypatch.setattr(voice_router, "db", fake_db)
206+
return fake_db
207+
208+
209+
# ── WebSocket auth tests ─────────────────────────────────────────────────────
210+
211+
class TestVoiceWebSocketAuth:
212+
213+
def test_no_token_rejects_4001(self, alice_session, monkeypatch):
214+
ws = _FakeWebSocket()
215+
_run(voice_router.voice_websocket(ws, "vs_alice", token=None))
216+
assert ws.close_code == 4001
217+
assert ws.accepted is False
218+
219+
def test_invalid_token_rejects_4001(self, alice_session, monkeypatch):
220+
ws = _FakeWebSocket()
221+
_run(voice_router.voice_websocket(ws, "vs_alice", token="garbage.not.jwt"))
222+
assert ws.close_code == 4001
223+
assert ws.accepted is False
224+
225+
def test_unknown_session_rejects_4004(self, monkeypatch):
226+
ws = _FakeWebSocket()
227+
_patch_db(monkeypatch, {"alice": {"id": 1, "role": "user"}})
228+
token = _make_jwt("alice")
229+
_run(voice_router.voice_websocket(ws, "vs_does_not_exist", token=token))
230+
assert ws.close_code == 4004
231+
assert ws.accepted is False
232+
233+
def test_unknown_user_rejects_4001(self, alice_session, monkeypatch):
234+
ws = _FakeWebSocket()
235+
_patch_db(monkeypatch, {})
236+
token = _make_jwt("ghost")
237+
_run(voice_router.voice_websocket(ws, "vs_alice", token=token))
238+
assert ws.close_code == 4001
239+
assert ws.accepted is False
240+
241+
def test_owner_passes_auth_gate(self, alice_session, monkeypatch):
242+
"""Alice connecting to her own session reaches accept()."""
243+
ws = _FakeWebSocket()
244+
_patch_db(monkeypatch, {"alice": {"id": 1, "role": "user"}})
245+
token = _make_jwt("alice")
246+
_run(voice_router.voice_websocket(ws, "vs_alice", token=token))
247+
assert ws.accepted is True
248+
assert ws.closed is True # closed at end of finally — but we got past the gate
249+
250+
def test_other_user_rejected_4003(self, alice_session, monkeypatch):
251+
"""Bob holding a valid JWT cannot attach to Alice's session."""
252+
ws = _FakeWebSocket()
253+
_patch_db(monkeypatch, {"bob": {"id": 2, "role": "user"}})
254+
token = _make_jwt("bob")
255+
_run(voice_router.voice_websocket(ws, "vs_alice", token=token))
256+
assert ws.close_code == 4003
257+
assert ws.accepted is False
258+
259+
def test_admin_bypasses_ownership(self, alice_session, monkeypatch):
260+
"""Admins can attach to any session for support purposes."""
261+
ws = _FakeWebSocket()
262+
_patch_db(monkeypatch, {"root": {"id": 99, "role": "admin"}})
263+
token = _make_jwt("root")
264+
_run(voice_router.voice_websocket(ws, "vs_alice", token=token))
265+
assert ws.accepted is True
266+
267+
def test_token_missing_sub_rejects_4001(self, alice_session, monkeypatch):
268+
ws = _FakeWebSocket()
269+
token = jwt.encode({"mode": "prod"}, SECRET_KEY, algorithm=ALGORITHM)
270+
_run(voice_router.voice_websocket(ws, "vs_alice", token=token))
271+
assert ws.close_code == 4001
272+
assert ws.accepted is False
273+
274+
275+
# ── voice_stop ownership tests ──────────────────────────────────────────────
276+
277+
class _FakeUser:
278+
def __init__(self, id, role="user", email="u@example.com", username="u"):
279+
self.id = id
280+
self.role = role
281+
self.email = email
282+
self.username = username
283+
284+
285+
class TestVoiceStopAuth:
286+
287+
def test_unknown_session_404(self, monkeypatch):
288+
req = voice_router.VoiceStopRequest(voice_session_id="vs_missing")
289+
with pytest.raises(HTTPException) as exc:
290+
_run(voice_router.voice_stop(req, name="alice-agent", current_user=_FakeUser(1)))
291+
assert exc.value.status_code == 404
292+
293+
def test_other_agent_403(self, alice_session, monkeypatch):
294+
"""Path agent doesn't match the session's agent — reject."""
295+
req = voice_router.VoiceStopRequest(voice_session_id="vs_alice")
296+
with pytest.raises(HTTPException) as exc:
297+
_run(voice_router.voice_stop(req, name="bob-agent", current_user=_FakeUser(1)))
298+
assert exc.value.status_code == 403
299+
300+
def test_other_user_403(self, alice_session, monkeypatch):
301+
"""JWT user doesn't own the session — reject even with correct path agent."""
302+
req = voice_router.VoiceStopRequest(voice_session_id="vs_alice")
303+
with pytest.raises(HTTPException) as exc:
304+
_run(voice_router.voice_stop(req, name="alice-agent", current_user=_FakeUser(2)))
305+
assert exc.value.status_code == 403
306+
307+
def test_owner_succeeds(self, alice_session, monkeypatch):
308+
req = voice_router.VoiceStopRequest(voice_session_id="vs_alice")
309+
# _save_transcript would touch db — stub it to a no-op.
310+
monkeypatch.setattr(voice_router, "_save_transcript", lambda s: 0)
311+
result = _run(voice_router.voice_stop(req, name="alice-agent", current_user=_FakeUser(1)))
312+
assert result.messages_saved == 0
313+
314+
def test_admin_bypasses_ownership(self, alice_session, monkeypatch):
315+
req = voice_router.VoiceStopRequest(voice_session_id="vs_alice")
316+
monkeypatch.setattr(voice_router, "_save_transcript", lambda s: 0)
317+
result = _run(voice_router.voice_stop(req, name="alice-agent", current_user=_FakeUser(99, role="admin")))
318+
assert result.messages_saved == 0

0 commit comments

Comments
 (0)