Skip to content

Commit b8de426

Browse files
MUHAMMEDHAFEEZcopybara-github
authored andcommitted
fix: reject the reserved segment 'user' as a session_id
Merge #7064 Reject 'user' (and session IDs starting with 'user/') as a session_id in InMemoryArtifactService and GcsArtifactService to prevent collisions with user-scoped artifacts. Fixes #7063 PiperOrigin-RevId: 981588022
1 parent 2e6ec4a commit b8de426

5 files changed

Lines changed: 168 additions & 0 deletions

File tree

‎src/google/adk/artifacts/artifact_util.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,30 @@ def _is_drive_qualified(value: str) -> bool:
155155
return _WINDOWS_DRIVE_RE.match(value) is not None
156156

157157

158+
def _validate_session_id_for_flat_storage(session_id: str) -> None:
159+
"""Validates a session_id used by flat storage artifact backends.
160+
161+
In addition to the checks in `validate_path_segment`, rejects values whose
162+
first path segment is the reserved value "user". Backends that lay out
163+
session-scoped and user-scoped artifacts in the same flat namespace
164+
(in-memory, GCS) use that exact string as a reserved segment marking
165+
user-scoped artifacts, so a session starting with "user" would silently write
166+
into -- and read out of -- that reserved namespace instead of its own.
167+
168+
Args:
169+
session_id: The caller-supplied session id.
170+
171+
Raises:
172+
InputValidationError: If `session_id` fails `validate_path_segment`, or has
173+
the reserved value "user" as its first path segment.
174+
"""
175+
validate_path_segment(session_id, "session_id")
176+
if session_id.replace("\\", "/").split("/")[0] == "user":
177+
raise input_validation_error.InputValidationError(
178+
"session_id must not be or start with the reserved value 'user'."
179+
)
180+
181+
158182
def validate_path_segment(value: str, field_name: str) -> None:
159183
"""Rejects values that could alter the constructed path.
160184

‎src/google/adk/artifacts/gcs_artifact_service.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,13 @@ def _save_artifact(
256256
) -> int:
257257
from google.cloud import exceptions # pylint: disable=g-import-not-at-top
258258

259+
if not self._file_has_user_namespace(filename):
260+
if session_id is None:
261+
raise InputValidationError(
262+
"Session ID must be provided for session-scoped artifacts."
263+
)
264+
artifact_util._validate_session_id_for_flat_storage(session_id)
265+
259266
artifact = ensure_part(artifact)
260267
blob_metadata = {k: str(v) for k, v in (custom_metadata or {}).items()}
261268
if artifact.inline_data and artifact.inline_data.display_name:

‎src/google/adk/artifacts/in_memory_artifact_service.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ async def save_artifact(
126126
session_id: Optional[str] = None,
127127
custom_metadata: Optional[dict[str, Any]] = None,
128128
) -> int:
129+
if not self._file_has_user_namespace(filename):
130+
if session_id is None:
131+
raise InputValidationError(
132+
"Session ID must be provided for session-scoped artifacts."
133+
)
134+
artifact_util._validate_session_id_for_flat_storage(session_id)
129135
artifact = ensure_part(artifact)
130136
path = self._artifact_path(app_name, user_id, filename, session_id)
131137
if path not in self.artifacts:

‎tests/unittests/artifacts/test_artifact_service.py‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,111 @@ async def test_save_load_delete(service_type, artifact_service_factory):
305305
)
306306

307307

308+
@pytest.mark.asyncio
309+
@pytest.mark.parametrize(
310+
"service_type",
311+
[
312+
ArtifactServiceType.IN_MEMORY,
313+
ArtifactServiceType.GCS,
314+
],
315+
)
316+
@pytest.mark.parametrize("session_id", ["user", "user/x", "user\\x"])
317+
async def test_save_artifact_rejects_reserved_user_as_session_id(
318+
service_type, session_id, artifact_service_factory
319+
):
320+
"""IN_MEMORY and GCS lay session-scoped and user-scoped artifacts out in
321+
the same flat namespace, using the literal segment "user" to mark
322+
user-scoped ones. A session actually named "user" (or starting with "user/")
323+
must be rejected rather than silently colliding with that reserved segment."""
324+
artifact_service = artifact_service_factory(service_type)
325+
326+
with pytest.raises(InputValidationError, match="reserved value 'user'"):
327+
await artifact_service.save_artifact(
328+
app_name="app0",
329+
user_id="user0",
330+
session_id=session_id,
331+
filename="report.txt",
332+
artifact=types.Part(text="hello"),
333+
)
334+
335+
336+
@pytest.mark.asyncio
337+
@pytest.mark.parametrize("session_id", ["user", "user/x", "user\\x"])
338+
async def test_file_allows_reserved_user_as_session_id(
339+
session_id,
340+
artifact_service_factory,
341+
):
342+
"""Unlike IN_MEMORY and GCS, FILE lays session-scoped artifacts out under
343+
their own `sessions/<id>/` subtree, distinct from the user-scoped
344+
`artifacts/` subtree, so a session literally named "user" cannot collide
345+
with it and is not rejected."""
346+
artifact_service = artifact_service_factory(ArtifactServiceType.FILE)
347+
348+
await artifact_service.save_artifact(
349+
app_name="app0",
350+
user_id="user0",
351+
session_id=session_id,
352+
filename="report.txt",
353+
artifact=types.Part(text="hello"),
354+
)
355+
loaded = await artifact_service.load_artifact(
356+
app_name="app0",
357+
user_id="user0",
358+
session_id=session_id,
359+
filename="report.txt",
360+
)
361+
assert loaded == types.Part(text="hello")
362+
363+
364+
@pytest.mark.asyncio
365+
@pytest.mark.parametrize(
366+
"service_type",
367+
[
368+
ArtifactServiceType.IN_MEMORY,
369+
ArtifactServiceType.GCS,
370+
],
371+
)
372+
@pytest.mark.parametrize("session_id", ["user", "user/x", "user\\x"])
373+
async def test_read_and_delete_paths_allow_reserved_user_as_session_id(
374+
service_type, session_id, artifact_service_factory
375+
):
376+
"""Reads and deletes must remain permissive for session IDs named "user" or
377+
starting with "user/", so existing data already stored under that prefix in a
378+
live bucket or memory store remains reachable and removable."""
379+
artifact_service = artifact_service_factory(service_type)
380+
381+
assert (
382+
await artifact_service.list_artifact_keys(
383+
app_name="app0", user_id="user0", session_id=session_id
384+
)
385+
== []
386+
)
387+
assert (
388+
await artifact_service.load_artifact(
389+
app_name="app0",
390+
user_id="user0",
391+
session_id=session_id,
392+
filename="report.txt",
393+
)
394+
is None
395+
)
396+
assert (
397+
await artifact_service.list_versions(
398+
app_name="app0",
399+
user_id="user0",
400+
session_id=session_id,
401+
filename="report.txt",
402+
)
403+
== []
404+
)
405+
await artifact_service.delete_artifact(
406+
app_name="app0",
407+
user_id="user0",
408+
session_id=session_id,
409+
filename="report.txt",
410+
)
411+
412+
308413
@pytest.mark.asyncio
309414
async def test_in_memory_loads_nested_artifact_reference(
310415
artifact_service_factory,

‎tests/unittests/artifacts/test_artifact_util.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,32 @@ def test_validate_path_segment_invalid(value, field_name):
201201
artifact_util.validate_path_segment(value, field_name)
202202

203203

204+
@pytest.mark.parametrize(
205+
"session_id",
206+
["user", "user/x", "user\\x", "user/has/slash", "user\\has\\backslash"],
207+
)
208+
def test_validate_session_id_for_flat_storage_rejects_reserved_user(
209+
session_id: str,
210+
):
211+
with pytest.raises(InputValidationError, match="reserved value 'user'"):
212+
artifact_util._validate_session_id_for_flat_storage(session_id)
213+
214+
215+
@pytest.mark.parametrize(
216+
"session_id",
217+
["session1", "users", "username", "group/user", "has/slash"],
218+
)
219+
def test_validate_session_id_for_flat_storage_allows_ordinary_values(
220+
session_id: str,
221+
):
222+
artifact_util._validate_session_id_for_flat_storage(session_id)
223+
224+
225+
def test_validate_session_id_for_flat_storage_still_runs_path_segment_checks():
226+
with pytest.raises(InputValidationError, match="must not be empty"):
227+
artifact_util._validate_session_id_for_flat_storage("")
228+
229+
204230
@pytest.mark.parametrize(
205231
"caller_session_id, uri_session_id",
206232
[

0 commit comments

Comments
 (0)