From 83497d8ff0a505a4ff05bf244e63acc323812aca Mon Sep 17 00:00:00 2001 From: Dymas Date: Tue, 26 May 2026 09:20:02 +0200 Subject: [PATCH] Harden remote access and path handling --- app.py | 8 ++ app_support.py | 174 ++++++++++++++++++++++++++++-- http_handler.py | 123 +++++++++++++++++++-- test_app.py | 276 ++++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 545 insertions(+), 36 deletions(-) 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}
- + @@ -427,6 +481,28 @@ class Handler(BaseHTTPRequestHandler): if not Handler._remote_credentials_valid(self): raise PermissionError("Remote username or password missing or invalid.") + def enforce_same_origin(self): + origin = Handler._origin(self) + if not origin: + if Handler._session_cookie(self): + raise PermissionError("Browser API requests must include a same-origin Origin header.") + return + if not Handler._origin_matches_host(self): + raise PermissionError("Cross-origin browser requests are not allowed.") + + def _remote_path_roots(self): + return configured_remote_path_roots(Handler._context(self).get_config_snapshot()) + + def validate_remote_path(self, path_value, label): + client_host = Handler._effective_client_host(self) + if client_address_is_local(client_host): + return + roots = Handler._remote_path_roots(self) + if not roots: + raise PermissionError(f"Remote {label} changes are disabled until a server-side path root is configured.") + if not path_is_within_roots(path_value, roots): + raise PermissionError(f"Remote {label} must stay inside the configured remote path roots.") + def do_GET(self): parsed = urlparse(self.path) try: @@ -456,10 +532,13 @@ class Handler(BaseHTTPRequestHandler): self.json(dependency_status()) elif parsed.path == "/api/fs/browse": params = parse_qs(parsed.query) + client_host = Handler._effective_client_host(self) + allowed_roots = None if client_address_is_local(client_host) else Handler._remote_path_roots(self) self.json( browse_filesystem( (params.get("path") or [""])[0], (params.get("mode") or ["dir"])[0], + allowed_roots=allowed_roots, ) ) elif parsed.path == "/api/search": @@ -543,17 +622,29 @@ class Handler(BaseHTTPRequestHandler): Handler._set_remote_auth_cookie_and_redirect(self, remote_access_session_value(), next_path) return self.ensure_client_access() + Handler.enforce_same_origin(self) if parsed.path == "/api/config": Handler.update_config(self, Handler.require_json_object(self, self.body_json())) elif parsed.path == "/api/config/jellyfin/sync": payload = Handler.require_json_object(self, self.body_json()) + for key, label in ( + ("download_dir", "download directory"), + ("jellyfin_tv_dir", "Jellyfin TV directory"), + ("jellyfin_movie_dir", "Jellyfin movie directory"), + ): + if str(payload.get(key) or "").strip(): + Handler.validate_remote_path(self, payload[key], label) self.json(Handler._context(self).run_jellyfin_sync(payload)) elif parsed.path == "/api/config/webhook/test": payload = Handler.require_json_object(self, self.body_json()) + validate_discord_webhook_url(payload.get("discord_webhook_url")) self.json(Handler._context(self).test_discord_webhook_config(payload)) elif parsed.path == "/api/queue": runtime = Handler._runtime(self) - self.json(runtime["download_queue"].add(self.body_json()), HTTPStatus.CREATED) + payload = Handler.require_json_object(self, self.body_json()) + if str(payload.get("download_dir") or "").strip(): + Handler.validate_remote_path(self, payload["download_dir"], "download directory") + self.json(runtime["download_queue"].add(payload), HTTPStatus.CREATED) elif parsed.path == "/api/queue/retry-failed": runtime = Handler._runtime(self) self.json(runtime["download_queue"].retry_all_failed()) @@ -641,6 +732,15 @@ class Handler(BaseHTTPRequestHandler): def update_config(self, payload): payload = Handler.require_json_object(self, payload) config = normalize_config(payload) + for key, label in ( + ("download_dir", "download directory"), + ("jellyfin_tv_dir", "Jellyfin TV directory"), + ("jellyfin_movie_dir", "Jellyfin movie directory"), + ): + if str(config.get(key) or "").strip(): + Handler.validate_remote_path(self, config[key], label) + if "discord_webhook_url" in payload and str(config.get("discord_webhook_url") or "").strip(): + validate_discord_webhook_url(config["discord_webhook_url"]) Path(config["download_dir"]).expanduser().mkdir(parents=True, exist_ok=True) context = Handler._context(self) context.save_runtime_config(payload) @@ -685,6 +785,9 @@ class Handler(BaseHTTPRequestHandler): raise ValueError(f"Field '{name}' must be a boolean") def body_json(self): + content_type = Handler._content_type(self).split(";", 1)[0].strip().lower() + if content_type != "application/json": + raise ValueError("Request body must use Content-Type: application/json.") try: length = int(self.headers.get("Content-Length", "0") or "0") except ValueError as exc: diff --git a/test_app.py b/test_app.py index bad30f1..04065dc 100644 --- a/test_app.py +++ b/test_app.py @@ -2,6 +2,7 @@ import base64 import importlib.util import io +import json import os import sys import tempfile @@ -46,6 +47,7 @@ class DummyHandler: self.headers = {} if content_length is not None: self.headers["Content-Length"] = str(content_length) + self.headers.setdefault("Content-Type", "application/json") self.rfile = io.BytesIO(body) self.json_payload = None self.json_status = None @@ -170,10 +172,31 @@ class NetworkGuardTests(unittest.TestCase): self.assertIn("Username", body) self.assertIn("Password", body) + def test_remote_login_page_escapes_hidden_next_value(self): + handler = DummyHandler('/?next=">') + handler.client_address = ("192.168.1.25", 8421) + handler.command = "GET" + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.do_GET(handler) + + body = handler.wfile.getvalue().decode("utf-8") + self.assertNotIn('', body) + self.assertIn('value="/?next=%22%3E%3Cscript%3Ealert%281%29%3C%2Fscript%3E"', body) + def test_remote_login_post_sets_cookie_and_redirects(self): body = b"username=demo&password=secret-pass&next=%2Fwatchlist" handler = DummyHandler("/auth/login", body=body, content_length=len(body)) handler.client_address = ("192.168.1.25", 8421) + handler.headers["Content-Type"] = "application/x-www-form-urlencoded" with mock.patch.dict( os.environ, @@ -191,6 +214,84 @@ class NetworkGuardTests(unittest.TestCase): self.assertEqual(headers.get("Location"), "/watchlist") self.assertIn("ani_cli_web_session=", headers.get("Set-Cookie", "")) + def test_remote_session_cookie_rejects_missing_origin_on_post(self): + body = b'{"show_id":"show-1"}' + handler = DummyHandler("/api/watchlist/remove", body=body, content_length=len(body)) + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Host"] = "anime.example:8421" + handler.headers["Cookie"] = ( + "ani_cli_web_session=" + + http_handler.remote_access_session_value({"auth_username": "demo", "auth_password": "secret-pass"}) + ) + handler.handler_context = mock.Mock(remove_from_watchlist=mock.Mock(return_value={"ok": True}), http_error=APP.HttpError) + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.do_POST(handler) + + self.assertEqual(handler.error_status, HTTPStatus.FORBIDDEN) + self.assertIn("origin", handler.error_message.lower()) + + def test_remote_session_cookie_rejects_cross_origin_post(self): + body = b'{"show_id":"show-1"}' + handler = DummyHandler("/api/watchlist/remove", body=body, content_length=len(body)) + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Host"] = "anime.example:8421" + handler.headers["Origin"] = "https://elsewhere.example" + handler.headers["Cookie"] = ( + "ani_cli_web_session=" + + http_handler.remote_access_session_value({"auth_username": "demo", "auth_password": "secret-pass"}) + ) + handler.handler_context = mock.Mock(remove_from_watchlist=mock.Mock(return_value={"ok": True}), http_error=APP.HttpError) + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.do_POST(handler) + + self.assertEqual(handler.error_status, HTTPStatus.FORBIDDEN) + self.assertIn("cross-origin", handler.error_message.lower()) + + def test_remote_session_cookie_accepts_same_origin_post(self): + body = b'{"show_id":"show-1"}' + handler = DummyHandler("/api/watchlist/remove", body=body, content_length=len(body)) + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Host"] = "anime.example:8421" + handler.headers["Origin"] = "https://anime.example:8421" + handler.headers["Cookie"] = ( + "ani_cli_web_session=" + + http_handler.remote_access_session_value({"auth_username": "demo", "auth_password": "secret-pass"}) + ) + remove_from_watchlist = mock.Mock(return_value={"ok": True}) + handler.handler_context = mock.Mock(remove_from_watchlist=remove_from_watchlist, http_error=APP.HttpError) + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.do_POST(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + remove_from_watchlist.assert_called_once_with("show-1") + def test_loopback_proxy_with_forwarded_remote_ip_requires_credentials(self): handler = DummyHandler("/api/config") handler.client_address = ("127.0.0.1", 8421) @@ -588,7 +689,7 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase): "mode": "sub", "quality": "best", "download_dir": "/tmp/example", - "discord_webhook_url": "https://discord.example/webhook", + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", "discord_webhook_events": ["download_completed"], }, watchlist_sync_fn=lambda _job: { @@ -1641,7 +1742,7 @@ class JellyfinSyncTests(unittest.TestCase): "jellyfin_sync_enabled": True, "jellyfin_tv_dir": str(jellyfin_tv), "jellyfin_movie_dir": "", - "discord_webhook_url": "https://discord.example/webhook", + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", "discord_webhook_events": ["jellyfin_sync_success"], }, ) @@ -1664,7 +1765,7 @@ class JellyfinSyncTests(unittest.TestCase): "jellyfin_sync_enabled": True, "jellyfin_tv_dir": "", "jellyfin_movie_dir": "", - "discord_webhook_url": "https://discord.example/webhook", + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", "discord_webhook_events": ["jellyfin_sync_failed"], }, ) @@ -1692,7 +1793,7 @@ class JellyfinSyncTests(unittest.TestCase): "jellyfin_sync_enabled": True, "jellyfin_tv_dir": str(Path(temp_root) / "jellyfin-tv"), "jellyfin_movie_dir": "", - "discord_webhook_url": "https://discord.example/webhook", + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", "discord_webhook_events": ["jellyfin_sync_failed", "jellyfin_sync_success"], }, ) @@ -1716,7 +1817,7 @@ class JellyfinSyncTests(unittest.TestCase): "jellyfin_sync_enabled": True, "jellyfin_tv_dir": str(Path(temp_root) / "jellyfin-tv"), "jellyfin_movie_dir": "", - "discord_webhook_url": "https://discord.example/webhook", + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", "discord_webhook_events": ["jellyfin_sync_failed"], }, ) @@ -1991,7 +2092,7 @@ class ConfigSnapshotTests(unittest.TestCase): def test_normalize_config_filters_discord_webhook_events(self): config = APP.normalize_config( { - "discord_webhook_url": " https://discord.example/webhook ", + "discord_webhook_url": " https://discord.com/api/webhooks/123456/test-token ", "discord_webhook_events": [ "watchlist_refresh_new_episodes", "runtime_error", @@ -2000,7 +2101,7 @@ class ConfigSnapshotTests(unittest.TestCase): ], } ) - self.assertEqual(config["discord_webhook_url"], "https://discord.example/webhook") + self.assertEqual(config["discord_webhook_url"], "https://discord.com/api/webhooks/123456/test-token") self.assertEqual( config["discord_webhook_events"], ["watchlist_refresh_new_episodes", "runtime_error"], @@ -2014,7 +2115,7 @@ class ConfigSnapshotTests(unittest.TestCase): with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook: result = APP.test_discord_webhook_config( { - "discord_webhook_url": "https://discord.example/webhook", + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", "discord_webhook_events": [], } ) @@ -2022,7 +2123,7 @@ class ConfigSnapshotTests(unittest.TestCase): self.assertEqual(result["message"], "Discord webhook test notification sent.") send_webhook.assert_called_once() config, event_name = send_webhook.call_args.args[:2] - self.assertEqual(config["discord_webhook_url"], "https://discord.example/webhook") + self.assertEqual(config["discord_webhook_url"], "https://discord.com/api/webhooks/123456/test-token") self.assertEqual(event_name, "test") self.assertTrue(send_webhook.call_args.kwargs["force"]) @@ -2094,7 +2195,7 @@ class WatchlistAutoRefreshSchedulerTests(unittest.TestCase): service = APP.WatchlistRefreshJobs( FakeStore(), config_getter=lambda: { - "discord_webhook_url": "https://discord.example/webhook", + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", "discord_webhook_events": [ "watchlist_refresh_new_episodes", "watchlist_refresh_failed", @@ -2121,7 +2222,7 @@ class DiscordWebhookFormattingTests(unittest.TestCase): with mock.patch.object(APP.app_support, "_post_discord_webhook", side_effect=lambda url, payload, attachments=None, timeout=10: payloads.append(payload)): result = APP.app_support.send_discord_webhook_event( { - "discord_webhook_url": "https://discord.example/webhook", + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", "discord_webhook_events": ["download_completed"], }, "download_completed", @@ -2149,7 +2250,7 @@ class DiscordWebhookFormattingTests(unittest.TestCase): with mock.patch.object(APP.app_support, "_post_discord_webhook", side_effect=lambda url, payload, attachments=None, timeout=10: payloads.append(payload)): result = APP.app_support.send_discord_webhook_event( { - "discord_webhook_url": "https://discord.example/webhook", + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", "discord_webhook_events": ["jellyfin_sync_success"], }, "jellyfin_sync_success", @@ -2455,7 +2556,7 @@ class StartupBehaviorTests(unittest.TestCase): "jellyfin_sync_enabled": True, "jellyfin_tv_dir": "/tmp/jellyfin-tv", "jellyfin_movie_dir": "/tmp/jellyfin-movies", - "discord_webhook_url": "https://discord.example/webhook", + "discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", "discord_webhook_events": ["runtime_error"], } ) @@ -2471,7 +2572,7 @@ class StartupBehaviorTests(unittest.TestCase): self.assertTrue(APP.CONFIG["jellyfin_sync_enabled"]) self.assertEqual(APP.CONFIG["jellyfin_tv_dir"], "/tmp/jellyfin-tv") self.assertEqual(APP.CONFIG["jellyfin_movie_dir"], "/tmp/jellyfin-movies") - self.assertEqual(APP.CONFIG["discord_webhook_url"], "https://discord.example/webhook") + self.assertEqual(APP.CONFIG["discord_webhook_url"], "https://discord.com/api/webhooks/123456/test-token") self.assertEqual(APP.CONFIG["discord_webhook_events"], ["runtime_error"]) APP.WATCHLIST_AUTO_REFRESH.notify_config_changed.assert_called_once_with() finally: @@ -2543,17 +2644,142 @@ class HandlerRouteTests(unittest.TestCase): self.assertEqual([entry["name"] for entry in handler.json_payload["entries"]], ["folder-a", "folder-b"]) self.assertTrue(all(entry["is_dir"] for entry in handler.json_payload["entries"])) + def test_remote_filesystem_browse_rejects_paths_outside_configured_roots(self): + with tempfile.TemporaryDirectory() as allowed_root, tempfile.TemporaryDirectory() as blocked_root: + APP.save_runtime_config({"download_dir": allowed_root, "mode": "sub", "quality": "best"}) + encoded = base64.b64encode(b"demo:secret-pass").decode("ascii") + handler = DummyHandler(f"/api/fs/browse?path={APP.quote(blocked_root)}&mode=dir") + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Authorization"] = f"Basic {encoded}" + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.do_GET(handler) + + self.assertEqual(handler.error_status, HTTPStatus.FORBIDDEN) + self.assertIn("allowed remote browse roots", handler.error_message.lower()) + + def test_remote_filesystem_browse_defaults_blank_path_to_allowed_root(self): + with tempfile.TemporaryDirectory() as allowed_root: + Path(allowed_root, "folder-a").mkdir() + APP.save_runtime_config({"download_dir": allowed_root, "mode": "sub", "quality": "best"}) + encoded = base64.b64encode(b"demo:secret-pass").decode("ascii") + handler = DummyHandler("/api/fs/browse?path=&mode=dir") + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Authorization"] = f"Basic {encoded}" + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.do_GET(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["path"], allowed_root) + self.assertEqual(handler.json_payload["home_path"], allowed_root) + + def test_remote_path_roots_include_explicit_allowlist(self): + with tempfile.TemporaryDirectory() as base_root, tempfile.TemporaryDirectory() as extra_root: + APP.save_runtime_config({"download_dir": base_root, "mode": "sub", "quality": "best"}) + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_REMOTE_PATH_ROOTS": extra_root, + }, + clear=False, + ): + roots = APP.app_support.configured_remote_path_roots(APP.get_config_snapshot()) + + self.assertIn(Path(base_root).resolve(), roots) + self.assertIn(Path(extra_root).resolve(), roots) + def test_config_post_accepts_discord_webhook_fields(self): - body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","discord_webhook_url":"https://discord.example/webhook","discord_webhook_events":["runtime_error","watchlist_refresh_failed","bad"]}' + body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","discord_webhook_url":"https://discord.com/api/webhooks/123456/test-token","discord_webhook_events":["runtime_error","watchlist_refresh_failed","bad"]}' handler = DummyHandler("/api/config", body=body, content_length=len(body)) APP.Handler.do_POST(handler) self.assertEqual(handler.json_status, HTTPStatus.OK) - self.assertEqual(handler.json_payload["discord_webhook_url"], "https://discord.example/webhook") + self.assertEqual(handler.json_payload["discord_webhook_url"], "https://discord.com/api/webhooks/123456/test-token") self.assertEqual( handler.json_payload["discord_webhook_events"], ["runtime_error", "watchlist_refresh_failed"], ) + def test_config_post_rejects_non_discord_webhook_host(self): + body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","discord_webhook_url":"https://example.com/not-discord"}' + handler = DummyHandler("/api/config", body=body, content_length=len(body)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) + self.assertIn("official discord webhook host", handler.error_message.lower()) + + def test_remote_config_post_rejects_download_dir_outside_allowed_roots(self): + with tempfile.TemporaryDirectory() as allowed_root, tempfile.TemporaryDirectory() as blocked_root: + APP.save_runtime_config({"download_dir": allowed_root, "mode": "sub", "quality": "best"}) + body = json.dumps( + { + "download_dir": blocked_root, + "mode": "sub", + "quality": "best", + } + ).encode("utf-8") + handler = DummyHandler("/api/config", body=body, content_length=len(body)) + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Authorization"] = f"Basic {base64.b64encode(b'demo:secret-pass').decode('ascii')}" + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, + clear=False, + ): + APP.Handler.do_POST(handler) + + self.assertEqual(handler.error_status, HTTPStatus.FORBIDDEN) + self.assertIn("remote download directory", handler.error_message.lower()) + + def test_remote_config_post_accepts_download_dir_inside_explicit_allowlist(self): + with tempfile.TemporaryDirectory() as current_root, tempfile.TemporaryDirectory() as allowed_root: + APP.save_runtime_config({"download_dir": current_root, "mode": "sub", "quality": "best"}) + body = json.dumps( + { + "download_dir": allowed_root, + "mode": "sub", + "quality": "best", + } + ).encode("utf-8") + handler = DummyHandler("/api/config", body=body, content_length=len(body)) + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Authorization"] = f"Basic {base64.b64encode(b'demo:secret-pass').decode('ascii')}" + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + "ANI_CLI_WEB_REMOTE_PATH_ROOTS": allowed_root, + }, + clear=False, + ): + APP.Handler.do_POST(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["download_dir"], allowed_root) + def test_config_routes_hide_auth_fields_and_preserve_saved_credentials(self): APP.save_runtime_config( { @@ -2582,7 +2808,7 @@ class HandlerRouteTests(unittest.TestCase): self.assertNotIn("auth_password", get_handler.json_payload) def test_config_webhook_test_route_uses_unsaved_payload(self): - body = b'{"discord_webhook_url":"https://discord.example/webhook","discord_webhook_events":["runtime_error"]}' + body = b'{"discord_webhook_url":"https://discord.com/api/webhooks/123456/test-token","discord_webhook_events":["runtime_error"]}' handler = DummyHandler("/api/config/webhook/test", body=body, content_length=len(body)) test_webhook = mock.Mock(return_value={"message": "ok"}) handler.handler_context = mock.Mock(test_discord_webhook_config=test_webhook) @@ -2590,15 +2816,29 @@ class HandlerRouteTests(unittest.TestCase): self.assertEqual(handler.json_status, HTTPStatus.OK) self.assertEqual(handler.json_payload["message"], "ok") test_webhook.assert_called_once_with( - {"discord_webhook_url": "https://discord.example/webhook", "discord_webhook_events": ["runtime_error"]} + {"discord_webhook_url": "https://discord.com/api/webhooks/123456/test-token", "discord_webhook_events": ["runtime_error"]} ) + def test_config_webhook_test_route_rejects_non_discord_webhook_url(self): + body = b'{"discord_webhook_url":"https://example.com/not-discord","discord_webhook_events":["runtime_error"]}' + handler = DummyHandler("/api/config/webhook/test", body=body, content_length=len(body)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) + self.assertIn("official discord webhook host", handler.error_message.lower()) + def test_body_json_rejects_oversized_request(self): handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"{}", content_length=APP.MAX_JSON_BODY_BYTES + 1) with self.assertRaises(APP.HttpError) as ctx: APP.Handler.body_json(handler) self.assertEqual(ctx.exception.status, HTTPStatus.REQUEST_ENTITY_TOO_LARGE) + def test_body_json_requires_application_json_content_type(self): + handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"{}", content_length=2) + handler.headers["Content-Type"] = "text/plain" + with self.assertRaises(ValueError) as ctx: + APP.Handler.body_json(handler) + self.assertIn("content-type", str(ctx.exception).lower()) + def test_body_json_rejects_non_utf8_payload(self): handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"\xff", content_length=1) with self.assertRaises(ValueError) as ctx: