Files
replypier/backend/tests/api/test_widget_takeover.py
T

430 lines
18 KiB
Python
Raw Normal View History

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
A widget visitor can be bound to an existing customer by an email they only
typed. That keeps one person in the inbox, but must never hand them that
person's conversations: a widget-issued token reaches only the conversations
started from its own browser, and may not stand for a person the embedding app
has signed in.
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from socketio.exceptions import ConnectionRefusedError
from app.api import token as token_router
from app.api import widget as widget_router
from app.api import widget_chat
from app.core.security import create_conversation_token
from app.database import get_db
from app.models.customer import Customer
from app.models.session_to_agent import SessionStatus, SessionToAgent
from app.repositories.customer import CustomerRepository
from app.repositories.session_to_agent import SessionToAgentRepository
from app.services import conversation_token
from app.services.contact_capture import CONTACT_EMAIL_META_KEY
VICTIM_BROWSER = "victim-browser"
@pytest.fixture
def client(db) -> TestClient:
app = FastAPI()
app.include_router(widget_router.router, prefix="/api/v1/widgets")
app.include_router(token_router.router, prefix="/api/v1")
def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
return TestClient(app)
@pytest.fixture
def mock_sio():
mock = MagicMock()
mock.enter_room = AsyncMock()
mock.emit = AsyncMock()
mock.save_session = AsyncMock()
mock.get_session = AsyncMock()
return mock
@pytest.fixture
def socket(db, test_widget, test_ai_config, mock_sio, monkeypatch):
"""Connect a widget socket with a given token; returns its saved session."""
monkeypatch.setattr(widget_chat, "sio", mock_sio)
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
monkeypatch.setattr(
"app.repositories.widget.WidgetRepository.get_widget",
lambda self, widget_id: test_widget,
)
ai_repo = MagicMock()
ai_repo.get_active_config.return_value = test_ai_config
monkeypatch.setattr(widget_chat, "AIConfigRepository", lambda db: ai_repo)
async def connect(token: str, sid: str):
claims = conversation_token.claims_ignoring_expiry(token)
monkeypatch.setattr(
widget_chat,
"authenticate_socket_conversation_token",
AsyncMock(return_value=(
str(test_widget.id), str(test_widget.organization_id), claims.get("sub"), token,
)),
)
mock_sio.save_session.reset_mock()
if not await widget_chat.widget_connect(sid, {}, {}):
return None
return mock_sio.save_session.call_args[0][1]
return connect
@pytest.fixture
def victim_session(db, test_widget, test_customer, test_user) -> SessionToAgent:
"""The victim's live conversation, with a human agent on it."""
session = SessionToAgent(
session_id=uuid4(),
customer_id=test_customer.id,
agent_id=test_widget.agent_id,
organization_id=test_widget.organization_id,
user_id=test_user.id,
status=SessionStatus.OPEN,
conversation_key=VICTIM_BROWSER,
)
db.add(session)
db.commit()
return session
def _victim_token(test_widget, test_customer) -> str:
return create_conversation_token(
widget_id=str(test_widget.id), customer_id=str(test_customer.id), ckey=VICTIM_BROWSER
)
def _customer_of(data: dict) -> str:
"""The customer a GET /widgets response bound the visitor to."""
return conversation_token.claims_ignoring_expiry(data["token"])["sub"]
def _emitted(mock_sio, event: str) -> dict:
for call in reversed(mock_sio.emit.call_args_list):
if call[0][0] == event:
return call[0][1]
raise AssertionError(f"{event} was never emitted")
class TestEmailDoesNotOpenAnotherBrowsersConversation:
"""GHSA-rfh4-6ghv-m2rr: GET /widgets/{id}?email=<victim>."""
@pytest.mark.asyncio
async def test_the_reported_takeover_lands_in_a_new_empty_conversation(
self, client, socket, mock_sio, test_widget, test_customer, victim_session
):
response = client.get(f"/api/v1/widgets/{test_widget.id}", params={"email": test_customer.email})
assert response.status_code == 200, response.text
data = response.json()
# Still one person in the inbox...
assert _customer_of(data) == str(test_customer.id)
# ...but nothing of theirs: not even who is helping them.
assert not (data["human_agent"] or {}).get("human_agent_name")
claims = conversation_token.inspect(data["token"], str(test_widget.id)).payload
assert claims["ckey"] != VICTIM_BROWSER
attacker = await socket(data["token"], "attacker")
assert attacker["session_id"] != str(victim_session.session_id)
assert _emitted(mock_sio, "session_initialized")["created"] is True
joined = [call[0][1] for call in mock_sio.enter_room.call_args_list]
assert str(victim_session.session_id) not in joined
mock_sio.get_session.return_value = attacker
await widget_chat.get_widget_chat_history("attacker")
assert _emitted(mock_sio, "chat_history")["messages"] == []
@pytest.mark.asyncio
async def test_the_victim_still_lands_in_their_own_conversation(
self, client, socket, test_widget, test_customer, victim_session
):
attacker_token = client.get(
f"/api/v1/widgets/{test_widget.id}", params={"email": test_customer.email}
).json()["token"]
await socket(attacker_token, "attacker")
victim = await socket(_victim_token(test_widget, test_customer), "victim")
assert victim["session_id"] == str(victim_session.session_id)
@pytest.mark.asyncio
async def test_a_token_issued_before_conversation_keys_reaches_no_existing_conversation(
self, db, socket, test_widget, test_customer
):
keyless = SessionToAgent(
session_id=uuid4(), customer_id=test_customer.id, agent_id=test_widget.agent_id,
organization_id=test_widget.organization_id, status=SessionStatus.OPEN,
)
db.add(keyless)
db.commit()
legacy_token = create_conversation_token(
widget_id=str(test_widget.id), customer_id=str(test_customer.id)
)
connected = await socket(legacy_token, "legacy")
assert connected["session_id"] != str(keyless.session_id)
@pytest.mark.asyncio
async def test_an_unverifiable_token_is_refused(self, socket, mock_sio, test_widget, test_customer, monkeypatch):
monkeypatch.setattr(widget_chat, "verify_conversation_token", lambda token: None)
assert await socket(_victim_token(test_widget, test_customer), "sid") is None
assert _emitted(mock_sio, "error")["type"] == "connection_error"
class TestOwnConversationOnly:
@pytest.mark.asyncio
async def test_a_closed_conversation_reopens_despite_a_newer_one_from_another_browser(
self, db, socket, mock_sio, test_widget, test_customer, victim_session, monkeypatch
):
victim = await socket(_victim_token(test_widget, test_customer), "victim")
victim_session.status = SessionStatus.CLOSED
victim_session.user_id = None
victim_session.assigned_at = datetime.now(timezone.utc) - timedelta(hours=1)
db.add(SessionToAgent(
session_id=uuid4(), customer_id=test_customer.id, agent_id=test_widget.agent_id,
organization_id=test_widget.organization_id, status=SessionStatus.OPEN,
conversation_key="someone-else", assigned_at=datetime.now(timezone.utc),
))
db.commit()
monkeypatch.setattr(widget_chat, "notify_new_chat", AsyncMock())
monkeypatch.setattr(
widget_chat.ChatAgent, "create_async", AsyncMock(side_effect=RuntimeError("stop here"))
)
mock_sio.get_session.return_value = victim
await widget_chat.handle_widget_chat("victim", {"message": "still there?"})
db.refresh(victim_session)
assert victim_session.status == SessionStatus.OPEN
def test_there_is_no_lookup_of_a_conversation_by_customer_alone(self):
assert not hasattr(SessionToAgentRepository, "get_active_customer_session")
@pytest.mark.asyncio
async def test_a_claimed_email_cannot_rename_the_customer(
self, db, client, socket, mock_sio, test_widget, test_customer, victim_session
):
test_customer.full_name = "Real Name"
db.commit()
attacker_token = client.get(
f"/api/v1/widgets/{test_widget.id}", params={"email": test_customer.email}
).json()["token"]
attacker = await socket(attacker_token, "attacker")
mock_sio.get_session.return_value = attacker
await widget_chat.handle_contact_info("attacker", {"form_data": {"name": "Attacker"}})
db.refresh(test_customer)
assert test_customer.full_name == "Real Name"
@pytest.mark.asyncio
async def test_a_claimed_email_cannot_leave_a_follow_up_address(
self, db, client, socket, mock_sio, test_widget, test_customer, victim_session
):
attacker_token = client.get(
f"/api/v1/widgets/{test_widget.id}", params={"email": test_customer.email}
).json()["token"]
attacker = await socket(attacker_token, "attacker")
mock_sio.get_session.return_value = attacker
await widget_chat.handle_contact_info("attacker", {"form_data": {"email": "attacker@evil.com"}})
db.refresh(test_customer)
assert CONTACT_EMAIL_META_KEY not in (test_customer.meta_data or {})
class TestMergedToken:
"""A device token naming a customer that lead capture merged into another."""
def test_a_token_for_a_merged_customer_is_judged_on_the_survivor(
self, db, client, test_widget, test_organization
):
survivor = Customer(email="member@acme.com", organization_id=test_organization.id,
is_authenticated=True)
db.add(survivor); db.commit(); db.refresh(survivor)
merged = Customer(email="123@noemail.com", organization_id=test_organization.id,
conversation_key="browser-a", merged_into_customer_id=survivor.id)
db.add(merged); db.commit(); db.refresh(merged)
token = create_conversation_token(
widget_id=str(test_widget.id), customer_id=str(merged.id), ckey="browser-a")
data = client.get(f"/api/v1/widgets/{test_widget.id}",
headers={"Authorization": f"Bearer {token}"}).json()
# Swapped now, rather than accepted here and refused on every connect.
assert data["token"]
assert _customer_of(data) not in (str(survivor.id), str(merged.id))
class TestPreClaimedAddress:
"""An address claimed through the widget before its owner signs in."""
@pytest.fixture
def pre_claim(self, db, client, test_widget, test_organization):
"""The attacker claims new@x; later the app signs the real user in."""
data = client.get(
f"/api/v1/widgets/{test_widget.id}", params={"email": "new.user@example.com"}
).json()
claimed = CustomerRepository(db).get_by_id(_customer_of(data))
assert claimed.email == "new.user@example.com"
signed_in = CustomerRepository(db).identify("new.user@example.com", test_organization.id)
assert signed_in.id == claimed.id and signed_in.is_authenticated
users_chat = SessionToAgent(
session_id=uuid4(), customer_id=signed_in.id, agent_id=test_widget.agent_id,
organization_id=test_widget.organization_id, status=SessionStatus.OPEN,
)
db.add(users_chat)
db.commit()
return data["token"], signed_in, users_chat
def test_the_old_token_is_swapped_for_a_customer_of_its_own(self, db, client, test_widget, pre_claim):
attacker_token, signed_in, _ = pre_claim
response = client.get(f"/api/v1/widgets/{test_widget.id}", headers={"Authorization": f"Bearer {attacker_token}"})
assert response.status_code == 200, response.text
data = response.json()
assert data["token"]
assert _customer_of(data) != str(signed_in.id)
swapped = CustomerRepository(db).get_by_id(_customer_of(data))
assert swapped.meta_data[CONTACT_EMAIL_META_KEY] == "new.user@example.com"
@pytest.mark.asyncio
async def test_the_old_token_is_sent_back_to_authorize(self, socket, pre_claim):
"""Refused the way an unbound token is, so the widget authorizes again
and GET /widgets/{id} hands it a customer of its own."""
attacker_token, _, _ = pre_claim
with pytest.raises(ConnectionRefusedError) as refused:
await socket(attacker_token, "attacker")
assert refused.value.error_args["data"]["type"] == "customer_required"
def test_typing_the_address_again_gets_an_anonymous_customer(self, db, client, test_widget, pre_claim):
_, signed_in, _ = pre_claim
data = client.get(
f"/api/v1/widgets/{test_widget.id}", params={"email": "new.user@example.com"}
).json()
assert _customer_of(data) != str(signed_in.id)
fresh = CustomerRepository(db).get_by_id(_customer_of(data))
assert CustomerRepository.is_placeholder_email(fresh.email)
assert fresh.meta_data[CONTACT_EMAIL_META_KEY] == "new.user@example.com"
@pytest.mark.asyncio
async def test_the_signed_in_user_keeps_their_conversation(self, socket, test_widget, pre_claim):
_, signed_in, users_chat = pre_claim
verified_token, _, _ = conversation_token.mint({
"sub": str(signed_in.id),
"widget_id": str(test_widget.id),
"customer_email": signed_in.email,
}, ttl_seconds=600)
connected = await socket(verified_token, "user")
assert connected["session_id"] == str(users_chat.session_id)
def _mint(widget, customer, **claims) -> str:
token, _, _ = conversation_token.mint(
{"sub": str(customer.id), "widget_id": str(widget.id), **claims}, ttl_seconds=600)
return token
class TestAccountScope:
"""Our in-app support chat: a token our backend vouched for on behalf of one
dashboard account, keyed to that account rather than to the address."""
@pytest.fixture
def signed_in(self, db, test_organization) -> Customer:
customer = Customer(email="member@acme.com", organization_id=test_organization.id, is_authenticated=True)
db.add(customer)
db.commit()
return customer
@pytest.mark.asyncio
async def test_the_account_keeps_its_customer_and_its_conversation(self, client, socket, test_widget, signed_in):
key = conversation_token.account_conversation_key("account-1")
token = _mint(test_widget, signed_in, customer_email=signed_in.email, ckey=key)
data = client.get(f"/api/v1/widgets/{test_widget.id}", headers={"Authorization": f"Bearer {token}"}).json()
assert "token" not in data or data["token"] is None # no swap to an anonymous customer
first = await socket(token, "drawer-1")
again = await socket(token, "drawer-2")
assert again["session_id"] == first["session_id"]
def test_a_widget_token_never_carries_an_account_key_forward(self, client, test_widget, signed_in):
"""An unverified token claiming an account key is refused, and the widget's
replacement starts a key of its own."""
forged = create_conversation_token(widget_id=str(test_widget.id),
ckey=conversation_token.account_conversation_key("account-1"))
data = client.get(f"/api/v1/widgets/{test_widget.id}", headers={"Authorization": f"Bearer {forged}"}).json()
claims = conversation_token.claims_ignoring_expiry(data["token"])
assert not conversation_token.is_account_key(claims["ckey"])
class TestEdges:
def test_a_token_for_a_customer_that_is_gone_gets_a_new_one(self, client, test_widget):
"""Answering without a token here meant every connect was refused, forever."""
ghost = create_conversation_token(widget_id=str(test_widget.id), customer_id=str(uuid4()), ckey="browser-a")
data = client.get(f"/api/v1/widgets/{test_widget.id}", headers={"Authorization": f"Bearer {ghost}"}).json()
assert data["token"]
assert conversation_token.claims_ignoring_expiry(data["token"])["ckey"] == "browser-a"
@pytest.mark.asyncio
async def test_a_refresh_cannot_move_the_socket_to_another_scope(self, socket, mock_sio, test_widget, test_customer):
connected = await socket(_victim_token(test_widget, test_customer), "victim")
other_scope = create_conversation_token(widget_id=str(test_widget.id), customer_id=str(test_customer.id),
ckey="another-browser")
mock_sio.get_session.return_value = dict(connected)
mock_sio.save_session.reset_mock()
await widget_chat.handle_refresh_token("victim", {"conversation_token": other_scope})
mock_sio.save_session.assert_not_awaited()
@pytest.mark.asyncio
async def test_a_visitor_who_cannot_save_contact_details_still_hears_back(
self, db, client, socket, mock_sio, test_widget, test_customer, victim_session
):
stranger = client.get(f"/api/v1/widgets/{test_widget.id}", params={"email": test_customer.email}).json()["token"]
mock_sio.get_session.return_value = await socket(stranger, "stranger")
mock_sio.emit.reset_mock()
await widget_chat.handle_contact_info("stranger", {"form_data": {"name": "Someone"}})
assert _emitted(mock_sio, "chat_response")["message"] == "Thanks — a teammate will follow up here."