mirror of
https://github.com/blw1138/cross-py-builder.git
synced 2026-09-07 21:41:09 -05:00
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:
@@ -1 +1,4 @@
|
|||||||
known_hosts
|
known_hosts
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
@@ -9,6 +9,7 @@ import time
|
|||||||
|
|
||||||
from flask import Flask, request, jsonify, send_file
|
from flask import Flask, request, jsonify, send_file
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import zipfile
|
import zipfile
|
||||||
import subprocess
|
import subprocess
|
||||||
import glob
|
import glob
|
||||||
@@ -46,26 +47,28 @@ def update_files():
|
|||||||
error_files = []
|
error_files = []
|
||||||
for key in request.files:
|
for key in request.files:
|
||||||
uploaded_file = request.files[key]
|
uploaded_file = request.files[key]
|
||||||
if uploaded_file.filename:
|
original_filename = os.path.basename(uploaded_file.filename or "")
|
||||||
original_filename = uploaded_file.filename
|
if not original_filename:
|
||||||
temp_save_path = os.path.join(LOCAL_DIR, f"{original_filename}.tmp")
|
error_files.append(uploaded_file.filename)
|
||||||
uploaded_file.save(temp_save_path)
|
continue
|
||||||
if os.path.getsize(temp_save_path):
|
temp_save_path = os.path.join(LOCAL_DIR, f"{original_filename}.tmp")
|
||||||
try:
|
uploaded_file.save(temp_save_path)
|
||||||
backup_path = os.path.join(LOCAL_DIR, original_filename + ".old")
|
if os.path.getsize(temp_save_path):
|
||||||
local_file_path = os.path.join(LOCAL_DIR, original_filename)
|
try:
|
||||||
os.rename(local_file_path, backup_path)
|
backup_path = os.path.join(LOCAL_DIR, original_filename + ".old")
|
||||||
shutil.move(temp_save_path, local_file_path)
|
local_file_path = os.path.join(LOCAL_DIR, original_filename)
|
||||||
os.remove(backup_path)
|
os.rename(local_file_path, backup_path)
|
||||||
needs_install_requirements |= (requirements_path == local_file_path)
|
shutil.move(temp_save_path, local_file_path)
|
||||||
updated_files.append(original_filename)
|
os.remove(backup_path)
|
||||||
except Exception as e:
|
needs_install_requirements |= (requirements_path == local_file_path)
|
||||||
print(f"Exception updating file ({original_filename}): {e}")
|
updated_files.append(original_filename)
|
||||||
error_files.append(original_filename)
|
except Exception as e:
|
||||||
else:
|
print(f"Exception updating file ({original_filename}): {e}")
|
||||||
print(f"Invalid size for {temp_save_path}!")
|
|
||||||
error_files.append(original_filename)
|
error_files.append(original_filename)
|
||||||
os.remove(temp_save_path)
|
else:
|
||||||
|
print(f"Invalid size for {temp_save_path}!")
|
||||||
|
error_files.append(original_filename)
|
||||||
|
os.remove(temp_save_path)
|
||||||
|
|
||||||
if os.path.exists(requirements_path) and needs_install_requirements:
|
if os.path.exists(requirements_path) and needs_install_requirements:
|
||||||
print(f"\nInstalling Required Packages...")
|
print(f"\nInstalling Required Packages...")
|
||||||
@@ -159,28 +162,47 @@ def status():
|
|||||||
def generate_job_id():
|
def generate_job_id():
|
||||||
return str(uuid.uuid4()).split('-')[-1]
|
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'])
|
@app.route("/checkout_git", methods=['POST'])
|
||||||
def checkout_project():
|
def checkout_project():
|
||||||
start_time = datetime.datetime.now()
|
if not request.is_json or not request.json.get('repo_url'):
|
||||||
repo_url = request.json.get('repo_url')
|
|
||||||
if not repo_url:
|
|
||||||
return jsonify({'error': 'Repository URL is required'}), 400
|
return jsonify({'error': 'Repository URL is required'}), 400
|
||||||
|
|
||||||
|
repo_url = request.json['repo_url']
|
||||||
print(f"\n========== Checking Out Git Project ==========")
|
print(f"\n========== Checking Out Git Project ==========")
|
||||||
|
|
||||||
job_id = generate_job_id()
|
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:
|
try:
|
||||||
|
os.makedirs(build_root, exist_ok=True)
|
||||||
system_status['status'] = "cloning_repo"
|
system_status['status'] = "cloning_repo"
|
||||||
subprocess.check_call(['git', 'clone', repo_url, repo_dir])
|
subprocess.check_call(['git', 'clone', repo_url, repo_dir])
|
||||||
system_status['status'] = "ready"
|
system_status['status'] = "ready"
|
||||||
except subprocess.CalledProcessError as e:
|
return install_and_build(repo_dir, job_id, start_time)
|
||||||
print(f"Error cloning repo: {e}")
|
except Exception as e:
|
||||||
|
print(f"Error processing checkout: {e}")
|
||||||
system_status['status'] = "ready"
|
system_status['status'] = "ready"
|
||||||
return jsonify({'error': 'Failed to clone repository'}), 500
|
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
|
||||||
|
|
||||||
return install_and_build(repo_dir, job_id, start_time)
|
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'])
|
@app.route('/upload', methods=['POST'])
|
||||||
def upload_project():
|
def upload_project():
|
||||||
@@ -204,12 +226,13 @@ def upload_project():
|
|||||||
# Extract ZIP
|
# Extract ZIP
|
||||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||||
print(f"Extracting uploaded project zip...")
|
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)
|
return install_and_build(working_dir, job_id, start_time)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Uncaught error processing job: {e}")
|
print(f"Uncaught error processing job: {e}")
|
||||||
system_status['status'] = "ready"
|
system_status['status'] = "ready"
|
||||||
|
shutil.rmtree(working_dir, ignore_errors=True)
|
||||||
return jsonify({"error": f"Uncaught error processing job: {e}"}), 500
|
return jsonify({"error": f"Uncaught error processing job: {e}"}), 500
|
||||||
|
|
||||||
def install_and_build(project_path, job_id, start_time):
|
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}")
|
print(f"Error setting up virtual environment: {e}")
|
||||||
system_status['status'] = "ready"
|
system_status['status'] = "ready"
|
||||||
system_status['running_job'] = None
|
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
|
return jsonify({"error": f"Error setting up virtual environment: {e}"}), 500
|
||||||
|
|
||||||
# Install requirements
|
# Install requirements
|
||||||
@@ -255,7 +278,7 @@ def install_and_build(project_path, job_id, start_time):
|
|||||||
print(f"Error installing requirements: {e}")
|
print(f"Error installing requirements: {e}")
|
||||||
system_status['status'] = "ready"
|
system_status['status'] = "ready"
|
||||||
system_status['running_job'] = None
|
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
|
return jsonify({"error": f"Error installing requirements: {e}"}), 500
|
||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
@@ -291,10 +314,7 @@ def install_and_build(project_path, job_id, start_time):
|
|||||||
print(f"Error compiling project: {e}")
|
print(f"Error compiling project: {e}")
|
||||||
system_status['status'] = "ready"
|
system_status['status'] = "ready"
|
||||||
system_status['running_job'] = None
|
system_status['running_job'] = None
|
||||||
try:
|
shutil.rmtree(project_path, ignore_errors=True)
|
||||||
os.remove(project_path)
|
|
||||||
except PermissionError:
|
|
||||||
pass
|
|
||||||
return jsonify({"error": f"Error compiling project: {e}"}), 500
|
return jsonify({"error": f"Error compiling project: {e}"}), 500
|
||||||
|
|
||||||
dist_path = os.path.join(project_path, "dist")
|
dist_path = os.path.join(project_path, "dist")
|
||||||
@@ -331,6 +351,9 @@ def system_os():
|
|||||||
def download_binaries(job_id):
|
def download_binaries(job_id):
|
||||||
"""Handles downloading the compiled PyInstaller binaries for a given job."""
|
"""Handles downloading the compiled PyInstaller binaries for a given job."""
|
||||||
try:
|
try:
|
||||||
|
if not is_valid_job_id(job_id):
|
||||||
|
return jsonify({"error": f"Invalid job id: {job_id}"}), 404
|
||||||
|
|
||||||
# Locate the build directory
|
# Locate the build directory
|
||||||
job_path = os.path.join(TMP_DIR, BUILD_DIR, job_id)
|
job_path = os.path.join(TMP_DIR, BUILD_DIR, job_id)
|
||||||
dist_path = os.path.join(job_path, "dist")
|
dist_path = os.path.join(job_path, "dist")
|
||||||
@@ -359,6 +382,9 @@ def download_binaries(job_id):
|
|||||||
|
|
||||||
@app.route('/delete/<job_id>', methods=['GET'])
|
@app.route('/delete/<job_id>', methods=['GET'])
|
||||||
def delete_project(job_id):
|
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)
|
job_path = os.path.join(TMP_DIR, BUILD_DIR, job_id)
|
||||||
|
|
||||||
if not os.path.exists(job_path):
|
if not os.path.exists(job_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user