Ctrl K
ComunidadGuías…

backend/proxy_auth.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Reverse-proxy header authentication helpers.

When enabled (UISettings.proxy_auth_enabled), a request is authenticated if its
DIRECT peer IP (``request.remote_addr`` — Sublarr applies no ProxyFix, so this
is the real TCP peer) is within an operator-configured trusted-proxy network
AND it carries a non-empty configured identity header (default ``Remote-User``).
Lets Authelia/authentik SSO satisfy the UI-auth gate. Fails closed on any
misconfiguration.

Operator requirement / Security note:
    This mechanism's security depends entirely on the trusted reverse proxy
    SETTING/OVERWRITING the identity header itself AND STRIPPING any
    client-supplied copy of that header before forwarding the request to
    Sublarr. Sublarr trusts the header's presence purely because the request
    came from an allow-listed IP — it has no way to tell whether the header
    value was set by the proxy or forged by the client. If the proxy forwards
    an incoming ``Remote-User`` (or whatever header is configured) verbatim
    instead of stripping/overwriting it, ANY client within the trusted IP
    range (e.g. another host on the same LAN/VLAN as the proxy) can forge an
    arbitrary identity by sending that header itself, fully bypassing
    authentication. Operators MUST configure their reverse proxy (Authelia,
    authentik, nginx, Traefik, etc.) to strip client-supplied identity headers
    on the public/untrusted side and only ever set them on the
    proxy-to-Sublarr hop.
"""

from __future__ import annotations

import ipaddress
import logging

logger = logging.getLogger(__name__)

_Network = ipaddress.IPv4Network | ipaddress.IPv6Network


def parse_trusted_networks(raw: str) -> list[_Network]:
    """Parse comma-separated IPs/CIDRs into network objects.

    Bare IPs become host networks (/32 or /128). Invalid entries are skipped
    with a warning.
    """
    networks: list[_Network] = []
    for part in (raw or "").split(","):
        part = part.strip()
        if not part:
            continue
        try:
            networks.append(ipaddress.ip_network(part, strict=False))
        except ValueError:
            logger.warning("Ignoring invalid trusted-proxy entry: %r", part)
    return networks


def ip_in_networks(ip_str: str | None, networks: list[_Network]) -> bool:
    """True if ip_str is a valid address contained in any of networks.

    Version mismatches (v4 address vs v6 network) return False, not an error.

    On dual-stack binds, a trusted proxy's peer address may arrive as an
    IPv4-mapped IPv6 address (e.g. ``::ffff:10.1.2.3``), which does not match
    an IPv4 CIDR by itself. If ip_str parses as such an address, its embedded
    IPv4 address is also checked against networks.
    """
    if not ip_str:
        return False
    try:
        ip = ipaddress.ip_address(ip_str)
    except ValueError:
        return False
    if any(ip in net for net in networks):
        return True
    mapped = getattr(ip, "ipv4_mapped", None)
    if mapped is not None:
        return any(mapped in net for net in networks)
    return False


def request_has_valid_proxy_auth() -> bool:
    """True if the current request is authenticated via a trusted reverse proxy.

    All must hold: feature enabled → a non-empty trusted-proxy allowlist is
    configured → the request's direct peer IP is within it → the configured
    identity header is present and non-empty. Any failure → False (fail closed).
    """
    from flask import request

    from config import get_settings

    settings = get_settings()
    if not getattr(settings, "proxy_auth_enabled", False):
        return False
    networks = parse_trusted_networks(getattr(settings, "proxy_auth_trusted_ips", "") or "")
    if not networks:
        return False  # enabled but no allowlist → never trust (fail closed)
    if not ip_in_networks(request.remote_addr, networks):
        return False
    header_name = getattr(settings, "proxy_auth_header", "Remote-User") or "Remote-User"
    return bool(request.headers.get(header_name, "").strip())