Skip to content

Commit f037f68

Browse files
sasha-gitggithub-actions[bot]
authored andcommitted
fix: Disallow args on /builder and Add warning about Web UI usage to CLI help
Co-authored-by: Sasha Sobran <asobran@google.com> PiperOrigin-RevId: 893521804
1 parent 081adbd commit f037f68

File tree

3 files changed

+105
-43
lines changed

3 files changed

+105
-43
lines changed

src/google/adk/cli/cli_tools_click.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1713,7 +1713,8 @@ def cli_api_server(
17131713
default=False,
17141714
help=(
17151715
"Optional. Deploy ADK Web UI if set. (default: deploy ADK API server"
1716-
" only)"
1716+
" only). WARNING: The web UI is for development and testing only — do"
1717+
" not use in production."
17171718
),
17181719
)
17191720
@click.option(
@@ -2229,7 +2230,8 @@ def cli_deploy_agent_engine(
22292230
default=False,
22302231
help=(
22312232
"Optional. Deploy ADK Web UI if set. (default: deploy ADK API server"
2232-
" only)"
2233+
" only). WARNING: The web UI is for development and testing only — do"
2234+
" not use in production."
22332235
),
22342236
)
22352237
@click.option(

src/google/adk/cli/fast_api.py

Lines changed: 60 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
import click
2929
from fastapi import FastAPI
30+
from fastapi import HTTPException
3031
from fastapi import UploadFile
3132
from fastapi.responses import FileResponse
3233
from fastapi.responses import PlainTextResponse
@@ -293,6 +294,39 @@ def _has_parent_reference(path: str) -> bool:
293294

294295
_ALLOWED_EXTENSIONS = frozenset({".yaml", ".yml"})
295296

297+
# --- YAML content security ---
298+
# The `args` key in agent YAML configs (CodeConfig.args, ToolConfig.args)
299+
# allows callers to pass arbitrary arguments to Python constructors and
300+
# functions, which is an RCE vector when exposed through the builder UI.
301+
# Block any upload that contains an `args` key anywhere in the document.
302+
_BLOCKED_YAML_KEYS = frozenset({"args"})
303+
304+
def _check_yaml_for_blocked_keys(content: bytes, filename: str) -> None:
305+
"""Raise if the YAML document contains any blocked keys."""
306+
import yaml
307+
308+
try:
309+
docs = list(yaml.safe_load_all(content))
310+
except yaml.YAMLError as exc:
311+
raise ValueError(f"Invalid YAML in {filename!r}: {exc}") from exc
312+
313+
def _walk(node: Any) -> None:
314+
if isinstance(node, dict):
315+
for key, value in node.items():
316+
if key in _BLOCKED_YAML_KEYS:
317+
raise ValueError(
318+
f"Blocked key {key!r} found in {filename!r}. "
319+
f"The '{key}' field is not allowed in builder uploads "
320+
"because it can execute arbitrary code."
321+
)
322+
_walk(value)
323+
elif isinstance(node, list):
324+
for item in node:
325+
_walk(item)
326+
327+
for doc in docs:
328+
_walk(doc)
329+
296330
def _parse_upload_filename(filename: Optional[str]) -> tuple[str, str]:
297331
if not filename:
298332
raise ValueError("Upload filename is missing.")
@@ -430,40 +464,14 @@ async def builder_build(
430464
files: list[UploadFile], tmp: Optional[bool] = False
431465
) -> bool:
432466
try:
433-
if tmp:
434-
app_names = set()
435-
uploads = []
436-
for file in files:
437-
app_name, rel_path = _parse_upload_filename(file.filename)
438-
app_names.add(app_name)
439-
uploads.append((rel_path, file))
440-
441-
if len(app_names) != 1:
442-
logger.error(
443-
"Exactly one app name is required, found: %s",
444-
sorted(app_names),
445-
)
446-
return False
447-
448-
app_name = next(iter(app_names))
449-
app_root = _get_app_root(app_name)
450-
tmp_agent_root = _get_tmp_agent_root(app_root, app_name)
451-
tmp_agent_root.mkdir(parents=True, exist_ok=True)
452-
453-
for rel_path, file in uploads:
454-
destination_path = _resolve_under_dir(tmp_agent_root, rel_path)
455-
destination_path.parent.mkdir(parents=True, exist_ok=True)
456-
with destination_path.open("wb") as buffer:
457-
shutil.copyfileobj(file.file, buffer)
458-
459-
return True
460-
461-
app_names = set()
462-
uploads = []
467+
# Phase 1: parse filenames and read content into memory.
468+
app_names: set[str] = set()
469+
uploads: list[tuple[str, bytes]] = []
463470
for file in files:
464471
app_name, rel_path = _parse_upload_filename(file.filename)
465472
app_names.add(app_name)
466-
uploads.append((rel_path, file))
473+
content = await file.read()
474+
uploads.append((rel_path, content))
467475

468476
if len(app_names) != 1:
469477
logger.error(
@@ -473,23 +481,40 @@ async def builder_build(
473481
return False
474482

475483
app_name = next(iter(app_names))
484+
485+
# Phase 2: validate every file *before* writing anything to disk.
486+
for rel_path, content in uploads:
487+
_check_yaml_for_blocked_keys(content, f"{app_name}/{rel_path}")
488+
489+
# Phase 3: write validated files to disk.
490+
if tmp:
491+
app_root = _get_app_root(app_name)
492+
tmp_agent_root = _get_tmp_agent_root(app_root, app_name)
493+
tmp_agent_root.mkdir(parents=True, exist_ok=True)
494+
495+
for rel_path, content in uploads:
496+
destination_path = _resolve_under_dir(tmp_agent_root, rel_path)
497+
destination_path.parent.mkdir(parents=True, exist_ok=True)
498+
destination_path.write_bytes(content)
499+
500+
return True
501+
476502
app_root = _get_app_root(app_name)
477503
app_root.mkdir(parents=True, exist_ok=True)
478504

479505
tmp_agent_root = _get_tmp_agent_root(app_root, app_name)
480506
if tmp_agent_root.is_dir():
481507
copy_dir_contents(tmp_agent_root, app_root)
482508

483-
for rel_path, file in uploads:
509+
for rel_path, content in uploads:
484510
destination_path = _resolve_under_dir(app_root, rel_path)
485511
destination_path.parent.mkdir(parents=True, exist_ok=True)
486-
with destination_path.open("wb") as buffer:
487-
shutil.copyfileobj(file.file, buffer)
512+
destination_path.write_bytes(content)
488513

489514
return cleanup_tmp(app_name)
490515
except ValueError as exc:
491516
logger.exception("Error in builder_build: %s", exc)
492-
return False
517+
raise HTTPException(status_code=400, detail=str(exc))
493518
except OSError as exc:
494519
logger.exception("Error in builder_build: %s", exc)
495520
return False

tests/unittests/cli/test_fast_api.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1694,8 +1694,7 @@ def test_builder_save_rejects_traversal(builder_test_client, tmp_path):
16941694
("app/../escape.yaml", b"nope\n", "application/x-yaml"),
16951695
)],
16961696
)
1697-
assert response.status_code == 200
1698-
assert response.json() is False
1697+
assert response.status_code == 400
16991698
assert not (tmp_path / "escape.yaml").exists()
17001699
assert not (tmp_path / "app" / "tmp" / "escape.yaml").exists()
17011700

@@ -1709,8 +1708,7 @@ def test_builder_save_rejects_py_files(builder_test_client, tmp_path):
17091708
("app/agent.py", b"import os\nos.system('id')\n", "text/plain"),
17101709
)],
17111710
)
1712-
assert response.status_code == 200
1713-
assert response.json() is False
1711+
assert response.status_code == 400
17141712
assert not (tmp_path / "app" / "tmp" / "app" / "agent.py").exists()
17151713

