Files
supervisor/tests/api/middleware/test_security.py
2578b1bf03 Hassio integration auth bypass from app with API access (#7122)
* Block Core hassio_auth endpoints from the add-on proxy

The API security blacklist is meant to stop add-ons from reaching Core's
"hassio" endpoints through the /core/api and /homeassistant/api proxy, but
the pattern only matched "hassio/" (with a trailing slash). Core's auth
endpoints are served at /api/hassio_auth and /api/hassio_auth/password_reset,
so they slipped past the blacklist and were passed through to the proxy.

The proxy authenticates upstream to Core as the Supervisor user, and Core's
HassIOPasswordReset only checks that the caller is the Supervisor user (no
owner check). As a result an add-on with homeassistant_api access could reach
the password-reset endpoint through the proxy and reset any user's password,
including the owner.

Widen the boundary after "hassio" to match both the loopback ("hassio/...")
and the auth endpoints ("hassio_auth...") so all hassio-prefixed Core
endpoints are blocked, and extend the blacklist test to cover them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJ56EftdwXvqw2p3vjYS1z

* Refuse to proxy Core hassio endpoints (defense in depth)

Add a redundant guard in the Home Assistant API proxy so an add-on can never
reach Core's Supervisor-only "hassio" endpoints (hassio_auth,
hassio_auth/password_reset, the hassio loopback) through the proxy. These run
as the Supervisor user on Core, so forwarding them would let an add-on reset
arbitrary user passwords.

The security middleware blacklist already blocks these paths; this guard sits
at the proxy itself so the proxy cannot become a confused deputy if that
blacklist ever regresses. The two checks are independent by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJ56EftdwXvqw2p3vjYS1z

* Check access before denylist

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-12 08:15:50 +02:00

324 lines
12 KiB
Python

