Harden remote access and path handling

This commit is contained in:
Dymas
2026-05-26 09:20:02 +02:00
parent ff605aca03
commit 83497d8ff0
4 changed files with 545 additions and 36 deletions
+166 -8
View File
@@ -9,11 +9,15 @@ import json
import mimetypes
import os
import re
import secrets
import shutil
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
import urllib.error
import urllib.request
from urllib.parse import urlparse
from uuid import uuid4
@@ -71,6 +75,8 @@ DISCORD_WEBHOOK_EVENT_LABELS = {
"watchlist_refresh_interrupted": "Refresh interrupted by shutdown or restart",
"runtime_error": "Unexpected runtime errors",
}
REMOTE_ACCESS_SESSION_TTL_SECONDS = 30 * 24 * 60 * 60
REMOTE_SESSION_STORE_LOCK = threading.RLock()
def env_flag(name, default=False):
@@ -124,21 +130,104 @@ def remote_access_credentials_match(username, password, config=None):
def remote_access_session_value(config=None):
return create_remote_access_session(config)
def remote_access_session_fingerprint(config=None):
username = remote_access_username(config)
password = remote_access_password(config)
if not username or not password:
return ""
return hmac.new(
f"{username}\0{password}".encode("utf-8"),
b"ani-cli-web-session",
hashlib.sha256,
).hexdigest()
return hashlib.sha256(f"{username}\0{password}".encode("utf-8")).hexdigest()
def _remote_access_session_token_digest(value):
return hashlib.sha256(str(value or "").strip().encode("utf-8")).hexdigest()
def _load_remote_access_sessions_locked():
data = load_json(REMOTE_SESSIONS_PATH, {})
if not isinstance(data, dict):
return {}
return data
def _write_remote_access_sessions_locked(data):
write_json(REMOTE_SESSIONS_PATH, data)
def _prune_remote_access_sessions_locked(data, now=None):
timestamp = int(now if now is not None else time.time())
changed = False
for token_digest, payload in list(data.items()):
try:
expires_at = int(payload.get("expires_at") or 0)
except (AttributeError, TypeError, ValueError):
expires_at = 0
if expires_at <= timestamp:
data.pop(token_digest, None)
changed = True
return changed
def create_remote_access_session(config=None, ttl_seconds=REMOTE_ACCESS_SESSION_TTL_SECONDS):
username = remote_access_username(config)
fingerprint = remote_access_session_fingerprint(config)
if not username or not fingerprint:
return ""
now = int(time.time())
token = secrets.token_urlsafe(32)
payload = {
"username": username,
"fingerprint": fingerprint,
"created_at": now_iso(),
"expires_at": now + max(60, int(ttl_seconds or REMOTE_ACCESS_SESSION_TTL_SECONDS)),
}
with REMOTE_SESSION_STORE_LOCK:
sessions = _load_remote_access_sessions_locked()
_prune_remote_access_sessions_locked(sessions, now=now)
sessions[_remote_access_session_token_digest(token)] = payload
_write_remote_access_sessions_locked(sessions)
return token
def remote_access_session_matches(value, config=None):
provided = str(value or "").strip()
expected = remote_access_session_value(config)
return bool(expected and provided and hmac.compare_digest(provided, expected))
fingerprint = remote_access_session_fingerprint(config)
username = remote_access_username(config)
if not provided or not fingerprint or not username:
return False
token_digest = _remote_access_session_token_digest(provided)
with REMOTE_SESSION_STORE_LOCK:
sessions = _load_remote_access_sessions_locked()
changed = _prune_remote_access_sessions_locked(sessions)
payload = sessions.get(token_digest)
if changed:
_write_remote_access_sessions_locked(sessions)
if not isinstance(payload, dict):
return False
return bool(
hmac.compare_digest(str(payload.get("fingerprint") or ""), fingerprint)
and hmac.compare_digest(str(payload.get("username") or ""), username)
)
def clear_remote_access_sessions(config=None):
expected_fingerprint = str(config.get("fingerprint") or "") if isinstance(config, dict) else ""
with REMOTE_SESSION_STORE_LOCK:
sessions = _load_remote_access_sessions_locked()
if expected_fingerprint:
filtered = {
token_digest: payload
for token_digest, payload in sessions.items()
if str((payload or {}).get("fingerprint") or "") != expected_fingerprint
}
else:
filtered = {}
if filtered == sessions:
return False
_write_remote_access_sessions_locked(filtered)
return True
def background_workers_enabled():
@@ -289,6 +378,7 @@ WATCHLIST_JSON_PATH = project_state_path("watchlist.json")
THUMBNAIL_ROOT = project_state_dir("thumbnails")
THUMBNAIL_RETRY_SECONDS = 15 * 60
ANIDB_TITLES_PATH = project_state_path("anidb-anime-titles.xml.gz")
REMOTE_SESSIONS_PATH = project_state_path("remote-sessions.json")
ANIDB_TITLES_CACHE = {
"mtime": None,
"entries": None,
@@ -417,6 +507,74 @@ def sanitize_public_config(config):
return {key: value for key, value in source.items() if key not in PRIVATE_CONFIG_KEYS}
def _env_remote_path_roots():
raw = str(os.environ.get("ANI_CLI_WEB_REMOTE_PATH_ROOTS") or "").strip()
if not raw:
return []
roots = []
for part in raw.split(os.pathsep):
text = str(part or "").strip()
if not text:
continue
try:
resolved = Path(text).expanduser().resolve()
except OSError:
continue
if resolved not in roots:
roots.append(resolved)
return roots
def configured_remote_path_roots(config=None):
normalized = normalize_config(config or {})
roots = list(_env_remote_path_roots())
for raw_path in (
normalized.get("download_dir"),
normalized.get("jellyfin_tv_dir"),
normalized.get("jellyfin_movie_dir"),
):
text = str(raw_path or "").strip()
if not text:
continue
try:
resolved = Path(text).expanduser().resolve()
except OSError:
continue
if resolved not in roots:
roots.append(resolved)
return roots
def path_is_within_roots(path, roots):
try:
candidate = Path(path).expanduser().resolve()
except OSError:
return False
for root in roots or []:
try:
root_path = Path(root).expanduser().resolve()
except OSError:
continue
if candidate == root_path or root_path in candidate.parents:
return True
return False
def validate_discord_webhook_url(url):
text = str(url or "").strip()
if not text:
return ""
parsed = urlparse(text)
if parsed.scheme != "https":
raise ValueError("Discord webhook URL must use https.")
host = str(parsed.netloc or "").split("@", 1)[-1].split(":", 1)[0].strip().lower()
if host not in {"discord.com", "ptb.discord.com", "canary.discord.com", "discordapp.com"}:
raise ValueError("Discord webhook URL must point to an official Discord webhook host.")
if not str(parsed.path or "").startswith("/api/webhooks/"):
raise ValueError("Discord webhook URL must use the Discord /api/webhooks/ path.")
return text
def discord_webhook_enabled(config, event_name, force=False):
if not isinstance(config, dict):
return False
@@ -567,7 +725,7 @@ def send_discord_webhook_event(config, event_name, payload=None, force=False):
event = str(event_name or "").strip()
if not discord_webhook_enabled(normalized, event, force=force):
return {"sent": False, "reason": "disabled"}
webhook_url = normalized["discord_webhook_url"]
webhook_url = validate_discord_webhook_url(normalized["discord_webhook_url"])
payload = payload or {}
if event == "test":