17161714

@@ -1732,8 +1730,7 @@ def test_builder_save_rejects_non_yaml_extensions(
17321730
(f"app/file{ext}", content, "application/octet-stream"),
17331731
)],
17341732
)
1735-
assert response.status_code == 200, f"Expected 200 for {ext}"
1736-
assert response.json() is False, f"Expected False for {ext}"
1733+
assert response.status_code == 400, f"Expected 400 for {ext}"
17371734

17381735

17391736
def test_builder_save_allows_yaml_files(builder_test_client, tmp_path):
@@ -1759,6 +1756,44 @@ def test_builder_save_allows_yaml_files(builder_test_client, tmp_path):
17591756
assert response.json() is True
17601757

17611758

1759+
def test_builder_save_rejects_args_key(builder_test_client, tmp_path):
1760+
"""Uploading YAML with an `args` key is rejected (RCE prevention)."""
1761+
yaml_with_args = b"""\
1762+
name: my_tool
1763+
args:
1764+
key: value
1765+
"""
1766+
response = builder_test_client.post(
1767+
"/builder/save?tmp=true",
1768+
files=[(
1769+
"files",
1770+
("app/root_agent.yaml", yaml_with_args, "application/x-yaml"),
1771+
)],
1772+
)
1773+
assert response.status_code == 400
1774+
assert "args" in response.json()["detail"]
1775+
assert not (tmp_path / "app" / "tmp" / "app" / "root_agent.yaml").exists()
1776+
1777+
1778+
def test_builder_save_rejects_nested_args_key(builder_test_client, tmp_path):
1779+
"""Uploading YAML with a nested `args` key is rejected."""
1780+
yaml_with_nested_args = b"""\
1781+
tools:
1782+
- name: some_tool
1783+
args:
1784+
param: value
1785+
"""
1786+
response = builder_test_client.post(
1787+
"/builder/save?tmp=true",
1788+
files=[(
1789+
"files",
1790+
("app/root_agent.yaml", yaml_with_nested_args, "application/x-yaml"),
1791+
)],
1792+
)
1793+
assert response.status_code == 400
1794+
assert "args" in response.json()["detail"]
1795+
1796+
17621797
def test_builder_get_rejects_non_yaml_file_paths(builder_test_client, tmp_path):
17631798
"""GET /builder/app/{app_name}?file_path=... rejects non-YAML extensions."""
17641799
app_root = tmp_path / "app"

0 commit comments

Comments
 (0)