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
+8
View File
@@ -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."}
+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":
+113 -10
View File
@@ -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'<p class="error">{message}</p>'
error_html = f'<p class="error">{html_escape(message)}</p>'
html = f"""<!doctype html>
<html lang="en">
<head>
@@ -390,7 +444,7 @@ class Handler(BaseHTTPRequestHandler):
</div>
{error_html}
<form method="post" action="/auth/login">
<input type="hidden" name="next" value="{next_path}">
<input type="hidden" name="next" value="{html_escape(next_path, quote=True)}">
<label>Username
<input name="username" type="text" autocomplete="username" autofocus required>
</label>
@@ -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:
+258 -18
View File
@@ -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="><script>alert(1)</script>')
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('<script>alert(1)</script>', 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: