diff --git a/AppImage/README.md b/AppImage/README.md index 8c221020..8f51ab23 100644 --- a/AppImage/README.md +++ b/AppImage/README.md @@ -201,6 +201,20 @@ After setting up your password, you can enable 2FA using any TOTP authenticator ![2FA Setup](https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/2fa-setup.png) +### 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 **IMPORTANT**: Never hardcode your API tokens directly in configuration files or scripts. Instead, use environment variables or secrets management. diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index ee530e24..18b04606 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -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 from jwt_middleware import require_auth, require_auth_or_ticket, require_admin_scope # noqa: E402 import auth_manager # noqa: E402 +import security_headers # noqa: E402 # ------------------------------------------------------------------- # Logging @@ -237,24 +238,23 @@ init_terminal_routes(app) # printable report use inline styles by design. # `connect-src` includes `wss:` for terminal WebSockets and `https:` for # 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 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. if 'Content-Security-Policy' not in response.headers: response.headers['Content-Security-Policy'] = ( - "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:; " - "frame-ancestors 'none'; " - "base-uri 'self'; " - "form-action 'self'" + security_headers.build_content_security_policy(frame_ancestors) ) response.headers.setdefault('X-Content-Type-Options', 'nosniff') 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 diff --git a/AppImage/scripts/security_headers.py b/AppImage/scripts/security_headers.py new file mode 100644 index 00000000..a93ac1c0 --- /dev/null +++ b/AppImage/scripts/security_headers.py @@ -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) diff --git a/AppImage/scripts/tests/test_security_headers.py b/AppImage/scripts/tests/test_security_headers.py new file mode 100644 index 00000000..68b8e5f2 --- /dev/null +++ b/AppImage/scripts/tests/test_security_headers.py @@ -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()