Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add default headers to webserver responses #97784

Merged
merged 3 commits into from Aug 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions homeassistant/components/http/__init__.py
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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,
}
),
)
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions 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)
44 changes: 44 additions & 0 deletions 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
1 change: 1 addition & 0 deletions tests/scripts/test_check_config.py
Expand Up @@ -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"}
Expand Down