Skip to content

Commit 65bb87e

Browse files
aryanorastarclaude
andauthored
fix(conversations): stop the public shared endpoint leaking owner-internal fields (#10273)
GET /v1/conversations/{id}/shared is public and unauthenticated, but its response returned the full Conversation model with only geolocation stripped — also exposing the server-side encryption tier (data_protection_level) and external_data (merge provenance: other conversation ids, plus integration metadata). Strip data_protection_level and external_data alongside geolocation before serializing. Scope is narrow: only owner-internal fields. The transcript, summary and audio the owner chose to publish are unchanged, so the response schema is unchanged. Verified: pytest test_shared_conversation_field_exposure.py -> 1 passed. Failure-Class: none Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ebacaab commit 65bb87e

2 files changed

Lines changed: 51 additions & 0 deletions

File tree

backend/routers/conversations.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,7 +1118,14 @@ def get_shared_conversation_by_id(conversation_id: str):
11181118
if not visibility or visibility == ConversationVisibility.private:
11191119
raise HTTPException(status_code=404, detail="Conversation is private")
11201120
conversation = deserialize_conversation(conversation)
1121+
# This endpoint is public and unauthenticated. Strip fields that are internal
1122+
# to the owner and never part of the shared transcript/summary the user chose
1123+
# to publish: precise geolocation, the server-side encryption tier, and
1124+
# external_data (which carries merge provenance — other conversation ids — and
1125+
# integration metadata).
11211126
conversation.geolocation = None
1127+
conversation.data_protection_level = None
1128+
conversation.external_data = None
11221129

11231130
# Fetch people data for speaker names
11241131
person_ids = conversation.get_person_ids()
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""The public /v1/conversations/{id}/shared endpoint must not expose owner-internal fields.
2+
3+
The endpoint returns a conversation to anyone with the link (no auth). It is
4+
meant to publish the transcript/summary the owner shared, not internal fields:
5+
precise geolocation (already stripped), the server-side encryption tier
6+
(`data_protection_level`), or `external_data` (merge provenance — other
7+
conversation ids — and integration metadata).
8+
"""
9+
10+
from types import SimpleNamespace
11+
from unittest.mock import patch
12+
13+
import routers.conversations as conv_router
14+
15+
16+
def test_shared_endpoint_strips_internal_fields_before_serialising():
17+
conv = SimpleNamespace(
18+
geolocation='here',
19+
data_protection_level='enhanced',
20+
external_data={'merge_metadata': {'source_ids': ['other-conv']}},
21+
)
22+
conv.get_person_ids = lambda: []
23+
24+
captured = {}
25+
26+
def fake_to_dict(c):
27+
captured['geolocation'] = c.geolocation
28+
captured['data_protection_level'] = c.data_protection_level
29+
captured['external_data'] = c.external_data
30+
return {'id': 'c1'}
31+
32+
with patch.object(conv_router.redis_db, 'get_conversation_uid', return_value='owner-uid'), patch.object(
33+
conv_router, '_get_valid_conversation_by_id', return_value={'visibility': 'public'}
34+
), patch.object(conv_router, 'deserialize_conversation', return_value=conv), patch.object(
35+
conv_router, 'conversation_to_dict', side_effect=fake_to_dict
36+
), patch.object(
37+
conv_router.users_db, 'get_people_by_ids', return_value=[]
38+
):
39+
conv_router.get_shared_conversation_by_id('c1')
40+
41+
# All three owner-internal fields must be cleared before serialization.
42+
assert captured['geolocation'] is None
43+
assert captured['data_protection_level'] is None
44+
assert captured['external_data'] is None

0 commit comments

Comments
 (0)