mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
hardening of script handling with configurable variables - making script execution more secure
This commit is contained in:
parent
055d2604ca
commit
55a5180ee1
2 changed files with 76 additions and 8 deletions
|
|
@ -1,28 +1,81 @@
|
|||
# connect/handlers/script.py
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
from django.conf import settings
|
||||
from .base import IntegrationHandler
|
||||
|
||||
|
||||
def _is_path_allowed(real_path: str) -> bool:
|
||||
# Ensure path is within one of the allowed directories
|
||||
for base in getattr(settings, "CONNECT_ALLOWED_SCRIPT_DIRS", []):
|
||||
base_abs = os.path.abspath(base) + os.sep
|
||||
if real_path.startswith(base_abs):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class ScriptHandler(IntegrationHandler):
|
||||
def execute(self):
|
||||
script_path = self.integration.config.get("path")
|
||||
raw_path = self.integration.config.get("path")
|
||||
if not raw_path:
|
||||
raise ValueError("Missing 'path' in integration config")
|
||||
|
||||
# Build environment variables from payload (prefixed for clarity)
|
||||
env = os.environ.copy()
|
||||
# Resolve and validate path
|
||||
real_path = os.path.abspath(os.path.realpath(raw_path))
|
||||
|
||||
if not os.path.exists(real_path):
|
||||
raise FileNotFoundError(f"Script not found: {real_path}")
|
||||
|
||||
if not _is_path_allowed(real_path):
|
||||
raise PermissionError(
|
||||
f"Script path '{real_path}' not within allowed directories: "
|
||||
f"{getattr(settings, 'CONNECT_ALLOWED_SCRIPT_DIRS', [])}"
|
||||
)
|
||||
|
||||
if getattr(settings, "CONNECT_SCRIPT_REQUIRE_EXECUTABLE", True):
|
||||
if not os.access(real_path, os.X_OK):
|
||||
raise PermissionError(f"Script is not executable: {real_path}")
|
||||
|
||||
if getattr(settings, "CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE", True):
|
||||
st = os.stat(real_path)
|
||||
if st.st_mode & stat.S_IWOTH:
|
||||
raise PermissionError(
|
||||
f"Refusing to execute world-writable script: {real_path}"
|
||||
)
|
||||
|
||||
# Build a sanitized minimal environment; avoid inheriting secrets
|
||||
env = {
|
||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
}
|
||||
for key, value in (self.payload or {}).items():
|
||||
# Convert keys to upper snake case and prefix
|
||||
env_key = f"DISPATCHARR_{str(key).upper()}"
|
||||
env[env_key] = str(value) if value is not None else ""
|
||||
env[env_key] = "" if value is None else str(value)
|
||||
|
||||
# Run with a timeout to prevent hanging scripts
|
||||
timeout = getattr(settings, "CONNECT_SCRIPT_TIMEOUT", 10)
|
||||
max_out = getattr(settings, "CONNECT_SCRIPT_MAX_OUTPUT", 65536)
|
||||
|
||||
result = subprocess.run(
|
||||
[script_path],
|
||||
[real_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
cwd=os.path.dirname(real_path) or None,
|
||||
)
|
||||
|
||||
# Truncate outputs to avoid excessive memory/logging
|
||||
stdout = result.stdout or ""
|
||||
stderr = result.stderr or ""
|
||||
if len(stdout) > max_out:
|
||||
stdout = stdout[:max_out] + "... [truncated]"
|
||||
if len(stderr) > max_out:
|
||||
stderr = stderr[:max_out] + "... [truncated]"
|
||||
|
||||
return {
|
||||
"exit_code": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"success": result.returncode == 0,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -412,3 +412,18 @@ LOGGING = {
|
|||
"level": LOG_LEVEL, # Use user-configured level instead of hardcoded 'INFO'
|
||||
},
|
||||
}
|
||||
|
||||
# Connect script execution safety settings
|
||||
# Allowed base directories for custom scripts; real paths must be inside
|
||||
_allowed_dirs_env = os.environ.get("DISPATCHARR_ALLOWED_SCRIPT_DIRS", "/data/plugins")
|
||||
CONNECT_ALLOWED_SCRIPT_DIRS = [p for p in _allowed_dirs_env.split(":") if p]
|
||||
|
||||
# Max execution time (seconds) for scripts
|
||||
CONNECT_SCRIPT_TIMEOUT = int(os.environ.get("DISPATCHARR_SCRIPT_TIMEOUT", "10"))
|
||||
|
||||
# Truncate stdout/stderr to this many characters to avoid large outputs
|
||||
CONNECT_SCRIPT_MAX_OUTPUT = int(os.environ.get("DISPATCHARR_SCRIPT_MAX_OUTPUT", "65536"))
|
||||
|
||||
# Require executable bit and disallow world-writable files
|
||||
CONNECT_SCRIPT_REQUIRE_EXECUTABLE = True
|
||||
CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE = True
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue