diff --git a/app.py b/app.py index ba877de..6723886 100644 --- a/app.py +++ b/app.py @@ -17,10 +17,10 @@ import shutil import logging import urllib.error -from datetime import datetime, timezone -from pathlib import Path -from flask import Flask, request, jsonify, send_from_directory, Response -from werkzeug.exceptions import BadRequest +from datetime import datetime, timezone +from pathlib import Path +from flask import Flask, request, jsonify, send_from_directory, Response +from werkzeug.exceptions import BadRequest # Setup logger for DevShell backend logging logging.basicConfig(level=logging.INFO) @@ -2450,10 +2450,10 @@ def get_all_scripts(): # ─── Security Enhancements ────────────────────────────────────────── @app.before_request -def enforce_security(): - from flask import abort - from urllib.parse import urlparse - +def enforce_security(): + from flask import abort + from urllib.parse import urlparse + # 1. Host Validation (prevents DNS Rebinding) host_only = request.host.split(':')[0] if host_only not in ('127.0.0.1', 'localhost'): @@ -2479,22 +2479,22 @@ def is_valid_local(url): abort(403) else: # Reject if neither is present and request is from a browser - user_agent = request.headers.get('User-Agent', '') - if any(b in user_agent for b in ['Mozilla', 'Chrome', 'Safari', 'Edge']): - abort(403) - - # 3. JSON body validation. Many API handlers safely default missing JSON to - # an empty payload, but malformed JSON should fail before route logic runs. - if request.method in ['POST', 'PUT', 'DELETE', 'PATCH'] and request.is_json: - try: - request.get_json(silent=False) - except BadRequest: - return jsonify({ - "success": False, - "error": "Invalid JSON payload", - }), 400 - -# ─── Routes ─────────────────────────────────────────────────────── + user_agent = request.headers.get('User-Agent', '') + if any(b in user_agent for b in ['Mozilla', 'Chrome', 'Safari', 'Edge']): + abort(403) + + # 3. JSON body validation. Many API handlers safely default missing JSON to + # an empty payload, but malformed JSON should fail before route logic runs. + if request.method in ['POST', 'PUT', 'DELETE', 'PATCH'] and request.is_json: + try: + request.get_json(silent=False) + except BadRequest: + return jsonify({ + "success": False, + "error": "Invalid JSON payload", + }), 400 + +# ─── Routes ─────────────────────────────────────────────────────── @app.route("/") @@ -4030,6 +4030,18 @@ def delete_script(): if os.path.exists(full_path): os.remove(full_path) + # Clean up empty parent category directory (but not SCRIPTS_DIR itself) + parent_dir = os.path.dirname(full_path) + if ( + parent_dir + and os.path.abspath(parent_dir) != os.path.abspath(SCRIPTS_DIR) + and os.path.isdir(parent_dir) + and not os.listdir(parent_dir) + ): + try: + os.rmdir(parent_dir) + except OSError as e: + logger.error(f"Failed to remove empty category folder: {e}") # Clean up favs favs = load_favorites() if rel_path in favs: diff --git a/tests/test_security.py b/tests/test_security.py index 6461af3..a645f56 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -324,3 +324,33 @@ def test_exec_command_requires_terminal_unlock(app_module, client): resp = client.post("/api/exec", json={"command": "echo 1", "password": ""}) assert resp.status_code == 401 +def test_delete_script_cleans_empty_category_dir(app_module, client, tmp_path): + # Setup tmp scripts dir + scripts_dir = tmp_path / "scripts" + scripts_dir.mkdir() + + # 1. Create a category with a single script (should delete category directory when script is deleted) + cat1 = scripts_dir / "cat1" + cat1.mkdir() + (cat1 / "script1.sh").write_text("echo hi") + + # 2. Create a category with two scripts (should NOT delete category directory when only one script is deleted) + cat2 = scripts_dir / "cat2" + cat2.mkdir() + (cat2 / "script1.sh").write_text("echo hi") + (cat2 / "script2.sh").write_text("echo bye") + + with patch.object(app_module, "SCRIPTS_DIR", str(scripts_dir)): + with patch.object(app_module, "check_lock", return_value=True): + # Delete single script in cat1 + resp1 = client.delete("/api/scripts/delete", json={"path": "cat1/script1.sh"}) + assert resp1.status_code == 200 + assert not (cat1 / "script1.sh").exists() + assert not cat1.exists() # category directory should be deleted + + # Delete one script in cat2 + resp2 = client.delete("/api/scripts/delete", json={"path": "cat2/script1.sh"}) + assert resp2.status_code == 200 + assert not (cat2 / "script1.sh").exists() + assert cat2.exists() # category directory should still exist + assert (cat2 / "script2.sh").exists()