From bb6a5d7215e0416bdd574670c3de34df4a9acfee Mon Sep 17 00:00:00 2001 From: Sabu Siyad Date: Tue, 9 Jun 2026 15:09:36 +0530 Subject: [PATCH 1/5] fix(downloads): Use random generated name for files (cherry picked from commit b26e4a49ec52c1e608055a32068a73e43d923aa5) --- agent/base.py | 14 +++++++++++++- agent/site.py | 39 +++++++++++++++++++++++++++++++++------ agent/utils.py | 4 ++-- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/agent/base.py b/agent/base.py index 423c8e5a..23ddc22a 100644 --- a/agent/base.py +++ b/agent/base.py @@ -91,13 +91,25 @@ def execute( def run_subprocess(self, command, directory, input, executable, non_zero_throw=True): # Start a child process and start reading output immediately + if isinstance(command, str): + import warnings + + warnings.warn( + "String commands are deprecated; use list form for safety", + DeprecationWarning, + stacklevel=2, + ) + use_shell = True + else: + use_shell = False + with subprocess.Popen( command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.PIPE if input else None, cwd=directory, - shell=True, + shell=use_shell, executable=executable, ) as process: if input: diff --git a/agent/site.py b/agent/site.py index 55d87df1..807c61c8 100644 --- a/agent/site.py +++ b/agent/site.py @@ -5,6 +5,7 @@ import os import re import shutil +import tarfile import time from datetime import datetime from shlex import quote @@ -136,6 +137,30 @@ def restore_site( finally: self.bench.drop_mariadb_user(self.name, mariadb_root_password, self.database) + @staticmethod + def _safe_extract_tar(path: str, dest: str, strip: int = 0): + """Extract a tar archive safely using Python tarfile. + + Strips ``strip`` leading path components from each member. + Rejects symlinks, hardlinks, absolute paths, and parent-traversal. + """ + with tarfile.open(path) as tar: + members = tar.getmembers() + for member in members: + if member.issym() or member.islnk(): + raise tarfile.ExtractError(f"Refusing to extract link: {member.name}") + if os.path.isabs(member.name): + raise tarfile.ExtractError(f"Refusing absolute path: {member.name}") + parts = member.name.split("/") + if ".." in parts: + raise tarfile.ExtractError(f"Refusing parent traversal: {member.name}") + if strip: + member.name = "/".join(parts[strip:]) + target = os.path.realpath(os.path.join(dest, member.name)) + if not target.startswith(os.path.realpath(dest)): + raise tarfile.ExtractError(f"Refusing path outside destination: {member.name}") + tar.extractall(path=dest, members=members) + @step("Restore Files") def restore_files( self, @@ -152,9 +177,10 @@ def restore_files( finally: os.makedirs(dir_path, exist_ok=True) - self.execute( - f"tar {'z' if public_file.endswith('.tgz') else ''}xvf {public_file} --strip 2", - directory=os.path.join(sites_directory, self.name), + self._safe_extract_tar( + public_file, + dest=os.path.join(sites_directory, self.name), + strip=2, ) if private_file: @@ -164,9 +190,10 @@ def restore_files( finally: os.makedirs(dir_path, exist_ok=True) - self.execute( - f"tar {'z' if private_file.endswith('.tgz') else ''}xvf {private_file} --strip 2", - directory=os.path.join(sites_directory, self.name), + self._safe_extract_tar( + private_file, + dest=os.path.join(sites_directory, self.name), + strip=2, ) @step("Checksum of Downloaded Backup Files") diff --git a/agent/utils.py b/agent/utils.py index 0498c2a2..733e0d7d 100644 --- a/agent/utils.py +++ b/agent/utils.py @@ -3,6 +3,7 @@ import hashlib import os import re +import secrets import shutil import struct import subprocess @@ -10,7 +11,6 @@ from datetime import datetime, timedelta from math import ceil from typing import TYPE_CHECKING -from urllib.parse import urlparse import requests @@ -58,7 +58,7 @@ def to_bytes(size_str: str) -> float: def download_file(url, prefix): """Download file locally under path prefix and return local path""" - filename = urlparse(url).path.split("/")[-1] + filename = secrets.token_urlsafe(16) local_filename = os.path.join(prefix, filename) with requests.get(url, stream=True) as r: From cac0ee0e639f8a4b2df609a3566d190fe9d9b22f Mon Sep 17 00:00:00 2001 From: Sabu Siyad Date: Tue, 9 Jun 2026 15:14:45 +0530 Subject: [PATCH 2/5] fix(site): Check for empty paths after strip (cherry picked from commit 065da0da921082382ef2076942bc1479def178c6) --- agent/site.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/agent/site.py b/agent/site.py index 807c61c8..71b6f26d 100644 --- a/agent/site.py +++ b/agent/site.py @@ -155,9 +155,13 @@ def _safe_extract_tar(path: str, dest: str, strip: int = 0): if ".." in parts: raise tarfile.ExtractError(f"Refusing parent traversal: {member.name}") if strip: - member.name = "/".join(parts[strip:]) + stripped = "/".join(parts[strip:]) + if not stripped: + continue + member.name = stripped + dest_real = os.path.realpath(dest) target = os.path.realpath(os.path.join(dest, member.name)) - if not target.startswith(os.path.realpath(dest)): + if not target.startswith(dest_real + os.sep): raise tarfile.ExtractError(f"Refusing path outside destination: {member.name}") tar.extractall(path=dest, members=members) From 060df0f5f5c0a769c0fd8b616ea4e9302efdaef9 Mon Sep 17 00:00:00 2001 From: Sabu Siyad Date: Tue, 9 Jun 2026 15:32:43 +0530 Subject: [PATCH 3/5] fix(downloads): Keep extension with random name (cherry picked from commit d887d5c1e010f9e1b62bbcc26707fa48ec1957ac) --- agent/utils.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/agent/utils.py b/agent/utils.py index 733e0d7d..d9b1ddc8 100644 --- a/agent/utils.py +++ b/agent/utils.py @@ -11,6 +11,7 @@ from datetime import datetime, timedelta from math import ceil from typing import TYPE_CHECKING +from urllib.parse import urlparse import requests @@ -58,7 +59,11 @@ def to_bytes(size_str: str) -> float: def download_file(url, prefix): """Download file locally under path prefix and return local path""" - filename = secrets.token_urlsafe(16) + basename = os.path.basename(urlparse(url).path) + ext = basename[basename.index(".") :] if "." in basename else "" + if ext and not all(c.isalnum() or c in "._-" for c in ext): + ext = "" + filename = secrets.token_urlsafe(16) + ext local_filename = os.path.join(prefix, filename) with requests.get(url, stream=True) as r: From 66eff56c57d0f2d3e65e1d12072c1f07188c2940 Mon Sep 17 00:00:00 2001 From: Sabu Siyad Date: Tue, 9 Jun 2026 15:54:37 +0530 Subject: [PATCH 4/5] fix(downloads): Check if extension is known (cherry picked from commit bba7299c858bfef1a7a70fd9d12e303cdfebceb2) --- agent/base.py | 5 +++++ agent/utils.py | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/agent/base.py b/agent/base.py index 23ddc22a..574a51b7 100644 --- a/agent/base.py +++ b/agent/base.py @@ -102,6 +102,11 @@ def run_subprocess(self, command, directory, input, executable, non_zero_throw=T use_shell = True else: use_shell = False + if executable is not None: + raise TypeError( + "executable is not supported with list-form commands; " + "include the binary as the first element of the list" + ) with subprocess.Popen( command, diff --git a/agent/utils.py b/agent/utils.py index d9b1ddc8..000c0192 100644 --- a/agent/utils.py +++ b/agent/utils.py @@ -60,7 +60,11 @@ def to_bytes(size_str: str) -> float: def download_file(url, prefix): """Download file locally under path prefix and return local path""" basename = os.path.basename(urlparse(url).path) - ext = basename[basename.index(".") :] if "." in basename else "" + ext = "" + for known in (".sql.gz", ".tar.gz", ".tgz", ".sql", ".gz", ".tar"): + if basename.endswith(known): + ext = known + break if ext and not all(c.isalnum() or c in "._-" for c in ext): ext = "" filename = secrets.token_urlsafe(16) + ext From b9bb7fb02285713ce316852486ce32da62409ac6 Mon Sep 17 00:00:00 2001 From: Sabu Siyad Date: Tue, 9 Jun 2026 16:58:13 +0530 Subject: [PATCH 5/5] fix(downloads): Only extract valid paths (cherry picked from commit 1c5a6b8ef0f37e451339408a1f8169972e118229) --- agent/site.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/agent/site.py b/agent/site.py index 71b6f26d..6b768dc9 100644 --- a/agent/site.py +++ b/agent/site.py @@ -146,6 +146,7 @@ def _safe_extract_tar(path: str, dest: str, strip: int = 0): """ with tarfile.open(path) as tar: members = tar.getmembers() + valid = [] for member in members: if member.issym() or member.islnk(): raise tarfile.ExtractError(f"Refusing to extract link: {member.name}") @@ -163,7 +164,8 @@ def _safe_extract_tar(path: str, dest: str, strip: int = 0): target = os.path.realpath(os.path.join(dest, member.name)) if not target.startswith(dest_real + os.sep): raise tarfile.ExtractError(f"Refusing path outside destination: {member.name}") - tar.extractall(path=dest, members=members) + valid.append(member) + tar.extractall(path=dest, members=valid) @step("Restore Files") def restore_files(