diff --git a/.env.example b/.env.example index 0f62d9b..5c99cfa 100644 --- a/.env.example +++ b/.env.example @@ -13,11 +13,6 @@ PYTORRENT_SCGI_RETRIES=8 # css/js libs PYTORRENT_USE_OFFLINE_LIBS=true -# Reverse proxy / HTTPS -PYTORRENT_PROXY_FIX_ENABLE=false -PYTORRENT_SESSION_COOKIE_SECURE=false -# PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS=https://your-domain.com - # Retention / Smart Queue PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90 PYTORRENT_JOBS_RETENTION_DAYS=30 @@ -64,3 +59,9 @@ PYTORRENT_AUTH_PROXY_AUTO_CREATE=true # Defaults for auto-created users PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw + +# Reverse proxy / HTTPS +PYTORRENT_PROXY_FIX_ENABLE=true +PYTORRENT_SESSION_COOKIE_SECURE=false +#PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS=https://pytorrent.domain.com +#PYTORRENT_API_ALLOWED_ORIGINS=https://pytorrent.domain.com \ No newline at end of file diff --git a/auth.md b/auth.md index affafd9..c598e5b 100644 --- a/auth.md +++ b/auth.md @@ -33,6 +33,28 @@ PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw ``` + +## Reverse proxy origin checks + +pyTorrent blocks unsafe API requests when the browser `Origin`/`Referer` does not match the application origin. Behind HTTPS reverse proxy this requires either correct forwarded headers or an explicit API origin allowlist. + +Recommended variables for reverse proxy mode: + +```env +PYTORRENT_PROXY_FIX_ENABLE=true +PYTORRENT_SESSION_COOKIE_SECURE=true +PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS=https://pytorrent.example.com +PYTORRENT_API_ALLOWED_ORIGINS=https://pytorrent.example.com +``` + +`PYTORRENT_API_ALLOWED_ORIGINS` accepts a comma-separated list, for example: + +```env +PYTORRENT_API_ALLOWED_ORIGINS=https://pytorrent.example.com +``` + +If `PYTORRENT_API_ALLOWED_ORIGINS` is not set, pyTorrent reuses `PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS` for API origin checks. + ## Local authentication Use this when pyTorrent should manage its own login screen and passwords. @@ -80,14 +102,14 @@ location / { } location /tinyauth { - proxy_pass http://10.87.7.99:3000/api/auth/nginx; + proxy_pass http://10.10.11.11:3000/api/auth/nginx; proxy_set_header x-forwarded-proto $scheme; proxy_set_header x-forwarded-host $http_host; proxy_set_header x-forwarded-uri $request_uri; } location @tinyauth_login { - return 302 http://auth.linuxiarz.pl/login?redirect_uri=$scheme://$http_host$request_uri; + return 302 http://auth.domian/login?redirect_uri=$scheme://$http_host$request_uri; } ``` diff --git a/pytorrent/config.py b/pytorrent/config.py index 82851a7..99c6f3a 100644 --- a/pytorrent/config.py +++ b/pytorrent/config.py @@ -86,8 +86,14 @@ PROXY_FIX_X_HOST = _env_int("PYTORRENT_PROXY_FIX_X_HOST", 1, 0) PROXY_FIX_X_PORT = _env_int("PYTORRENT_PROXY_FIX_X_PORT", 1, 0) PROXY_FIX_X_PREFIX = _env_int("PYTORRENT_PROXY_FIX_X_PREFIX", 1, 0) +def _env_csv(name: str) -> list[str]: + return [item.strip().rstrip("/") for item in os.getenv(name, "").split(",") if item.strip()] + _SOCKETIO_CORS = os.getenv("PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS", "").strip() SOCKETIO_CORS_ALLOWED_ORIGINS = None if not _SOCKETIO_CORS else [item.strip() for item in _SOCKETIO_CORS.split(",") if item.strip()] +# Note: API origin checks are separate from Socket.IO CORS. When unset, reuse the Socket.IO allowlist for operator-friendly reverse proxy setups. +_API_ALLOWED_ORIGINS = _env_csv("PYTORRENT_API_ALLOWED_ORIGINS") +API_ALLOWED_ORIGINS = _API_ALLOWED_ORIGINS or _env_csv("PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS") TRAFFIC_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS", 90, 1) JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1) diff --git a/pytorrent/services/auth.py b/pytorrent/services/auth.py index eb564fb..16858d8 100644 --- a/pytorrent/services/auth.py +++ b/pytorrent/services/auth.py @@ -16,6 +16,7 @@ from ..config import ( AUTH_PROXY_AUTO_CREATE_PERMISSION, AUTH_PROXY_AUTO_CREATE_ROLE, AUTH_PROXY_USER_HEADER, + API_ALLOWED_ORIGINS, ) from ..db import connect, default_user_id, utcnow @@ -171,14 +172,29 @@ def visible_profile_ids(user_id: int | None = None) -> set[int] | None: +def _origin_key(value: str) -> str: + parsed = urlparse(str(value or "").strip()) + if not parsed.scheme or not parsed.netloc: + return "" + return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}" + + +def _request_origin() -> str: + return _origin_key(f"{request.scheme}://{request.host}") + + def same_origin_request() -> bool: - """Return False only when an unsafe request clearly comes from another origin.""" + """Return False only when an unsafe API request clearly comes from an untrusted origin.""" origin = request.headers.get("Origin") or request.headers.get("Referer") if not origin: return True try: - parsed = urlparse(origin) - return parsed.scheme == request.scheme and parsed.netloc == request.host + source_origin = _origin_key(origin) + if not source_origin: + return False + if source_origin == _request_origin(): + return True + return source_origin in set(API_ALLOWED_ORIGINS) except Exception: return False