From b2593d6d9c98e107c21d763b1dcbb339760f31e6 Mon Sep 17 00:00:00 2001 From: adilburaksen Date: Thu, 7 May 2026 02:54:10 +0300 Subject: [PATCH] fix: enforce path boundary in built-in agent file tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_root_directory.py: prevent path traversal in Agent Builder Assistant - Add _validate_root_directory() to reject absolute paths and '..' from session-state root_directory (closes Flaw 2 from the security report) - Remove the unconditional 'if is_absolute: return' bypass that let any absolute file_path skip root enforcement (closes Flaw 1) - Resolve both candidate and root to real paths, then enforce the boundary with candidate.relative_to(resolved_root); ValueError on escape Affected tools: read_files, write_files, delete_files, explore_project Attack vector: POST /apps/__agent session state + read_files with '/' root_directory or absolute file_path → arbitrary file read/write/delete --- .../utils/resolve_root_directory.py | 71 ++++++++++++++----- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/src/google/adk/cli/built_in_agents/utils/resolve_root_directory.py b/src/google/adk/cli/built_in_agents/utils/resolve_root_directory.py index ca7398733f..8aa8b44dee 100644 --- a/src/google/adk/cli/built_in_agents/utils/resolve_root_directory.py +++ b/src/google/adk/cli/built_in_agents/utils/resolve_root_directory.py @@ -26,6 +26,33 @@ from .path_normalizer import sanitize_generated_file_path +def _validate_root_directory(root_directory: str) -> None: + """Validate that root_directory from session state is safe to use. + + Rejects values that could redirect file operations outside the project root. + + Args: + root_directory: The root_directory value from session state. + + Raises: + ValueError: If root_directory contains unsafe path components. + """ + if not root_directory: + return + if Path(root_directory).is_absolute(): + raise ValueError( + f'root_directory must be a relative path, got: {root_directory!r}' + ) + if any(c in root_directory for c in ['\x00', '\\']): + raise ValueError( + f'root_directory contains invalid characters: {root_directory!r}' + ) + if any(part == '..' for part in Path(root_directory).parts): + raise ValueError( + f"root_directory must not contain '..': {root_directory!r}" + ) + + def resolve_file_path( file_path: str, session_state: Optional[Dict[str, Any]] = None, @@ -43,32 +70,42 @@ def resolve_file_path( Returns: Resolved absolute Path object + + Raises: + ValueError: If the resolved path escapes the project root. """ normalized_path = sanitize_generated_file_path(file_path) file_path_obj = Path(normalized_path) - # If already absolute, use as-is - if file_path_obj.is_absolute(): - return file_path_obj - # Get root directory from session state, default to "./" - root_directory = "./" - if session_state and "root_directory" in session_state: - root_directory = session_state["root_directory"] + root_directory = './' + if session_state and 'root_directory' in session_state: + root_directory = session_state['root_directory'] + _validate_root_directory(root_directory) - # Use the same resolution logic as the main function + # Compute the resolved root as an absolute path root_path_obj = Path(root_directory) - - if root_path_obj.is_absolute(): - resolved_root = root_path_obj + if working_directory: + resolved_root = (Path(working_directory) / root_path_obj).resolve() else: - if working_directory: - resolved_root = Path(working_directory) / root_directory - else: - resolved_root = Path(os.getcwd()) / root_directory + resolved_root = (Path(os.getcwd()) / root_path_obj).resolve() - # Resolve file path relative to root directory - return resolved_root / file_path_obj + # Resolve the candidate path + if file_path_obj.is_absolute(): + candidate = file_path_obj.resolve() + else: + candidate = (resolved_root / file_path_obj).resolve() + + # Enforce boundary: reject paths that escape the project root + try: + candidate.relative_to(resolved_root) + except ValueError as e: + raise ValueError( + f'Path {file_path!r} resolves outside project root' + f' {resolved_root!r}' + ) from e + + return candidate def resolve_file_paths(