From 369a484a786a6734c7b6bf59151304fc6db03fef Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 7 Aug 2023 05:25:13 +0200 Subject: [PATCH] Add default headers to webserver responses (#97784) * Add default headers to webserver responses * Set default server header * Fix other tests --- homeassistant/components/http/__init__.py | 8 +++++ homeassistant/components/http/headers.py | 32 +++++++++++++++++ tests/components/http/test_headers.py | 44 +++++++++++++++++++++++ tests/scripts/test_check_config.py | 1 + 4 files changed, 85 insertions(+) create mode 100644 homeassistant/components/http/headers.py create mode 100644 tests/components/http/test_headers.py diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 68602e34d3e29..48ad0cb875274 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -53,6 +53,7 @@ ) from .cors import setup_cors from .forwarded import async_setup_forwarded +from .headers import setup_headers from .request_context import current_request, setup_request_context from .security_filter import setup_security_filter from .static import CACHE_HEADERS, CachingStaticResource @@ -69,6 +70,7 @@ CONF_SSL_KEY: Final = "ssl_key" CONF_CORS_ORIGINS: Final = "cors_allowed_origins" CONF_USE_X_FORWARDED_FOR: Final = "use_x_forwarded_for" +CONF_USE_X_FRAME_OPTIONS: Final = "use_x_frame_options" CONF_TRUSTED_PROXIES: Final = "trusted_proxies" CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold" CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled" @@ -118,6 +120,7 @@ vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): vol.In( [SSL_INTERMEDIATE, SSL_MODERN] ), + vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean, } ), ) @@ -136,6 +139,7 @@ class ConfData(TypedDict, total=False): ssl_key: str cors_allowed_origins: list[str] use_x_forwarded_for: bool + use_x_frame_options: bool trusted_proxies: list[IPv4Network | IPv6Network] login_attempts_threshold: int ip_ban_enabled: bool @@ -180,6 +184,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ssl_key = conf.get(CONF_SSL_KEY) cors_origins = conf[CONF_CORS_ORIGINS] use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False) + use_x_frame_options = conf[CONF_USE_X_FRAME_OPTIONS] trusted_proxies = conf.get(CONF_TRUSTED_PROXIES) or [] is_ban_enabled = conf[CONF_IP_BAN_ENABLED] login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD] @@ -200,6 +205,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: use_x_forwarded_for=use_x_forwarded_for, login_threshold=login_threshold, is_ban_enabled=is_ban_enabled, + use_x_frame_options=use_x_frame_options, ) async def stop_server(event: Event) -> None: @@ -331,6 +337,7 @@ async def async_initialize( use_x_forwarded_for: bool, login_threshold: int, is_ban_enabled: bool, + use_x_frame_options: bool, ) -> None: """Initialize the server.""" self.app[KEY_HASS] = self.hass @@ -348,6 +355,7 @@ async def async_initialize( await async_setup_auth(self.hass, self.app) + setup_headers(self.app, use_x_frame_options) setup_cors(self.app, cors_origins) if self.ssl_certificate: diff --git a/homeassistant/components/http/headers.py b/homeassistant/components/http/headers.py new file mode 100644 index 0000000000000..b53f354b144ad --- /dev/null +++ b/homeassistant/components/http/headers.py @@ -0,0 +1,32 @@ +"""Middleware that helps with the control of headers in our responses.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from aiohttp.web import Application, Request, StreamResponse, middleware + +from homeassistant.core import callback + + +@callback +def setup_headers(app: Application, use_x_frame_options: bool) -> None: + """Create headers middleware for the app.""" + + @middleware + async def headers_middleware( + request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] + ) -> StreamResponse: + """Process request and add headers to the responses.""" + response = await handler(request) + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["X-Content-Type-Options"] = "nosniff" + + # Set an empty server header, to prevent aiohttp of setting one. + response.headers["Server"] = "" + + if use_x_frame_options: + response.headers["X-Frame-Options"] = "SAMEORIGIN" + + return response + + app.middlewares.append(headers_middleware) diff --git a/tests/components/http/test_headers.py b/tests/components/http/test_headers.py new file mode 100644 index 0000000000000..6d7dbad68f643 --- /dev/null +++ b/tests/components/http/test_headers.py @@ -0,0 +1,44 @@ +"""Test headers middleware.""" +from http import HTTPStatus + +from aiohttp import web + +from homeassistant.components.http.headers import setup_headers + +from tests.typing import ClientSessionGenerator + + +async def mock_handler(request): + """Return OK.""" + return web.Response(text="OK") + + +async def test_headers_added(aiohttp_client: ClientSessionGenerator) -> None: + """Test that headers are being added on each request.""" + app = web.Application() + app.router.add_get("/", mock_handler) + + setup_headers(app, use_x_frame_options=True) + + mock_api_client = await aiohttp_client(app) + resp = await mock_api_client.get("/") + + assert resp.status == HTTPStatus.OK + assert resp.headers["Referrer-Policy"] == "no-referrer" + assert resp.headers["Server"] == "" + assert resp.headers["X-Content-Type-Options"] == "nosniff" + assert resp.headers["X-Frame-Options"] == "SAMEORIGIN" + + +async def test_allow_framing(aiohttp_client: ClientSessionGenerator) -> None: + """Test that we allow framing when disabled.""" + app = web.Application() + app.router.add_get("/", mock_handler) + + setup_headers(app, use_x_frame_options=False) + + mock_api_client = await aiohttp_client(app) + resp = await mock_api_client.get("/") + + assert resp.status == HTTPStatus.OK + assert "X-Frame-Options" not in resp.headers diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 44a4d55d545a5..e410dd672ce92 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -117,6 +117,7 @@ def test_secrets(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: "login_attempts_threshold": -1, "server_port": 8123, "ssl_profile": "modern", + "use_x_frame_options": True, } assert res["secret_cache"] == { get_test_config_dir("secrets.yaml"): {"http_pw": "http://google.com"}