Skip to content

Commit 6f259f0

Browse files
GWealecopybara-github
authored andcommitted
fix: Harden YAML builder tmp save/cleanup
- Add path-safe helpers so all builder filesystem operations stay under <agents_dir>/<app_name> and reject traversal/invalid upload paths. - Rework /builder/save to support tmp=true writes under <app>/tmp/<app>, promote tmp → app root on final save (preserving tools.py/tools/), then clean up tmp on success. - Simplify /builder/app/{app_name}/cancel to best-effort delete tmp; update GET /builder/app/{app_name}?tmp=true to auto-recreate tmp from the app root and safely serve requested files. Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 852366567
1 parent 3ec7ae3 commit 6f259f0

2 files changed

Lines changed: 418 additions & 95 deletions

File tree

src/google/adk/cli/fast_api.py

Lines changed: 229 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -211,75 +211,214 @@ def tear_down_observer(observer: Observer, _: AdkWebServer):
211211
**extra_fast_api_args,
212212
)
213213

214+
agents_base_path = (Path.cwd() / agents_dir).resolve()
215+
216+
def _get_app_root(app_name: str) -> Path:
217+
if app_name in ("", ".", ".."):
218+
raise ValueError(f"Invalid app name: {app_name!r}")
219+
if Path(app_name).name != app_name or "\\" in app_name:
220+
raise ValueError(f"Invalid app name: {app_name!r}")
221+
app_root = (agents_base_path / app_name).resolve()
222+
if not app_root.is_relative_to(agents_base_path):
223+
raise ValueError(f"Invalid app name: {app_name!r}")
224+
return app_root
225+
226+
def _normalize_relative_path(path: str) -> str:
227+
return path.replace("\\", "/").lstrip("/")
228+
229+
def _has_parent_reference(path: str) -> bool:
230+
return any(part == ".." for part in path.split("/"))
231+
232+
def _parse_upload_filename(filename: Optional[str]) -> tuple[str, str]:
233+
if not filename:
234+
raise ValueError("Upload filename is missing.")
235+
filename = _normalize_relative_path(filename)
236+
if "/" not in filename:
237+
raise ValueError(f"Invalid upload filename: {filename!r}")
238+
app_name, rel_path = filename.split("/", 1)
239+
if not app_name or not rel_path:
240+
raise ValueError(f"Invalid upload filename: {filename!r}")
241+
if rel_path.startswith("/"):
242+
raise ValueError(f"Absolute upload path rejected: {filename!r}")
243+
if _has_parent_reference(rel_path):
244+
raise ValueError(f"Path traversal rejected: {filename!r}")
245+
return app_name, rel_path
246+
247+
def _parse_file_path(file_path: str) -> str:
248+
file_path = _normalize_relative_path(file_path)
249+
if not file_path:
250+
raise ValueError("file_path is missing.")
251+
if file_path.startswith("/"):
252+
raise ValueError(f"Absolute file_path rejected: {file_path!r}")
253+
if _has_parent_reference(file_path):
254+
raise ValueError(f"Path traversal rejected: {file_path!r}")
255+
return file_path
256+
257+
def _resolve_under_dir(root_dir: Path, rel_path: str) -> Path:
258+
file_path = root_dir / rel_path
259+
resolved_root_dir = root_dir.resolve()
260+
resolved_file_path = file_path.resolve()
261+
if not resolved_file_path.is_relative_to(resolved_root_dir):
262+
raise ValueError(f"Path escapes root_dir: {rel_path!r}")
263+
return file_path
264+
265+
def _get_tmp_agent_root(app_root: Path, app_name: str) -> Path:
266+
tmp_agent_root = app_root / "tmp" / app_name
267+
resolved_tmp_agent_root = tmp_agent_root.resolve()
268+
if not resolved_tmp_agent_root.is_relative_to(app_root):
269+
raise ValueError(f"Invalid tmp path for app: {app_name!r}")
270+
return tmp_agent_root
271+
272+
def copy_dir_contents(source_dir: Path, dest_dir: Path) -> None:
273+
dest_dir.mkdir(parents=True, exist_ok=True)
274+
for source_path in source_dir.iterdir():
275+
if source_path.name == "tmp":
276+
continue
277+
278+
dest_path = dest_dir / source_path.name
279+
if source_path.is_dir():
280+
if dest_path.exists() and dest_path.is_file():
281+
dest_path.unlink()
282+
shutil.copytree(source_path, dest_path, dirs_exist_ok=True)
283+
elif source_path.is_file():
284+
if dest_path.exists() and dest_path.is_dir():
285+
shutil.rmtree(dest_path)
286+
shutil.copy2(source_path, dest_path)
287+
288+
def cleanup_tmp(app_name: str) -> bool:
289+
try:
290+
app_root = _get_app_root(app_name)
291+
except ValueError as exc:
292+
logger.exception("Error in cleanup_tmp: %s", exc)
293+
return False
294+
295+
try:
296+
tmp_agent_root = _get_tmp_agent_root(app_root, app_name)
297+
except ValueError as exc:
298+
logger.exception("Error in cleanup_tmp: %s", exc)
299+
return False
300+
301+
try:
302+
shutil.rmtree(tmp_agent_root)
303+
except FileNotFoundError:
304+
pass
305+
except OSError as exc:
306+
logger.exception("Error deleting tmp agent root: %s", exc)
307+
return False
308+
309+
tmp_dir = app_root / "tmp"
310+
resolved_tmp_dir = tmp_dir.resolve()
311+
if not resolved_tmp_dir.is_relative_to(app_root):
312+
logger.error(
313+
"Refusing to delete tmp outside app_root: %s", resolved_tmp_dir
314+
)
315+
return False
316+
317+
try:
318+
tmp_dir.rmdir()
319+
except OSError:
320+
pass
321+
322+
return True
323+
324+
def ensure_tmp_exists(app_name: str) -> bool:
325+
try:
326+
app_root = _get_app_root(app_name)
327+
except ValueError as exc:
328+
logger.exception("Error in ensure_tmp_exists: %s", exc)
329+
return False
330+
331+
if not app_root.is_dir():
332+
return False
333+
334+
try:
335+
tmp_agent_root = _get_tmp_agent_root(app_root, app_name)
336+
except ValueError as exc:
337+
logger.exception("Error in ensure_tmp_exists: %s", exc)
338+
return False
339+
340+
if tmp_agent_root.exists():
341+
return True
342+
343+
try:
344+
tmp_agent_root.mkdir(parents=True, exist_ok=True)
345+
copy_dir_contents(app_root, tmp_agent_root)
346+
except OSError as exc:
347+
logger.exception("Error in ensure_tmp_exists: %s", exc)
348+
return False
349+
350+
return True
351+
214352
@app.post("/builder/save", response_model_exclude_none=True)
215353
async def builder_build(
216354
files: list[UploadFile], tmp: Optional[bool] = False
217355
) -> bool:
218-
base_path = Path.cwd() / agents_dir
219-
for file in files:
220-
if not file.filename:
221-
logger.exception("Agent name is missing in the input files")
222-
return False
223-
agent_name, filename = file.filename.split("/")
224-
agent_dir = os.path.join(base_path, agent_name)
225-
try:
226-
# File name format: {app_name}/{agent_name}.yaml
227-
if tmp:
228-
agent_dir = os.path.join(agent_dir, "tmp/" + agent_name)
229-
os.makedirs(agent_dir, exist_ok=True)
230-
file_path = os.path.join(agent_dir, filename)
231-
with open(file_path, "wb") as buffer:
356+
try:
357+
if tmp:
358+
app_names = set()
359+
uploads = []
360+
for file in files:
361+
app_name, rel_path = _parse_upload_filename(file.filename)
362+
app_names.add(app_name)
363+
uploads.append((rel_path, file))
364+
365+
if len(app_names) != 1:
366+
logger.error(
367+
"Exactly one app name is required, found: %s", sorted(app_names)
368+
)
369+
return False
370+
371+
app_name = next(iter(app_names))
372+
app_root = _get_app_root(app_name)
373+
tmp_agent_root = _get_tmp_agent_root(app_root, app_name)
374+
tmp_agent_root.mkdir(parents=True, exist_ok=True)
375+
376+
for rel_path, file in uploads:
377+
destination_path = _resolve_under_dir(tmp_agent_root, rel_path)
378+
destination_path.parent.mkdir(parents=True, exist_ok=True)
379+
with destination_path.open("wb") as buffer:
232380
shutil.copyfileobj(file.file, buffer)
233381

