mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-10-01 09:48:09 +01:00
* Add API to manage SSH authorized keys on Home Assistant OS
The OS Agent has long exposed AddSSHAuthKey and ClearSSHAuthKeys on its
io.hass.os System D-Bus object, but the Supervisor never wrapped them, so
there was no way to manage root's SSH authorized keys through the
Supervisor API. Add POST /os/ssh/authorized_keys, which replaces the
configured keys with the submitted list (an empty list just clears them).
Since OS Agent writes each key verbatim to /root/.ssh/authorized_keys as
root, the endpoint validates strictly before anything is written: plain
public keys only (no options, no certificates), a key type allowlist
matching what dropbear on Home Assistant OS can verify, no control
characters (one submitted key can never write more than one line), the
base64 blob must embed the declared key type, and entries are capped at
dropbear's 3000 byte per-line limit. The endpoint is admin-only for
add-on tokens.
Replacement clears the existing keys and then adds each key. OS Agent
releases up to 1.10.x return an error when clearing an already absent
file (inverted error check, since fixed), which is the state of every
first-time user, so this specific error is treated as the empty state it
reports.
dropbear on Home Assistant OS is gated by ConditionFileNotEmpty on the
authorized_keys file, which systemd only evaluates when the unit starts,
so the service is started after a non-empty key set is written. A running
dropbear re-reads the file on every authentication attempt and needs no
restart.
* Delegate SSH key validation to OS Agent
Review discussion questioned the full key validation (type allowlist,
base64 blob checks, canonicalization): OS Agent 1.10.0 validates
submitted keys itself and treats clearing an already absent
authorized_keys file as success, so the Supervisor can rely on it
instead of duplicating the logic.
Require OS Agent 1.10.0 or newer and reject requests on older releases
with 404, like the Raspberry Pi firmware endpoints do; this also makes
the missing-file compatibility shim for the clear call unnecessary. A
key rejected by OS Agent surfaces as an error response including its
validation message. The Supervisor keeps only a basic per-key sanity
check that runs before anything is written: no control characters (one
submitted key can never write more than one authorized_keys line) and
at most 3000 bytes (dropbear ignores longer lines, which would leave a
key that passes but never works).
* Support OS Agent releases before 1.10.0
Requiring OS Agent 1.10.0 would keep the feature unavailable until the
next OS update reaches users, while Supervisor updates roll out
independently. Drop the version requirement and accept that validation
is lossy on older releases: the Supervisor sanity check still prevents
writing more than one line per key or lines dropbear ignores, but
proper key validation only happens on OS Agent 1.10.0 or newer.
This brings back the need to tolerate the error OS Agent releases
before 1.10.0 return when clearing an already absent authorized_keys
file (inverted error check): match the complete os.Remove error message
for the authorized_keys path and treat it as the empty state clearing
aims for, on affected versions only.
* Split SSH authorized keys API into add and clear endpoints
Review feedback preferred endpoints mapping 1:1 onto the OS Agent D-Bus
methods over a single replace-the-set API, whose POST semantics were
also questioned (an idempotent full replacement would be PUT).
POST /os/ssh/authorized_keys now takes a single key ({"key": "..."})
and appends it via AddSSHAuthKey; DELETE /os/ssh/authorized_keys
removes all keys via ClearSSHAuthKeys. Each call maps to exactly one
OS Agent operation, so no request can partially succeed. Clients that
want to replace the configured set clear and re-add; a GET (which
needs an OS Agent extension first) and an idempotent PUT can be added
later.
The per-key sanity check, the dropbear service start after adding a
key, and the tolerance for the missing-file clear error of OS Agent
releases before 1.10.0 carry over unchanged.
* Add endpoint to list SSH authorized keys
With only add and clear operations the authorized_keys file is
write-only for API consumers: a user cannot audit which keys grant
access to the box, or whether any exist at all — including keys that
were imported from USB or written by add-ons.
OS Agent 1.11.0 added a ListSSHAuthKeys D-Bus method. Expose it as
GET /os/ssh/authorized_keys, returning the configured entries verbatim.
The endpoint requires OS Agent 1.11.0 and returns 404 on older
releases, like the Raspberry Pi firmware endpoints do; add and clear
keep working on all OS Agent releases.
* Restrict SSH authorized keys endpoints to Home Assistant Core
Review decision: manipulating root's SSH access is not a capability
add-ons should have, even with the admin role, so move the endpoints
from admin-only to the core_only middleware pattern. Only requests
authenticated with the Home Assistant Core token pass; add-on tokens of
any role, the CLI plugin, and the observer are rejected. This also
means the host shell (ha CLI) cannot use the endpoints for now — the
restriction can be opened up later.
The exclusion from the manager role allowlist is kept: if the path is
ever removed from core_only again, it falls back to admin-only rather
than becoming manager-accessible.
* Stop dropbear after clearing SSH authorized keys
Clearing all authorized keys is a revocation, but without stopping
dropbear the listener keeps running until reboot and established
sessions survive, as only a service stop terminates them. Stop the
service after a successful clear, mirroring the USB config import
(haos-config), which also stops dropbear when the imported
authorized_keys file is removed. Stopping an inactive unit is a no-op.
* Serialize SSH authorized keys jobs on a common lock
The add and clear jobs each perform a file operation followed by a
service operation, and nothing prevented them from running
concurrently. An interleaving like clear-file, add-key, start-dropbear,
stop-dropbear lets both requests succeed while the final service state
does not match the final key state (key configured, dropbear stopped).
Make OSManager a JobGroup and run both jobs with GROUP_QUEUE
concurrency, so each file-and-service operation completes before the
next starts. The regression test fails without the shared lock.
334 lines
12 KiB
Python
334 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()),
|
|
("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
|
|
|
|
# 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"
|