Repository navigation
Security: InMemoryArtifactService follows artifact:// references across app, user, and session scope #6124
Description
Summary
InMemoryArtifactService accepts artifact://... references in save_artifact(), stores them as-is, and later resolves the embedded app_name, user_id, and session_id during load_artifact() without checking that they still match the caller scope.
This allows an attacker to save an artifact in their own scope whose fileData.fileUri points at another app's or user's artifact, then read the victim artifact back through the attacker's own artifact.
Affected code
src/google/adk/artifacts/in_memory_artifact_service.pysrc/google/adk/cli/api_server.pyartifact save/load routes
The save path validates only that the URI parses:
save_artifact()accepts a caller-suppliedartifact://...URI and stores the part unchanged.load_artifact()later parses that URI and recursively loads the embedded target artifact under the embedded scope.
Why this is security-relevant
This is a scope-enforcement failure, not just a convenience bug.
An attacker who is allowed to access only their own artifact API path can still read artifacts belonging to:
- another user in the same app
- another session of the same user
- another app on the same ADK instance
The vulnerable backend is especially relevant because ADK deploy helpers default Cloud Run and GKE deployments to memory:// for session/artifact services when use_local_storage is disabled.
Reproduction
Minimal service-level reproduction:
import asyncio
from google.genai import types
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
async def main():
svc = InMemoryArtifactService()
await svc.save_artifact(
app_name='victim-app',
user_id='victim',
session_id='victim-session',
filename='user:secret.txt',
artifact=types.Part(text='CROSS_APP_SECRET=777'),
)
await svc.save_artifact(
app_name='attacker-app',
user_id='attacker',
session_id='attacker-session',
filename='loot.txt',
artifact=types.Part(
file_data=types.FileData(
file_uri=(
'artifact://apps/victim-app/users/victim/'
'artifacts/user:secret.txt/versions/0'
),
mime_type='text/plain',
)
),
)
leaked = await svc.load_artifact(
app_name='attacker-app',
user_id='attacker',
session_id='attacker-session',
filename='loot.txt',
)
print(leaked.text)
asyncio.run(main())Observed result before patch:
CROSS_APP_SECRET=777
I also reproduced the same behavior through the HTTP artifact API by:
- creating a victim session and saving a victim artifact
- creating an attacker session
- POSTing an attacker artifact whose
artifact.fileData.fileUripoints at the victim artifact - GETting the attacker artifact back
The response returned the victim artifact content.
Expected behavior
Artifact references should only resolve within the caller's own allowed scope:
- same app
- same user
- same session for session-scoped references
User-scoped user: artifacts should still be loadable across sessions for the same app/user pair.
Proposed fix
Validate parsed artifact://... references against the caller scope before storing them and again before dereferencing them on load.
Validation
I have a PR prepared that:
- blocks cross-app and cross-user artifact references on save
- blocks cross-session session-scoped references on load
- preserves same-session references
- preserves same-user
user:references across sessions - adds regression coverage for the allowed and blocked cases
Local validation:
PYTHONPATH=src python -m pytest tests/unittests/artifacts/test_artifact_service.py -k "in_memory_artifact_reference" -q
5 passed
Broader local artifact slice on Windows:
PYTHONPATH=src python -m pytest tests/unittests/artifacts/test_artifact_service.py -q
62 passed, 3 failed
The remaining 3 failures were existing Windows-specific file-artifact path assertions unrelated to this change.
Activity
surajksharma07 commentedon Jun 15, 2026
@petrmarinec Thanks for catching both vulnerable paths — the scope enforcement gap in save_artifact() and the blind dereference in load_artifact() are clearly confirmed, and the repro fires immediately with no setup needed.
The approach in PR #6125 (validating the embedded app_name/user_id/session_id before storing and again before dereferencing, while keeping same-user user: references working across sessions) looks like exactly the right fix.
Could you also confirm the workaround holds for the HTTP API path you mentioned — specifically the POST attacker artifact → GET returning victim content flow — and make sure that scenario gets regression coverage in the test suite?
Once that's verified end-to-end on your side, we'll move forward with #6125.
petrmarinec commentedon Jun 15, 2026
Thanks, confirmed end-to-end on the HTTP artifact API path and pushed regression coverage to PR #6125.
What I verified locally:
InMemoryArtifactServicePOST /apps/attacker-app/users/attacker/sessions/attacker-session/artifactswith a forgedartifact.fileData.fileUripointing at the victim artifactGET /apps/attacker-app/users/attacker/sessions/attacker-session/artifacts/loot.txtreturns 404, so the previous POST attacker artifact -> GET victim content flow no longer worksI added regression coverage for that HTTP flow in
tests/unittests/cli/test_fast_api.pyand re-ran the relevant slices locally:The PR branch now includes both the service-level scope tests and the HTTP API regression test.
surajksharma07 commentedon Jun 16, 2026
@petrmarinec Confirmed, thanks — the HTTP flow test in test_fast_api.py plus the in_memory_artifact_reference cases cover exactly what we asked for, and #6125 is green across all checks (header-check, change-verification, CLA).
Team is looking more into it.
Thanks!
fix: Constrain artifact references to the caller scope
fix: Constrain artifact references to the caller scope