Harden remote access and path handling
This commit is contained in:
+166
-8
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user