mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 01:56:44 +00:00
fix(security): allow trusted frame ancestors
This commit is contained in:
@@ -201,6 +201,20 @@ After setting up your password, you can enable 2FA using any TOTP authenticator
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### Embedding in Trusted Iframes
|
||||||
|
|
||||||
|
By default, ProxMenux Monitor blocks embedding in iframes with `frame-ancestors 'none'` and `X-Frame-Options: DENY`.
|
||||||
|
|
||||||
|
If you run a trusted local portal or monitoring page and need to embed the Monitor, set `PROXMENUX_ALLOWED_FRAME_ANCESTORS` to the exact parent origins that may frame it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PROXMENUX_ALLOWED_FRAME_ANCESTORS="https://portal.example.com http://raspberrypi.local:8080"
|
||||||
|
```
|
||||||
|
|
||||||
|
Only exact `http://` or `https://` origins are accepted. Paths, wildcards, broad schemes, credentials, and malformed values are ignored. When this setting is present, the Monitor sends a matching CSP `frame-ancestors` allowlist and omits `X-Frame-Options`, because that legacy header cannot express multiple allowed parents.
|
||||||
|
|
||||||
|
`ALLOWED_FRAME_ANCESTORS` is also accepted as a compatibility alias when `PROXMENUX_ALLOWED_FRAME_ANCESTORS` is not set.
|
||||||
|
|
||||||
### Security Best Practices for API Tokens
|
### Security Best Practices for API Tokens
|
||||||
|
|
||||||
**IMPORTANT**: Never hardcode your API tokens directly in configuration files or scripts. Instead, use environment variables or secrets management.
|
**IMPORTANT**: Never hardcode your API tokens directly in configuration files or scripts. Instead, use environment variables or secrets management.
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ from notification_manager import notification_manager # noqa: E402
|
|||||||
import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates
|
import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates
|
||||||
from jwt_middleware import require_auth, require_auth_or_ticket, require_admin_scope # noqa: E402
|
from jwt_middleware import require_auth, require_auth_or_ticket, require_admin_scope # noqa: E402
|
||||||
import auth_manager # noqa: E402
|
import auth_manager # noqa: E402
|
||||||
|
import security_headers # noqa: E402
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -237,24 +238,23 @@ init_terminal_routes(app)
|
|||||||
# printable report use inline styles by design.
|
# printable report use inline styles by design.
|
||||||
# `connect-src` includes `wss:` for terminal WebSockets and `https:` for
|
# `connect-src` includes `wss:` for terminal WebSockets and `https:` for
|
||||||
# third-party AI providers (OpenAI / Anthropic).
|
# third-party AI providers (OpenAI / Anthropic).
|
||||||
|
# `frame-ancestors` defaults to `'none'`. Operators can explicitly allow
|
||||||
|
# trusted embedding parents with PROXMENUX_ALLOWED_FRAME_ANCESTORS.
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def _apply_security_headers(response):
|
def _apply_security_headers(response):
|
||||||
|
frame_ancestors = security_headers.get_allowed_frame_ancestors()
|
||||||
|
|
||||||
# Don't override if a downstream handler already set a custom CSP.
|
# Don't override if a downstream handler already set a custom CSP.
|
||||||
if 'Content-Security-Policy' not in response.headers:
|
if 'Content-Security-Policy' not in response.headers:
|
||||||
response.headers['Content-Security-Policy'] = (
|
response.headers['Content-Security-Policy'] = (
|
||||||
"default-src 'self'; "
|
security_headers.build_content_security_policy(frame_ancestors)
|
||||||
"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:; "
|
|
||||||
"frame-ancestors 'none'; "
|
|
||||||
"base-uri 'self'; "
|
|
||||||
"form-action 'self'"
|
|
||||||
)
|
)
|
||||||
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
|
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
|
||||||
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
|
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||||
response.headers.setdefault('X-Frame-Options', 'DENY')
|
if security_headers.should_emit_x_frame_options(frame_ancestors):
|
||||||
|
response.headers.setdefault('X-Frame-Options', 'DENY')
|
||||||
|
else:
|
||||||
|
response.headers.pop('X-Frame-Options', None)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#!/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)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
import security_headers
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityHeadersTests(unittest.TestCase):
|
||||||
|
def test_default_csp_blocks_all_frame_ancestors_and_keeps_xfo(self):
|
||||||
|
frame_ancestors = security_headers.get_allowed_frame_ancestors({})
|
||||||
|
|
||||||
|
csp = security_headers.build_content_security_policy(frame_ancestors)
|
||||||
|
|
||||||
|
self.assertIn("frame-ancestors 'none'", csp)
|
||||||
|
self.assertTrue(security_headers.should_emit_x_frame_options(frame_ancestors))
|
||||||
|
|
||||||
|
def test_primary_env_allows_exact_http_and_https_origins(self):
|
||||||
|
frame_ancestors = security_headers.get_allowed_frame_ancestors({
|
||||||
|
"PROXMENUX_ALLOWED_FRAME_ANCESTORS": (
|
||||||
|
"https://dashboard.example.test, "
|
||||||
|
"http://raspberrypi.local:8080 http://10.0.0.5"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
frame_ancestors,
|
||||||
|
[
|
||||||
|
"https://dashboard.example.test",
|
||||||
|
"http://raspberrypi.local:8080",
|
||||||
|
"http://10.0.0.5",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"frame-ancestors https://dashboard.example.test "
|
||||||
|
"http://raspberrypi.local:8080 http://10.0.0.5",
|
||||||
|
security_headers.build_content_security_policy(frame_ancestors),
|
||||||
|
)
|
||||||
|
self.assertFalse(security_headers.should_emit_x_frame_options(frame_ancestors))
|
||||||
|
|
||||||
|
def test_compat_env_is_used_when_primary_env_is_empty(self):
|
||||||
|
frame_ancestors = security_headers.get_allowed_frame_ancestors({
|
||||||
|
"ALLOWED_FRAME_ANCESTORS": "https://legacy.example.test",
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(frame_ancestors, ["https://legacy.example.test"])
|
||||||
|
|
||||||
|
def test_primary_env_takes_precedence_over_compat_env(self):
|
||||||
|
frame_ancestors = security_headers.get_allowed_frame_ancestors({
|
||||||
|
"PROXMENUX_ALLOWED_FRAME_ANCESTORS": "https://primary.example.test",
|
||||||
|
"ALLOWED_FRAME_ANCESTORS": "https://compat.example.test",
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(frame_ancestors, ["https://primary.example.test"])
|
||||||
|
|
||||||
|
def test_invalid_and_overly_broad_sources_are_rejected(self):
|
||||||
|
frame_ancestors = security_headers.get_allowed_frame_ancestors({
|
||||||
|
"PROXMENUX_ALLOWED_FRAME_ANCESTORS": (
|
||||||
|
"* https: http://valid.example.test "
|
||||||
|
"https://with-path.example.test/app "
|
||||||
|
"javascript:alert(1) "
|
||||||
|
"https://evil.example.test;frame-src * "
|
||||||
|
"https://user:pass@example.test "
|
||||||
|
"https://invalid-port.example.test:nope "
|
||||||
|
"example.test"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(frame_ancestors, ["http://valid.example.test"])
|
||||||
|
|
||||||
|
def test_self_keyword_is_normalized_and_sources_are_deduplicated(self):
|
||||||
|
frame_ancestors = security_headers.get_allowed_frame_ancestors({
|
||||||
|
"PROXMENUX_ALLOWED_FRAME_ANCESTORS": (
|
||||||
|
"self 'self' HTTPS://Dashboard.Example.Test "
|
||||||
|
"https://dashboard.example.test/"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(frame_ancestors, ["'self'", "https://dashboard.example.test"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user