Files
ProxMenux/AppImage/scripts/security_headers.py
T

103 lines
3.0 KiB
Python

#!/usr/bin/env python3
"""Helpers for ProxMenux HTTP security headers."""
from __future__ import annotations
import os
import re
from urllib.parse import urlparse
PRIMARY_FRAME_ANCESTORS_ENV = "PROXMENUX_ALLOWED_FRAME_ANCESTORS"
COMPAT_FRAME_ANCESTORS_ENV = "ALLOWED_FRAME_ANCESTORS"
_FRAME_ANCESTOR_KEYWORDS = {
"self": "'self'",
"'self'": "'self'",
}
_UNSAFE_CSP_CHARS = re.compile(r"[\r\n;]")
_FRAME_ANCESTOR_SEPARATOR = re.compile(r"[\s,]+")
_CSP_PREFIX = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https:; "
"font-src 'self' data:; "
"connect-src 'self' ws: wss: https:; "
)
_CSP_SUFFIX = "base-uri 'self'; form-action 'self'"
def _split_frame_ancestor_sources(raw_value: str) -> list[str]:
return [
source.strip()
for source in _FRAME_ANCESTOR_SEPARATOR.split(raw_value)
if source.strip()
]
def _normalize_frame_ancestor_source(source: str) -> str | None:
token = source.strip()
lowered = token.lower()
if lowered in _FRAME_ANCESTOR_KEYWORDS:
return _FRAME_ANCESTOR_KEYWORDS[lowered]
if not token or _UNSAFE_CSP_CHARS.search(token):
return None
# Keep the initial support intentionally narrow: exact HTTP(S) origins.
# Broad schemes, wildcards, paths, queries, and credentials are rejected.
if token in {"*", "http:", "https:"}:
return None
parsed = urlparse(token)
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
return None
if parsed.username or parsed.password:
return None
try:
parsed.port
except ValueError:
return None
if parsed.path not in ("", "/") or parsed.params or parsed.query or parsed.fragment:
return None
if not parsed.hostname or "*" in parsed.hostname:
return None
return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
def get_allowed_frame_ancestors(environ: dict[str, str] | None = None) -> list[str]:
"""Return sanitized frame-ancestor CSP sources from environment settings."""
environ = os.environ if environ is None else environ
raw_value = environ.get(PRIMARY_FRAME_ANCESTORS_ENV, "").strip()
if not raw_value:
raw_value = environ.get(COMPAT_FRAME_ANCESTORS_ENV, "").strip()
sources: list[str] = []
seen: set[str] = set()
for raw_source in _split_frame_ancestor_sources(raw_value):
source = _normalize_frame_ancestor_source(raw_source)
if source and source not in seen:
sources.append(source)
seen.add(source)
return sources
def build_content_security_policy(frame_ancestors: list[str] | None = None) -> str:
ancestors_value = " ".join(frame_ancestors or []) or "'none'"
return _CSP_PREFIX + f"frame-ancestors {ancestors_value}; " + _CSP_SUFFIX
def should_emit_x_frame_options(frame_ancestors: list[str] | None = None) -> bool:
return not bool(frame_ancestors)