"""Test API security layer."""
import asyncio
from http import HTTPStatus
from unittest.mock import patch
from aiohttp import web
from aiohttp.test_utils import TestClient
import pytest
import urllib3
from supervisor.api import RestAPI
from supervisor.apps.app import App
from supervisor.const import ROLE_ALL, CoreState
from supervisor.coresys import CoreSys
# pylint: disable=redefined-outer-name
async def mock_handler(request):
"""Return OK."""
return web.Response(text="OK")
@pytest.fixture
async def api_system(aiohttp_client, coresys: CoreSys) -> TestClient:
"""Fixture for RestAPI client."""
api = RestAPI(coresys)
api.webapp = web.Application()
with patch("supervisor.docker.supervisor.os") as os:
os.environ = {"SUPERVISOR_NAME": "hassio_supervisor"}
await api.load()
api.webapp.middlewares.append(api.security.block_bad_requests)
api.webapp.middlewares.append(api.security.system_validation)
api.webapp.router.add_get("/{all:.*}", mock_handler)
return await aiohttp_client(api.webapp)
@pytest.fixture
async def api_token_validation(aiohttp_client, coresys: CoreSys) -> TestClient:
"""Fixture for RestAPI client with token validation middleware."""
api = RestAPI(coresys)
api.webapp = web.Application()
with patch("supervisor.docker.supervisor.os") as os:
os.environ = {"SUPERVISOR_NAME": "hassio_supervisor"}
await api.start()
api.webapp.middlewares.append(api.security.token_validation)
api.webapp.router.add_get("/{all:.*}", mock_handler)
api.webapp.router.add_post("/{all:.*}", mock_handler)
api.webapp.router.add_delete("/{all:.*}", mock_handler)
return await aiohttp_client(api.webapp)
@pytest.fixture(
name="api_token_validation_with_prefix",
params=[pytest.param("", id="v1"), pytest.param("/v2", id="v2")],
)
async def fixture_api_token_validation_with_prefix(
request: pytest.FixtureRequest,
api_token_validation: TestClient,
) -> tuple[TestClient, str]:
"""Provide (client, path_prefix) for token_validation on both API versions.
Mirrors api_client_with_prefix, but keeps the real token_validation
middleware (which api_client/api_client_v2 replace with a stub) so security
checks like the blacklist are actually exercised on the v1 and v2 paths.
"""
return api_token_validation, request.param
@pytest.fixture(name="plugin_tokens")
async def fixture_plugin_tokens(coresys: CoreSys) -> None:
"""Mock plugin tokens used in middleware."""
# pylint: disable=protected-access
coresys.plugins.cli._data["access_token"] = "c_123456"
coresys.plugins.observer._data["access_token"] = "o_123456"
# pylint: enable=protected-access
async def test_api_security_system_initialize(api_system: TestClient, coresys: CoreSys):
"""Test security."""
await coresys.core.set_state(CoreState.INITIALIZE)
resp = await api_system.get("/supervisor/ping")
result = await resp.json()
assert resp.status == 400
assert result["result"] == "error"
async def test_api_security_system_setup(api_system: TestClient, coresys: CoreSys):
"""Test security."""
await coresys.core.set_state(CoreState.SETUP)
resp = await api_system.get("/supervisor/ping")
result = await resp.json()
assert resp.status == 400
assert result["result"] == "error"
async def test_api_security_system_running(api_system: TestClient, coresys: CoreSys):
"""Test security."""
await coresys.core.set_state(CoreState.RUNNING)
resp = await api_system.get("/supervisor/ping")
assert resp.status == 200
async def test_api_security_system_startup(api_system: TestClient, coresys: CoreSys):
"""Test security."""
await coresys.core.set_state(CoreState.STARTUP)
resp = await api_system.get("/supervisor/ping")
assert resp.status == 200
@pytest.mark.parametrize(
("request_path", "request_params", "fail_on_query_string"),
[
("/proc/self/environ", {}, False),
("/", {"test": "/test/../../api"}, True),
("/", {"test": "test/../../api"}, True),
("/", {"test": "/test/%2E%2E%2f%2E%2E%2fapi"}, True),
("/", {"test": "test/%2E%2E%2f%2E%2E%2fapi"}, True),
("/", {"test": "test/%252E%252E/api"}, True),
("/", {"test": "test/%252E%252E%2fapi"}, True),
(
"/",
{"test": "test/%2525252E%2525252E%2525252f%2525252E%2525252E%2525252fapi"},
True,
),
("/test/.%252E/api", {}, False),
("/test/%252E%252E/api", {}, False),
("/test/%2E%2E%2f%2E%2E%2fapi", {}, False),
("/test/%2525252E%2525252E%2525252f%2525252E%2525252E/api", {}, False),
("/", {"sql": ";UNION SELECT (a, b"}, True),
("/", {"sql": "UNION%20SELECT%20%28a%2C%20b"}, True),
("/UNION%20SELECT%20%28a%2C%20b", {}, False),
("/", {"sql": "concat(..."}, True),
("/", {"xss": "<script >"}, True),
("/<script >", {"xss": ""}, False),
("/%3Cscript%3E", {}, False),
],
)
async def test_bad_requests(
request_path: str,
request_params: dict[str, str],
fail_on_query_string: bool,
api_system: TestClient,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test request paths that should be filtered."""
# Manual params handling
if request_params:
raw_params = "&".join(f"{val}={key}" for val, key in request_params.items())
man_params = f"?{raw_params}"
else:
man_params = ""
http = urllib3.PoolManager()
resp = await asyncio.get_running_loop().run_in_executor(
None,
http.request,
"GET",
f"http://{api_system.host}:{api_system.port}{request_path}{man_params}",
request_params,
)
assert resp.status == HTTPStatus.BAD_REQUEST
message = "Filtered a potential harmful request to:"
if fail_on_query_string:
message = "Filtered a request with a potential harmful query string:"
assert message in caplog.text
def _versioned_path(prefix: str, path: str) -> str:
"""Translate a v1 middleware path to the requested API version.
The v2 sub-app keeps the same paths behind a /v2 prefix, except the add-on
routes which are renamed /addons/... -> /apps/.... The role/bypass/core_only
expectations are otherwise identical, which is exactly the v1<->v2 pattern
parity these tests guard.
"""
if not prefix:
return path
return prefix + path.replace("/addons", "/apps")
@pytest.mark.parametrize(
("request_method", "request_path", "success_roles"),
[
("post", "/auth/reset", {"admin"}),
("get", "/auth/list", {"admin"}),
("delete", "/auth/cache", {"admin", "manager"}),
("get", "/auth", set(ROLE_ALL)),
("post", "/auth", set(ROLE_ALL)),
("get", "/backups/info", set(ROLE_ALL)),
("get", "/backups/abc123/download", {"admin", "manager", "backup"}),
("post", "/backups/new/full", {"admin", "manager", "backup"}),
("post", "/backups/abc123/restore/full", {"admin", "manager", "backup"}),
("get", "/core/info", set(ROLE_ALL)),
("post", "/core/update", {"admin", "manager", "homeassistant"}),
("post", "/core/restart", {"admin", "manager", "homeassistant"}),
("get", "/addons/self/options/config", set(ROLE_ALL)),
("post", "/addons/self/options", set(ROLE_ALL)),
("post", "/addons/self/restart", set(ROLE_ALL)),
("post", "/addons/self/security", {"admin"}),
("get", "/addons/abc123/options/config", {"admin", "manager"}),
("post", "/addons/abc123/options", {"admin", "manager"}),
("post", "/addons/abc123/restart", {"admin", "manager"}),
("post", "/addons/abc123/security", {"admin"}),
("post", "/os/datadisk/wipe", {"admin"}),
("post", "/addons/self/sys_options", set()),
("post", "/addons/abc123/sys_options", set()),
],
)
@pytest.mark.usefixtures("plugin_tokens")
async def test_token_validation(
api_token_validation_with_prefix: tuple[TestClient, str],
install_app_example: App,
request_method: str,
request_path: str,
success_roles: set[str],
):
"""Test token validation paths on both API versions."""
client, prefix = api_token_validation_with_prefix
request_path = _versioned_path(prefix, request_path)
install_app_example.persist["access_token"] = "abc123"
install_app_example.data["hassio_api"] = True
for role in success_roles:
install_app_example.data["hassio_role"] = role
resp = await getattr(client, request_method)(
request_path, headers={"Authorization": "Bearer abc123"}
)
assert resp.status == 200
for role in set(ROLE_ALL) - success_roles:
install_app_example.data["hassio_role"] = role
resp = await getattr(client, request_method)(
request_path, headers={"Authorization": "Bearer abc123"}
)
assert resp.status == 403
@pytest.mark.usefixtures("plugin_tokens")
async def test_home_assistant_paths(
api_token_validation_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test Home Assistant only paths on both API versions."""
client, prefix = api_token_validation_with_prefix
coresys.homeassistant.supervisor_token = "abc123"
resp = await client.post(
_versioned_path(prefix, "/addons/local_test/sys_options"),
headers={"Authorization": "Bearer abc123"},
)
assert resp.status == 200
@pytest.mark.usefixtures("plugin_tokens")
async def test_blacklist(
api_token_validation_with_prefix: tuple[TestClient, str],
install_app_example: App,
):
"""Test the Core API hassio loopback is blocked on every API version."""
client, prefix = api_token_validation_with_prefix
install_app_example.persist["access_token"] = "abc123"
install_app_example.data["hassio_api"] = True
install_app_example.data["hassio_role"] = "admin"
# The hassio loopback is blacklisted regardless of role
resp = await client.get(
f"{prefix}/core/api/hassio/app", headers={"Authorization": "Bearer abc123"}
)
assert resp.status == 403
# The Core auth endpoints run as the Supervisor user; an add-on must not
# reach them through the proxy (they would allow resetting any password)
resp = await client.get(
f"{prefix}/core/api/hassio_auth", headers={"Authorization": "Bearer abc123"}
)
assert resp.status == 403
resp = await client.post(
f"{prefix}/core/api/hassio_auth/password_reset",
headers={"Authorization": "Bearer abc123"},
)
assert resp.status == 403
# A normal (non-hassio) Core API call through the same proxy is allowed
resp = await client.get(
f"{prefix}/core/api/states", headers={"Authorization": "Bearer abc123"}
)
assert resp.status == 200
@pytest.mark.usefixtures("plugin_tokens")
async def test_blacklist_legacy_alias(
api_token_validation: TestClient,
install_app_example: App,
):
"""Test the legacy /homeassistant proxy alias (v1 only) is blacklisted."""
install_app_example.persist["access_token"] = "abc123"
install_app_example.data["hassio_api"] = True
install_app_example.data["hassio_role"] = "admin"
resp = await api_token_validation.get(
"/homeassistant/api/hassio/app", headers={"Authorization": "Bearer abc123"}
)
assert resp.status == 403
async def test_api_security_system_stopping(api_system: TestClient, coresys: CoreSys):
"""Test API requests are rejected while the Supervisor is stopping."""
await coresys.core.set_state(CoreState.STOPPING)
resp = await api_system.get("/supervisor/ping")
result = await resp.json()
assert resp.status == 400
assert result["result"] == "error"