diff --git a/app.py b/app.py index 663c737..34e65d9 100644 --- a/app.py +++ b/app.py @@ -49,6 +49,7 @@ from app_support import ( WORKER_SHUTDOWN_TIMEOUT_SECONDS, background_workers_enabled, client_address_is_local, + clear_remote_access_sessions, debug_log, load_json, migrate_legacy_state_storage, @@ -57,8 +58,10 @@ from app_support import ( normalize_episode_offset, normalize_media_type, now_iso, + remote_access_session_fingerprint, sanitize_path_component, thumbnail_file_path, + validate_discord_webhook_url, write_json, ) from http_handler import HandlerContext, build_handler_class, server_host, server_port @@ -2647,12 +2650,16 @@ def save_runtime_config(config): if isinstance(config, dict): merged.update(config) normalized = normalize_config(merged) + previous_fingerprint = remote_access_session_fingerprint(current) + next_fingerprint = remote_access_session_fingerprint(normalized) scheduler = None with RUNTIME_LOCK: global CONFIG CONFIG = dict(normalized) write_json(CONFIG_PATH, CONFIG) scheduler = WATCHLIST_AUTO_REFRESH + if previous_fingerprint and previous_fingerprint != next_fingerprint: + clear_remote_access_sessions({"fingerprint": previous_fingerprint}) if scheduler is not None: scheduler.notify_config_changed() return dict(CONFIG) @@ -2662,6 +2669,7 @@ def test_discord_webhook_config(config): normalized = normalize_config(config) if not normalized.get("discord_webhook_url"): raise ValueError("Discord webhook URL is required for testing.") + validate_discord_webhook_url(normalized["discord_webhook_url"]) send_discord_webhook_event(normalized, "test", force=True) return {"message": "Discord webhook test notification sent."} diff --git a/app_support.py b/app_support.py index e27e426..324a504 100644 --- a/app_support.py +++ b/app_support.py @@ -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": diff --git a/http_handler.py b/http_handler.py index 044bbd8..e8fd352 100644 --- a/http_handler.py +++ b/http_handler.py @@ -10,11 +10,12 @@ import subprocess import traceback from base64 import b64decode from binascii import Error as BinasciiError -from functools import lru_cache from dataclasses import dataclass -from http.cookies import SimpleCookie +from functools import lru_cache from http import HTTPStatus +from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler +from html import escape as html_escape from pathlib import Path from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse @@ -26,9 +27,11 @@ from app_support import ( MODE_CHOICES, VERSION, client_address_is_local, + configured_remote_path_roots, debug_enabled, debug_log, normalize_config, + path_is_within_roots, remote_access_allowed, remote_access_credentials_configured, remote_access_credentials_match, @@ -36,6 +39,7 @@ from app_support import ( remote_access_session_value, sanitize_public_config, send_discord_webhook_event, + validate_discord_webhook_url, ) @dataclass(frozen=True) @@ -81,11 +85,16 @@ def dependency_status(): return result -def browse_filesystem(path_value="", mode="dir"): +def browse_filesystem(path_value="", mode="dir", allowed_roots=None): raw_mode = str(mode or "dir").strip().lower() browse_mode = raw_mode if raw_mode in {"dir", "file"} else "dir" raw_path = str(path_value or "").strip() - base_path = Path(raw_path).expanduser() if raw_path else Path.home() + if raw_path: + base_path = Path(raw_path).expanduser() + elif allowed_roots: + base_path = allowed_roots[0] + else: + base_path = Path.home() try: resolved = base_path.resolve() except OSError as exc: @@ -96,6 +105,8 @@ def browse_filesystem(path_value="", mode="dir"): resolved = resolved.parent if not resolved.is_dir(): raise ValueError("Selected path is not a directory.") + if allowed_roots and not path_is_within_roots(resolved, allowed_roots): + raise PermissionError("That path is outside the allowed remote browse roots.") entries = [] try: @@ -104,6 +115,8 @@ def browse_filesystem(path_value="", mode="dir"): is_dir = child.is_dir() except OSError: continue + if allowed_roots and not path_is_within_roots(child, allowed_roots): + continue if browse_mode == "dir" and not is_dir: continue entries.append( @@ -118,10 +131,12 @@ def browse_filesystem(path_value="", mode="dir"): entries.sort(key=lambda item: (not item["is_dir"], item["name"].lower(), item["name"])) parent_path = str(resolved.parent if resolved.parent != resolved else resolved) + if allowed_roots and not path_is_within_roots(parent_path, allowed_roots): + parent_path = str(resolved) return { "path": str(resolved), "parent_path": parent_path, - "home_path": str(Path.home()), + "home_path": str(allowed_roots[0] if allowed_roots else Path.home()), "mode": browse_mode, "entries": entries, } @@ -194,6 +209,39 @@ class Handler(BaseHTTPRequestHandler): def _authorization_header(self): return str(self.headers.get("Authorization") or self.headers.get("authorization") or "").strip() + def _host_header(self): + host = str(self.headers.get("X-Forwarded-Host") or self.headers.get("x-forwarded-host") or "").strip() + if host: + return host.split(",", 1)[0].strip() + return str(self.headers.get("Host") or self.headers.get("host") or "").strip() + + def _origin(self): + return str(self.headers.get("Origin") or self.headers.get("origin") or "").strip() + + def _content_type(self): + return str(self.headers.get("Content-Type") or self.headers.get("content-type") or "").strip() + + def _request_is_secure(self): + forwarded_proto = str(self.headers.get("X-Forwarded-Proto") or self.headers.get("x-forwarded-proto") or "").strip() + if forwarded_proto: + return forwarded_proto.split(",", 1)[0].strip().lower() == "https" + forwarded = str(self.headers.get("Forwarded") or self.headers.get("forwarded") or "").strip() + if forwarded: + for part in forwarded.split(";"): + key, _, value = part.partition("=") + if key.strip().lower() == "proto": + return value.strip().strip('"').lower() == "https" + return False + + def _origin_matches_host(self): + origin = Handler._origin(self) + if not origin: + return False + parsed = urlparse(origin) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return False + return parsed.netloc.lower() == Handler._host_header(self).lower() + def _basic_auth_credentials(self): header = Handler._authorization_header(self) if not header.startswith("Basic "): @@ -263,16 +311,22 @@ class Handler(BaseHTTPRequestHandler): return urlunparse(("", "", parsed.path or "/", "", query, "")) def _remote_auth_cookie(self, session_value): - return ( + attributes = ( f"{Handler.remote_session_cookie_name}={session_value}; " f"Path=/; HttpOnly; SameSite=Lax; Max-Age={Handler.remote_session_cookie_max_age_seconds}" ) + if Handler._request_is_secure(self): + attributes += "; Secure" + return attributes def _clear_remote_auth_cookie(self): - return ( + attributes = ( f"{Handler.remote_session_cookie_name}=; " "Path=/; HttpOnly; SameSite=Lax; Max-Age=0" ) + if Handler._request_is_secure(self): + attributes += "; Secure" + return attributes def _set_remote_auth_cookie_and_redirect(self, session_value, location): Handler.write_response_bytes( @@ -294,7 +348,7 @@ class Handler(BaseHTTPRequestHandler): next_path = Handler._current_path_without_auth_query(self, parsed) error_html = "" if message: - error_html = f'
{message}
' + error_html = f'{html_escape(message)}
' html = f""" @@ -390,7 +444,7 @@ class Handler(BaseHTTPRequestHandler): {error_html}