From a879543a1cc2a0dbc10a7ee0397a67faa9e17bb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 07:46:53 +0000 Subject: [PATCH] Security audit: fix XSS, missing function, improve SSH & URL handling - Fix XSS: HTML-escape all user input (URLs, package names, errors, proxy data) - Fix NameError: add missing is_demo_link() function (called but undefined) - Fix: remove unused http_in fetch in proxies_get() - Security: mask API keys in log output (TMDB key no longer visible in logs) - Security: use known_hosts for SSH host key verification when available - Security: remove .env from git tracking, add .env.example template - Usability: add URL reachability check before submitting to JDownloader - Usability: add "Erledigte Jobs entfernen" button to clear finished/failed jobs - Usability: color-code job status (red for failed, green for finished) - Docs: add security section to README (known_hosts, HTTPS, .env) https://claude.ai/code/session_01S774Pqazr2U8vkSyhUBgDs --- .env => .env.example | 6 +++ README.md | 12 ++++- jd-webgui/app.py | 106 +++++++++++++++++++++++++++++++++++-------- 3 files changed, 104 insertions(+), 20 deletions(-) rename .env => .env.example (82%) diff --git a/.env b/.env.example similarity index 82% rename from .env rename to .env.example index ede07b7..ef22406 100644 --- a/.env +++ b/.env.example @@ -53,3 +53,9 @@ BASIC_AUTH_PASS=CHANGE_ME # ===== Polling ===== POLL_SECONDS=5 + +# ===== SSH host key verification (optional) ===== +# Path to known_hosts file inside container. If present, strict host key +# checking is used. If absent, all host keys are accepted (less secure). +# Generate with: ssh-keyscan -p 22 192.168.1.1 > known_hosts +# SSH_KNOWN_HOSTS=/ssh/known_hosts diff --git a/README.md b/README.md index 7f293a0..5fc72db 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Web GUI to: ## Files - `docker-compose.yml` – stack -- `.env.example` – copy to `.env` and fill values +- `.env.example` – copy to `.env` and fill in your values (**never commit `.env`!**) - `jd-webgui/app.py` – FastAPI web app - `jd-webgui/Dockerfile` – includes ffprobe @@ -40,6 +40,16 @@ docker compose up -d --build - If `MYJD_DEVICE` is empty, the WebGUI will automatically pick the first available device. - Ensure the SSH user can write to `/jellyfin/Filme` (and series dir if used). +## Security +- **Never commit `.env`** – it contains passwords and API keys. Only `.env.example` is tracked. +- **SSH host key verification**: For secure SFTP transfers, provide a `known_hosts` file: + ```bash + ssh-keyscan -p 22 192.168.1.1 > known_hosts + ``` + Mount it in `docker-compose.yml` and set `SSH_KNOWN_HOSTS=/ssh/known_hosts`. + Without it, any host key is accepted (MITM risk on untrusted networks). +- **Basic Auth** protects the WebGUI but transmits credentials in cleartext over HTTP. Use a reverse proxy with HTTPS (e.g. Traefik, Caddy) in production. + ## Troubleshooting - Device not found: list devices ```bash diff --git a/jd-webgui/app.py b/jd-webgui/app.py index 4c04770..5de0991 100644 --- a/jd-webgui/app.py +++ b/jd-webgui/app.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 import hashlib +import html as html_mod import json import os import re @@ -63,6 +64,16 @@ URL_RE = re.compile(r"^https?://", re.I) NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) +def esc(s: str) -> str: + """HTML-escape a string to prevent XSS.""" + return html_mod.escape(str(s), quote=True) + +def mask_secret(value: str, visible: int = 4) -> str: + """Mask a secret string, showing only the last `visible` characters.""" + if len(value) <= visible: + return "***" + return "***" + value[-visible:] + VIDEO_EXTS = { ".mkv", ".mp4", ".m4v", ".avi", ".mov", ".wmv", ".flv", ".webm", ".ts", ".m2ts", ".mts", ".mpg", ".mpeg", ".vob", ".ogv", @@ -219,6 +230,29 @@ def is_video_file(path: str) -> bool: return False return ext in VIDEO_EXTS +DEMO_PATTERNS = {"big_buck_bunny", "bigbuckbunny", "big buck bunny", "bbb_sunflower"} + +def is_demo_link(name: str) -> bool: + """Detect JDownloader demo/fallback videos (e.g. Big Buck Bunny).""" + lower = name.lower().replace("-", "_").replace(".", " ") + return any(pat in lower for pat in DEMO_PATTERNS) + +def check_url_reachable(url: str) -> Optional[str]: + """Try a HEAD request to verify the URL is reachable. Returns error string or None.""" + try: + req = urllib.request.Request(url, method="HEAD") + req.add_header("User-Agent", "Mozilla/5.0") + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status >= 400: + return f"URL antwortet mit HTTP {resp.status}" + except urllib.error.HTTPError as e: + return f"URL nicht erreichbar: HTTP {e.code}" + except urllib.error.URLError as e: + return f"URL nicht erreichbar: {e.reason}" + except Exception as e: + return f"URL-Check fehlgeschlagen: {e}" + return None + def md5_file(path: str) -> str: h = hashlib.md5() with open(path, "rb") as f: @@ -262,10 +296,17 @@ def ffprobe_ok(path: str) -> bool: # ============================================================ # SSH/SFTP # ============================================================ +SSH_KNOWN_HOSTS = os.environ.get("SSH_KNOWN_HOSTS", "/ssh/known_hosts") + def ssh_connect() -> paramiko.SSHClient: ssh = paramiko.SSHClient() - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - log_connection(f"SSH connect {JELLYFIN_USER}@{JELLYFIN_HOST}:{JELLYFIN_PORT}") + if os.path.isfile(SSH_KNOWN_HOSTS): + ssh.load_host_keys(SSH_KNOWN_HOSTS) + ssh.set_missing_host_key_policy(paramiko.RejectPolicy()) + log_connection(f"SSH connect {JELLYFIN_USER}@{JELLYFIN_HOST}:{JELLYFIN_PORT} (known_hosts verified)") + else: + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + log_connection(f"SSH connect {JELLYFIN_USER}@{JELLYFIN_HOST}:{JELLYFIN_PORT} (WARNING: no known_hosts, accepting any host key)") ssh.connect( hostname=JELLYFIN_HOST, port=JELLYFIN_PORT, @@ -310,9 +351,19 @@ def remote_md5sum(ssh: paramiko.SSHClient, remote_path: str) -> str: # ============================================================ # TMDB & naming # ============================================================ +def _sanitize_url_for_log(url: str) -> str: + """Remove sensitive query params (api_key) from URLs before logging.""" + parsed = urllib.parse.urlparse(url) + params = urllib.parse.parse_qs(parsed.query, keep_blank_values=True) + for key in ("api_key", "apikey", "token"): + if key in params: + params[key] = ["***"] + safe_query = urllib.parse.urlencode(params, doseq=True) + return urllib.parse.urlunparse(parsed._replace(query=safe_query)) + def _http_get_json(url: str, headers: Optional[Dict[str, str]] = None) -> Any: req = urllib.request.Request(url, headers=headers or {}) - log_connection(f"HTTP GET {url} (no-proxy)") + log_connection(f"HTTP GET {_sanitize_url_for_log(url)} (no-proxy)") with NO_PROXY_OPENER.open(req, timeout=20) as r: return json.loads(r.read().decode("utf-8", "replace")) @@ -908,17 +959,18 @@ def render_job_rows() -> str: cancel_html = "" if j.status not in {"finished", "failed", "canceled"}: cancel_html = ( - f"
" + f"" f"" f"
" ) + status_class = "error" if j.status == "failed" else ("success" if j.status == "finished" else "") rows += ( f"" - f"{j.id}" - f"{j.url}" - f"{j.package_name}" - f"{j.library}" - f"{j.status}
{j.message}{progress_html}{cancel_html}" + f"{esc(j.id)}" + f"{esc(j.url)}" + f"{esc(j.package_name)}" + f"{esc(j.library)}" + f"{esc(j.status)}
{esc(j.message)}{progress_html}{cancel_html}" f"" ) @@ -929,7 +981,7 @@ def render_job_rows() -> str: def render_page(error: str = "") -> str: rows = render_job_rows() - err_html = f"

{error}

" if error else "" + err_html = f"

{esc(error)}

" if error else "" auth_note = "aktiv" if _auth_enabled() else "aus" return f""" @@ -991,6 +1043,10 @@ def render_page(error: str = "") -> str: {rows} + +
+ +
""" @@ -1050,8 +1106,8 @@ def render_proxies_page( out_text: str = "", export_path: str = "", ) -> str: - err_html = f"

{error}

" if error else "" - msg_html = f"

{message}

" if message else "" + err_html = f"

{esc(error)}

" if error else "" + msg_html = f"

{esc(message)}

" if message else "" return f""" @@ -1068,12 +1124,12 @@ def render_proxies_page(

- +

- +
@@ -1083,7 +1139,7 @@ def render_proxies_page(

Format: socks5://IP:PORT, socks4://IP:PORT. Keine Prüfung/Validierung.

- +
@@ -1092,12 +1148,12 @@ def render_proxies_page(

Speichert die Liste als .jdproxies im Container, z. B. zum Import in JDownloader → Verbindungsmanager → Importieren.

- - + +
-

Aktueller Pfad: {export_path or PROXY_EXPORT_PATH}

+

Aktueller Pfad: {esc(export_path or PROXY_EXPORT_PATH)}

""" @@ -1120,6 +1176,11 @@ def submit(url: str = Form(...), package_name: str = Form(""), library: str = Fo if not URL_RE.match(url): return HTMLResponse(render_page("Nur http(s) URLs erlaubt."), status_code=400) + url_err = check_url_reachable(url) + if url_err: + log_connection(f"URL-Check fehlgeschlagen: {url} -> {url_err}") + return HTMLResponse(render_page(f"Link nicht erreichbar: {url_err}"), status_code=400) + dev = get_device() resp = dev.linkgrabber.add_links([{ "links": url, @@ -1160,6 +1221,14 @@ def cancel(jobid: str): job.message = "Abbruch angefordert…" return RedirectResponse(url="/", status_code=303) +@app.post("/clear-finished") +def clear_finished(): + with lock: + to_remove = [jid for jid, j in jobs.items() if j.status in {"finished", "failed", "canceled"}] + for jid in to_remove: + del jobs[jid] + return RedirectResponse(url="/", status_code=303) + @app.get("/proxies", response_class=HTMLResponse) def proxies_get(): try: @@ -1169,7 +1238,6 @@ def proxies_get(): socks4_in = fetch_proxy_list( "https://api.proxyscrape.com/v4/free-proxy-list/get?request=displayproxies&protocol=socks4&timeout=10000&country=all&ssl=yes&anonymity=elite&skip=0&limit=2000" ) - http_in = fetch_proxy_list("https://raw.githubusercontent.com/TheSpeedX/SOCKS-List/master/http.txt") s5 = format_proxy_lines(socks5_in, "socks5") s4 = format_proxy_lines(socks4_in, "socks4")