Harden build agent against path traversal and add job cleanup

- Validate job IDs on /download and /delete; reject invalid identifiers
- Prevent zip-slip during /upload extraction via safe_extract
- Basename uploaded filenames in /update to block path traversal
- Route git-checkout jobs into pybuild-data so they can be downloaded/deleted
- Clean up job dirs on extract, checkout, and build failures
- Ignore Python bytecode and egg-info artifacts
This commit is contained in:
Brett Williams
2026-08-30 21:24:54 -05:00
parent b511962a1f
commit 1902d36ae7
2 changed files with 64 additions and 35 deletions
+3
View File
@@ -1 +1,4 @@
known_hosts
__pycache__/
*.py[cod]
*.egg-info/
+44 -18
View File
@@ -9,6 +9,7 @@ import time
from flask import Flask, request, jsonify, send_file
import os
import re
import zipfile
import subprocess
import glob
@@ -46,8 +47,10 @@ def update_files():
error_files = []
for key in request.files:
uploaded_file = request.files[key]
if uploaded_file.filename:
original_filename = uploaded_file.filename
original_filename = os.path.basename(uploaded_file.filename or "")
if not original_filename:
error_files.append(uploaded_file.filename)
continue
temp_save_path = os.path.join(LOCAL_DIR, f"{original_filename}.tmp")
uploaded_file.save(temp_save_path)
if os.path.getsize(temp_save_path):
@@ -159,28 +162,47 @@ def status():
def generate_job_id():
return str(uuid.uuid4()).split('-')[-1]
JOB_ID_PATTERN = re.compile(r"[0-9a-f]{12}")
def is_valid_job_id(job_id):
return bool(job_id) and bool(JOB_ID_PATTERN.fullmatch(job_id))
@app.route("/checkout_git", methods=['POST'])
def checkout_project():
start_time = datetime.datetime.now()
repo_url = request.json.get('repo_url')
if not repo_url:
if not request.is_json or not request.json.get('repo_url'):
return jsonify({'error': 'Repository URL is required'}), 400
repo_url = request.json['repo_url']
print(f"\n========== Checking Out Git Project ==========")
job_id = generate_job_id()
repo_dir = os.path.join(TMP_DIR, job_id)
build_root = os.path.join(TMP_DIR, BUILD_DIR)
repo_dir = os.path.join(build_root, job_id)
start_time = datetime.datetime.now()
try:
os.makedirs(build_root, exist_ok=True)
system_status['status'] = "cloning_repo"
subprocess.check_call(['git', 'clone', repo_url, repo_dir])
system_status['status'] = "ready"
except subprocess.CalledProcessError as e:
print(f"Error cloning repo: {e}")
system_status['status'] = "ready"
return jsonify({'error': 'Failed to clone repository'}), 500
return install_and_build(repo_dir, job_id, start_time)
except Exception as e:
print(f"Error processing checkout: {e}")
system_status['status'] = "ready"
system_status['running_job'] = None
shutil.rmtree(repo_dir, ignore_errors=True)
if isinstance(e, subprocess.CalledProcessError):
return jsonify({'error': 'Failed to clone repository'}), 500
return jsonify({'error': f"Uncaught error processing checkout: {e}"}), 500
def safe_extract(zip_ref, dest_dir):
"""Extract a zipfile, rejecting members that would escape dest_dir."""
dest_dir = os.path.realpath(dest_dir)
for member in zip_ref.infolist():
member_path = os.path.realpath(os.path.join(dest_dir, member.filename))
if os.path.commonpath([dest_dir, member_path]) != dest_dir:
raise ValueError(f"Unsafe path in archive: {member.filename!r}")
zip_ref.extract(member, dest_dir)
@app.route('/upload', methods=['POST'])
def upload_project():
@@ -204,12 +226,13 @@ def upload_project():
# Extract ZIP
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
print(f"Extracting uploaded project zip...")
zip_ref.extractall(working_dir)
safe_extract(zip_ref, working_dir)
return install_and_build(working_dir, job_id, start_time)
except Exception as e:
print(f"Uncaught error processing job: {e}")
system_status['status'] = "ready"
shutil.rmtree(working_dir, ignore_errors=True)
return jsonify({"error": f"Uncaught error processing job: {e}"}), 500
def install_and_build(project_path, job_id, start_time):
@@ -238,7 +261,7 @@ def install_and_build(project_path, job_id, start_time):
print(f"Error setting up virtual environment: {e}")
system_status['status'] = "ready"
system_status['running_job'] = None
os.rmdir(project_path)
shutil.rmtree(project_path, ignore_errors=True)
return jsonify({"error": f"Error setting up virtual environment: {e}"}), 500
# Install requirements
@@ -255,7 +278,7 @@ def install_and_build(project_path, job_id, start_time):
print(f"Error installing requirements: {e}")
system_status['status'] = "ready"
system_status['running_job'] = None
os.rmdir(project_path)
shutil.rmtree(project_path, ignore_errors=True)
return jsonify({"error": f"Error installing requirements: {e}"}), 500
results = {}
@@ -291,10 +314,7 @@ def install_and_build(project_path, job_id, start_time):
print(f"Error compiling project: {e}")
system_status['status'] = "ready"
system_status['running_job'] = None
try:
os.remove(project_path)
except PermissionError:
pass
shutil.rmtree(project_path, ignore_errors=True)
return jsonify({"error": f"Error compiling project: {e}"}), 500
dist_path = os.path.join(project_path, "dist")
@@ -331,6 +351,9 @@ def system_os():
def download_binaries(job_id):
"""Handles downloading the compiled PyInstaller binaries for a given job."""
try:
if not is_valid_job_id(job_id):
return jsonify({"error": f"Invalid job id: {job_id}"}), 404
# Locate the build directory
job_path = os.path.join(TMP_DIR, BUILD_DIR, job_id)
dist_path = os.path.join(job_path, "dist")
@@ -359,6 +382,9 @@ def download_binaries(job_id):
@app.route('/delete/<job_id>', methods=['GET'])
def delete_project(job_id):
if not is_valid_job_id(job_id):
return jsonify({"error": f"Invalid job id: {job_id}"}), 404
job_path = os.path.join(TMP_DIR, BUILD_DIR, job_id)
if not os.path.exists(job_path):