234-
else:
235-
source_dir = os.path.join(agent_dir, "tmp/" + agent_name)
236-
destination_dir = agent_dir
237-
for item in os.listdir(source_dir):
238-
source_item = os.path.join(source_dir, item)
239-
destination_item = os.path.join(destination_dir, item)
240-
if os.path.isdir(source_item):
241-
shutil.copytree(source_item, destination_item, dirs_exist_ok=True)
242-
# Check if the item is a file
243-
elif os.path.isfile(source_item):
244-
shutil.copy2(source_item, destination_item)
245-
except Exception as e:
246-
logger.exception("Error in builder_build: %s", e)
382+
return True
383+
384+
app_names = set()
385+
uploads = []
386+
for file in files:
387+
app_name, rel_path = _parse_upload_filename(file.filename)
388+
app_names.add(app_name)
389+
uploads.append((rel_path, file))
390+
391+
if len(app_names) != 1:
392+
logger.error(
393+
"Exactly one app name is required, found: %s", sorted(app_names)
394+
)
247395
return False
248396

249-
return True
397+
app_name = next(iter(app_names))
398+
app_root = _get_app_root(app_name)
399+
app_root.mkdir(parents=True, exist_ok=True)
400+
401+
tmp_agent_root = _get_tmp_agent_root(app_root, app_name)
402+
if tmp_agent_root.is_dir():
403+
copy_dir_contents(tmp_agent_root, app_root)
404+
405+
for rel_path, file in uploads:
406+
destination_path = _resolve_under_dir(app_root, rel_path)
407+
destination_path.parent.mkdir(parents=True, exist_ok=True)
408+
with destination_path.open("wb") as buffer:
409+
shutil.copyfileobj(file.file, buffer)
410+
411+
return cleanup_tmp(app_name)
412+
except ValueError as exc:
413+
logger.exception("Error in builder_build: %s", exc)
414+
return False
415+
except OSError as exc:
416+
logger.exception("Error in builder_build: %s", exc)
417+
return False
250418

