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
+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: