mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-10-01 00:04:00 +01:00
The Core API proxy compared its deny pattern against the once-decoded route capture and then formatted that string into the upstream URL, where yarl decoded percent-encoded unreserved characters a second time. A path such as hassio%255Fauth/password_reset passed both the middleware blacklist and the proxy's deny check but reached Core as /api/hassio_auth/password_reset, which the proxy executes as the Supervisor user. Forward the raw path exactly as received instead of the decoded capture, and build the upstream URL with encoded=True so the bytes checked are the bytes sent. This also stops lossy re-encoding of legitimate paths, e.g. an encoded slash no longer turns into a path separator. Match both the middleware blacklist and the proxy deny pattern against the recursively unquoted path so any encoding depth resolves to the same decision. The recursive unquote helper moves to module level so both call sites share it. Reported in GHSA-m2gm-724m-7rf9. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
349 lines
12 KiB
Python
349 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"}),
|
|
("get", "/os/ssh/authorized_keys", set()),
|
|
("post", "/os/ssh/authorized_keys", set()),
|
|
("delete", "/os/ssh/authorized_keys", set()),
|
|
("get", "/time/info", set(ROLE_ALL)),
|
|
("post", "/time/options", {"admin", "manager"}),
|
|
("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
|
|
|
|
for method in ("get", "post", "delete"):
|
|
resp = await getattr(client, method)(
|
|
_versioned_path(prefix, "/os/ssh/authorized_keys"),
|
|
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
|
|
|
|
# Percent-encoded variants are matched on the fully decoded path. Only the
|
|
# first decode happens in aiohttp; a second one would happen downstream.
|
|
for encoded in (
|
|
"hassio%5Fauth/password_reset",
|
|
"hassio%255Fauth/password_reset",
|
|
"hassio%252Fapp",
|
|
):
|
|
resp = await client.post(
|
|
f"{prefix}/core/api/{encoded}",
|
|
headers={"Authorization": "Bearer abc123"},
|
|
)
|
|
assert resp.status == 403, encoded
|
|
|
|
# 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"
|