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