Compare commits

..

1 Commits

Author SHA1 Message Date
7d0231c004 Handle submit API response safely and avoid 500 crash 2026-04-12 18:44:48 +02:00
4 changed files with 1328 additions and 1351 deletions

View File

@@ -26,4 +26,4 @@ services:
volumes: volumes:
- ./data/jd-output:/output:rw - ./data/jd-output:/output:rw
- ./data/md5:/md5:rw - ./data/md5:/md5:rw
- ${SSH_KEY_PATH:-/root/.ssh/id_ed25519}:/ssh/id_ed25519:ro - /root/.ssh/id_ed25519:/ssh/id_ed25519:ro

View File

@@ -2,17 +2,19 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt . RUN pip install --no-cache-dir \
RUN pip install --no-cache-dir -r requirements.txt fastapi \
uvicorn \
myjdapi \
paramiko \
python-multipart
RUN useradd -m -u 1000 appuser && chown appuser:appuser /app COPY app.py /app/app.py
COPY static /app/static
USER appuser
COPY --chown=appuser:appuser app.py .
COPY --chown=appuser:appuser static ./static
EXPOSE 8080 EXPOSE 8080
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"] CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]

View File

@@ -3,14 +3,11 @@ from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import hmac
import html as html_mod import html as html_mod
import ipaddress
import json import json
import os import os
import re import re
import shlex import shlex
import socket
import subprocess import subprocess
import threading import threading
import time import time
@@ -109,7 +106,7 @@ def _check_basic_auth(req: Request) -> bool:
if ":" not in raw: if ":" not in raw:
return False return False
user, pw = raw.split(":", 1) user, pw = raw.split(":", 1)
return hmac.compare_digest(user, BASIC_AUTH_USER) and hmac.compare_digest(pw, BASIC_AUTH_PASS) return user == BASIC_AUTH_USER and pw == BASIC_AUTH_PASS
def _auth_challenge() -> HTMLResponse: def _auth_challenge() -> HTMLResponse:
return HTMLResponse( return HTMLResponse(
@@ -240,26 +237,8 @@ def is_demo_link(name: str) -> bool:
lower = name.lower().replace("-", "_").replace(".", " ") lower = name.lower().replace("-", "_").replace(".", " ")
return any(pat in lower for pat in DEMO_PATTERNS) return any(pat in lower for pat in DEMO_PATTERNS)
def _is_ssrf_target(url: str) -> bool:
"""Return True if the URL resolves to a private/loopback address (SSRF protection)."""
try:
host = urllib.parse.urlparse(url).hostname or ""
try:
addr = ipaddress.ip_address(host)
except ValueError:
try:
host = socket.gethostbyname(host)
addr = ipaddress.ip_address(host)
except Exception:
return False
return addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved
except Exception:
return False
def check_url_reachable(url: str) -> Optional[str]: def check_url_reachable(url: str) -> Optional[str]:
"""Try a HEAD request to verify the URL is reachable. Returns error string or None.""" """Try a HEAD request to verify the URL is reachable. Returns error string or None."""
if _is_ssrf_target(url):
return "URL zeigt auf eine interne/private Adresse (nicht erlaubt)"
try: try:
req = urllib.request.Request(url, method="HEAD") req = urllib.request.Request(url, method="HEAD")
req.add_header("User-Agent", "Mozilla/5.0") req.add_header("User-Agent", "Mozilla/5.0")
@@ -456,22 +435,13 @@ def format_proxy_lines(raw: str, scheme: str) -> str:
return "\n".join(dedup) return "\n".join(dedup)
_PROXY_FETCH_LIMIT = 2 * 1024 * 1024 # 2 MB cap to prevent memory exhaustion
_proxy_cache: Dict[str, Tuple[float, str]] = {}
_PROXY_CACHE_TTL = 300.0 # 5 minutes
def fetch_proxy_list(url: str) -> str: def fetch_proxy_list(url: str) -> str:
now = time.time()
cached_ts, cached_text = _proxy_cache.get(url, (0.0, ""))
if cached_text and now - cached_ts < _PROXY_CACHE_TTL:
return cached_text
req = urllib.request.Request(url) req = urllib.request.Request(url)
log_connection(f"HTTP GET {url} (no-proxy)") log_connection(f"HTTP GET {url} (no-proxy)")
with NO_PROXY_OPENER.open(req, timeout=20) as resp: with NO_PROXY_OPENER.open(req, timeout=20) as resp:
text = resp.read(_PROXY_FETCH_LIMIT).decode("utf-8", "replace") text = resp.read().decode("utf-8", "replace")
if "\n" not in text and re.search(r"\s", text): if "\n" not in text and re.search(r"\s", text):
text = re.sub(r"\s+", "\n", text.strip()) return re.sub(r"\s+", "\n", text.strip())
_proxy_cache[url] = (now, text)
return text return text
def build_jdproxies_payload(text: str) -> Dict[str, Any]: def build_jdproxies_payload(text: str) -> Dict[str, Any]:
@@ -1198,6 +1168,7 @@ def index():
@app.post("/submit") @app.post("/submit")
def submit(url: str = Form(...), package_name: str = Form(""), library: str = Form("auto")): def submit(url: str = Form(...), package_name: str = Form(""), library: str = Form("auto")):
try:
ensure_env() ensure_env()
url = url.strip() url = url.strip()
package_name = (package_name or "").strip() or "WebGUI" package_name = (package_name or "").strip() or "WebGUI"
@@ -1211,10 +1182,7 @@ def submit(url: str = Form(...), package_name: str = Form(""), library: str = Fo
log_connection(f"URL-Check fehlgeschlagen: {url} -> {url_err}") log_connection(f"URL-Check fehlgeschlagen: {url} -> {url_err}")
return HTMLResponse(render_page(f"Link nicht erreichbar: {url_err}"), status_code=400) return HTMLResponse(render_page(f"Link nicht erreichbar: {url_err}"), status_code=400)
try:
dev = get_device() dev = get_device()
except Exception as e:
return HTMLResponse(render_page(f"JDownloader nicht erreichbar: {e}"), status_code=503)
resp = dev.linkgrabber.add_links([{ resp = dev.linkgrabber.add_links([{
"links": url, "links": url,
"autostart": True, "autostart": True,
@@ -1222,9 +1190,18 @@ def submit(url: str = Form(...), package_name: str = Form(""), library: str = Fo
"packageName": package_name, "packageName": package_name,
}]) }])
jobid = str(resp.get("id", "")) jobid = ""
if isinstance(resp, dict):
jobid = str(resp.get("id", "")).strip()
elif isinstance(resp, (str, int)):
jobid = str(resp).strip()
elif isinstance(resp, list) and resp and isinstance(resp[0], dict):
jobid = str(resp[0].get("id", "")).strip()
if not jobid: if not jobid:
return HTMLResponse(render_page(f"Unerwartete Antwort von add_links: {resp}"), status_code=500) msg = f"Unerwartete Antwort von add_links (kein Job-ID): {resp!r}"
log_connection(msg)
return HTMLResponse(render_page(msg), status_code=502)
with lock: with lock:
jobs[jobid] = Job( jobs[jobid] = Job(
@@ -1241,6 +1218,9 @@ def submit(url: str = Form(...), package_name: str = Form(""), library: str = Fo
t.start() t.start()
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url="/", status_code=303)
except Exception as e:
log_connection(f"Submit-Fehler: {e}")
return HTMLResponse(render_page(f"Interner Fehler beim Absenden: {e}"), status_code=500)
@app.post("/cancel/{jobid}") @app.post("/cancel/{jobid}")
def cancel(jobid: str): def cancel(jobid: str):

View File

@@ -1,5 +0,0 @@
fastapi
uvicorn
myjdapi
paramiko
python-multipart