Skip to content

Commit b2916c7

Browse files
Nicola PesaventoGWeale
authored andcommitted
fix: validate session_id and enforce ownership in delete_session
Merge google#5580 COPYBARA_INTEGRATE_REVIEW=google#5580 from nicola-pesavento:fix/vertex-ai-session-id-validation eca1504 Change-Id: I8efe7d5703459f92f063e2f667ca076eb0916632
1 parent 2748c1b commit b2916c7

2 files changed

Lines changed: 74 additions & 3 deletions

File tree

src/google/adk/sessions/vertex_ai_session_service.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,19 @@
4747
_COMPACTION_CUSTOM_METADATA_KEY = '_compaction'
4848
_USAGE_METADATA_CUSTOM_METADATA_KEY = '_usage_metadata'
4949

50+
_SESSION_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]+$')
51+
52+
53+
def _validate_session_id(session_id: str) -> None:
54+
"""Rejects session IDs that could escape the URL path segment."""
55+
if not isinstance(session_id, str) or not _SESSION_ID_PATTERN.fullmatch(
56+
session_id
57+
):
58+
raise ValueError(
59+
f'Invalid session_id {session_id!r}: must match'
60+
f' {_SESSION_ID_PATTERN.pattern}.'
61+
)
62+
5063

5164
def _quote_filter_literal(value: str) -> str:
5265
"""Quotes filter values so embedded metacharacters stay inside the literal."""
@@ -134,6 +147,7 @@ async def create_session(
134147

135148
config = {'session_state': state} if state else {}
136149
if session_id:
150+
_validate_session_id(session_id)
137151
config['session_id'] = session_id
138152
config.update(kwargs)
139153
async with self._get_api_client() as api_client:
@@ -164,6 +178,7 @@ async def get_session(
164178
session_id: str,
165179
config: Optional[GetSessionConfig] = None,
166180
) -> Optional[Session]:
181+
_validate_session_id(session_id)
167182
reasoning_engine_id = self._get_reasoning_engine_id(app_name)
168183
session_resource_name = (
169184
f'reasoningEngines/{reasoning_engine_id}/sessions/{session_id}'
@@ -263,14 +278,30 @@ async def list_sessions(
263278
async def delete_session(
264279
self, *, app_name: str, user_id: str, session_id: str
265280
) -> None:
281+
_validate_session_id(session_id)
266282
reasoning_engine_id = self._get_reasoning_engine_id(app_name)
283+
session_resource_name = (
284+
f'reasoningEngines/{reasoning_engine_id}/sessions/{session_id}'
285+
)
267286

268287
async with self._get_api_client() as api_client:
288+
# Enforce ownership: delete_session otherwise ignores user_id entirely.
289+
try:
290+
existing = await api_client.agent_engines.sessions.get(
291+
name=session_resource_name
292+
)
293+
except ClientError as e:
294+
if e.code == 404:
295+
return
296+
raise
297+
if existing.user_id != user_id:
298+
raise ValueError(
299+
f'Session {session_id} does not belong to user {user_id}.'
300+
)
301+
269302
try:
270303
await api_client.agent_engines.sessions.delete(
271-
name=(
272-
f'reasoningEngines/{reasoning_engine_id}/sessions/{session_id}'
273-
),
304+
name=session_resource_name,
274305
)
275306
except Exception as e:
276307
logger.error('Error deleting session %s: %s', session_id, e)
@@ -302,6 +333,7 @@ async def append_event(self, session: Session, event: Event) -> Event:
302333
# Update the in-memory session.
303334
await super().append_event(session=session, event=event)
304335

336+
_validate_session_id(session.id)
305337
reasoning_engine_id = self._get_reasoning_engine_id(session.app_name)
306338

307339
# Build config (Monolithic approach)

tests/unittests/sessions/test_vertex_ai_session_service.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,45 @@ async def test_get_and_delete_session():
725725
assert str(excinfo.value) == '404 Session not found: 1'
726726

727727

728+
@pytest.mark.asyncio
729+
@pytest.mark.usefixtures('mock_get_api_client')
730+
async def test_delete_session_rejects_other_users_session():
731+
"""delete_session must not delete a session owned by a different user."""
732+
session_service = mock_vertex_ai_session_service()
733+
734+
# session '1' belongs to 'user'; 'user2' must not be allowed to delete it.
735+
with pytest.raises(ValueError) as excinfo:
736+
await session_service.delete_session(
737+
app_name='123', user_id='user2', session_id='1'
738+
)
739+
assert 'does not belong to user user2' in str(excinfo.value)
740+
741+
# Session must still exist.
742+
assert (
743+
await session_service.get_session(
744+
app_name='123', user_id='user', session_id='1'
745+
)
746+
== MOCK_SESSION
747+
)
748+
749+
750+
@pytest.mark.asyncio
751+
@pytest.mark.usefixtures('mock_get_api_client')
752+
async def test_session_id_path_traversal_rejected():
753+
"""Session IDs containing path-traversal characters must be rejected."""
754+
session_service = mock_vertex_ai_session_service()
755+
756+
for bad_id in ['..', '../foo', '..?force=true', 'a/b', '']:
757+
with pytest.raises(ValueError):
758+
await session_service.delete_session(
759+
app_name='123', user_id='user', session_id=bad_id
760+
)
761+
with pytest.raises(ValueError):
762+
await session_service.get_session(
763+
app_name='123', user_id='user', session_id=bad_id
764+
)
765+
766+
728767
@pytest.mark.asyncio
729768
@pytest.mark.usefixtures('mock_get_api_client')
730769
async def test_get_session_with_page_token():

0 commit comments

Comments
 (0)