251419
@app.post("/builder/app/{app_name}/cancel", response_model_exclude_none=True)
252420
async def builder_cancel(app_name: str) -> bool:
253-
base_path = Path.cwd() / agents_dir
254-
agent_dir = os.path.join(base_path, app_name)
255-
destination_dir = os.path.join(agent_dir, "tmp/" + app_name)
256-
source_dir = agent_dir
257-
source_items = set(os.listdir(source_dir))
258-
try:
259-
for item in os.listdir(destination_dir):
260-
if item in source_items:
261-
continue
262-
# If it doesn't exist in the source, delete it from the destination
263-
item_path = os.path.join(destination_dir, item)
264-
if os.path.isdir(item_path):
265-
shutil.rmtree(item_path)
266-
elif os.path.isfile(item_path):
267-
os.remove(item_path)
268-
269-
for item in os.listdir(source_dir):
270-
source_item = os.path.join(source_dir, item)
271-
destination_item = os.path.join(destination_dir, item)
272-
if item == "tmp" and os.path.isdir(source_item):
273-
continue
274-
if os.path.isdir(source_item):
275-
shutil.copytree(source_item, destination_item, dirs_exist_ok=True)
276-
# Check if the item is a file
277-
elif os.path.isfile(source_item):
278-
shutil.copy2(source_item, destination_item)
279-
except Exception as e:
280-
logger.exception("Error in builder_build: %s", e)
281-
return False
282-
return True
421+
return cleanup_tmp(app_name)
283422

284423
@app.get(
285424
"/builder/app/{app_name}",
@@ -291,34 +430,42 @@ async def get_agent_builder(
291430
file_path: Optional[str] = None,
292431
tmp: Optional[bool] = False,
293432
):
294-
base_path = Path.cwd() / agents_dir
295-
agent_dir = base_path / app_name
433+
try:
434+
app_root = _get_app_root(app_name)
435+
except ValueError as exc:
436+
logger.exception("Error in get_agent_builder: %s", exc)
437+
return ""
438+
439+
agent_dir = app_root
296440
if tmp:
297-
agent_dir = agent_dir / "tmp"
298-
agent_dir = agent_dir / app_name
299-
if not file_path:
300-
file_name = "root_agent.yaml"
301-
root_file_path = agent_dir / file_name
302-
if not root_file_path.is_file():
441+
if not ensure_tmp_exists(app_name):
303442
return ""
304-
else:
305-
return FileResponse(
306-
path=root_file_path,
307-
media_type="application/x-yaml",
308-
filename="${app_name}.yaml",
309-
headers={"Cache-Control": "no-store"},
310-
)
443+
agent_dir = app_root / "tmp" / app_name
444+
445+
if not file_path:
446+
rel_path = "root_agent.yaml"
311447
else:
312-
agent_file_path = agent_dir / file_path
313-
if not agent_file_path.is_file():
448+
try:
449+
rel_path = _parse_file_path(file_path)
450+
except ValueError as exc:
451+
logger.exception("Error in get_agent_builder: %s", exc)
314452
return ""
315-
else:
316-
return FileResponse(
317-
path=agent_file_path,
318-
media_type="application/x-yaml",
319-
filename=file_path,
320-
headers={"Cache-Control": "no-store"},
321-
)
453+
454+
try:
455+
agent_file_path = _resolve_under_dir(agent_dir, rel_path)
456+
except ValueError as exc:
457+
logger.exception("Error in get_agent_builder: %s", exc)
458+
return ""
459+
460+
if not agent_file_path.is_file():
461+
return ""
462+
463+
return FileResponse(
464+
path=agent_file_path,
465+
media_type="application/x-yaml",
466+
filename=file_path or f"{app_name}.yaml",
467+
headers={"Cache-Control": "no-store"},
468+
)
322469

323470
if a2a:
324471
from a2a.server.apps import A2AStarletteApplication

0 commit comments

Comments
 (0)