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