2727
2828import click
2929from fastapi import FastAPI
30+ from fastapi import HTTPException
3031from fastapi import UploadFile
3132from fastapi .responses import FileResponse
3233from 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
0 commit comments