mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-16 05:36:29 +00:00
Backport core api filter (#4165)
This commit is contained in:
parent
692d34a13c
commit
3d74e07c5e
@ -14,3 +14,4 @@ pytest==7.2.1
|
|||||||
pyupgrade==3.3.1
|
pyupgrade==3.3.1
|
||||||
time-machine==2.9.0
|
time-machine==2.9.0
|
||||||
typing_extensions==4.3.0
|
typing_extensions==4.3.0
|
||||||
|
urllib3==1.26.14
|
@ -53,6 +53,7 @@ class RestAPI(CoreSysAttributes):
|
|||||||
self.webapp: web.Application = web.Application(
|
self.webapp: web.Application = web.Application(
|
||||||
client_max_size=MAX_CLIENT_SIZE,
|
client_max_size=MAX_CLIENT_SIZE,
|
||||||
middlewares=[
|
middlewares=[
|
||||||
|
self.security.block_bad_requests,
|
||||||
self.security.system_validation,
|
self.security.system_validation,
|
||||||
self.security.token_validation,
|
self.security.token_validation,
|
||||||
],
|
],
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
"""Handle security part of this API."""
|
"""Handle security part of this API."""
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from typing import Final
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from aiohttp.web import Request, RequestHandler, Response, middleware
|
from aiohttp.web import Request, RequestHandler, Response, middleware
|
||||||
from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized
|
from aiohttp.web_exceptions import HTTPBadRequest, HTTPForbidden, HTTPUnauthorized
|
||||||
|
|
||||||
from ...const import (
|
from ...const import (
|
||||||
REQUEST_FROM,
|
REQUEST_FROM,
|
||||||
@ -22,7 +24,7 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
|||||||
# fmt: off
|
# fmt: off
|
||||||
|
|
||||||
# Block Anytime
|
# Block Anytime
|
||||||
BLACKLIST = re.compile(
|
BLACKLIST: Final = re.compile(
|
||||||
r"^(?:"
|
r"^(?:"
|
||||||
r"|/homeassistant/api/hassio/.*"
|
r"|/homeassistant/api/hassio/.*"
|
||||||
r"|/core/api/hassio/.*"
|
r"|/core/api/hassio/.*"
|
||||||
@ -30,7 +32,7 @@ BLACKLIST = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Free to call or have own security concepts
|
# Free to call or have own security concepts
|
||||||
NO_SECURITY_CHECK = re.compile(
|
NO_SECURITY_CHECK: Final = re.compile(
|
||||||
r"^(?:"
|
r"^(?:"
|
||||||
r"|/homeassistant/api/.*"
|
r"|/homeassistant/api/.*"
|
||||||
r"|/homeassistant/websocket"
|
r"|/homeassistant/websocket"
|
||||||
@ -41,14 +43,14 @@ NO_SECURITY_CHECK = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Observer allow API calls
|
# Observer allow API calls
|
||||||
OBSERVER_CHECK = re.compile(
|
OBSERVER_CHECK: Final = re.compile(
|
||||||
r"^(?:"
|
r"^(?:"
|
||||||
r"|/.+/info"
|
r"|/.+/info"
|
||||||
r")$"
|
r")$"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Can called by every add-on
|
# Can called by every add-on
|
||||||
ADDONS_API_BYPASS = re.compile(
|
ADDONS_API_BYPASS: Final = re.compile(
|
||||||
r"^(?:"
|
r"^(?:"
|
||||||
r"|/addons/self/(?!security|update)[^/]+"
|
r"|/addons/self/(?!security|update)[^/]+"
|
||||||
r"|/addons/self/options/config"
|
r"|/addons/self/options/config"
|
||||||
@ -60,7 +62,7 @@ ADDONS_API_BYPASS = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Policy role add-on API access
|
# Policy role add-on API access
|
||||||
ADDONS_ROLE_ACCESS = {
|
ADDONS_ROLE_ACCESS: dict[str, re.Pattern] = {
|
||||||
ROLE_DEFAULT: re.compile(
|
ROLE_DEFAULT: re.compile(
|
||||||
r"^(?:"
|
r"^(?:"
|
||||||
r"|/.+/info"
|
r"|/.+/info"
|
||||||
@ -111,6 +113,26 @@ ADDONS_ROLE_ACCESS = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FILTERS: Final = re.compile(
|
||||||
|
r"(?:"
|
||||||
|
|
||||||
|
# Common exploits
|
||||||
|
r"proc/self/environ"
|
||||||
|
r"|(<|%3C).*script.*(>|%3E)"
|
||||||
|
|
||||||
|
# File Injections
|
||||||
|
r"|(\.\.//?)+" # ../../anywhere
|
||||||
|
r"|[a-zA-Z0-9_]=/([a-z0-9_.]//?)+" # .html?v=/.//test
|
||||||
|
|
||||||
|
# SQL Injections
|
||||||
|
r"|union.*select.*\("
|
||||||
|
r"|union.*all.*select.*"
|
||||||
|
r"|concat.*\("
|
||||||
|
|
||||||
|
r")",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
||||||
|
|
||||||
@ -121,6 +143,32 @@ class SecurityMiddleware(CoreSysAttributes):
|
|||||||
"""Initialize security middleware."""
|
"""Initialize security middleware."""
|
||||||
self.coresys: CoreSys = coresys
|
self.coresys: CoreSys = coresys
|
||||||
|
|
||||||
|
def _recursive_unquote(self, value: str) -> str:
|
||||||
|
"""Handle values that are encoded multiple times."""
|
||||||
|
if (unquoted := unquote(value)) != value:
|
||||||
|
unquoted = self._recursive_unquote(unquoted)
|
||||||
|
return unquoted
|
||||||
|
|
||||||
|
@middleware
|
||||||
|
async def block_bad_requests(
|
||||||
|
self, request: Request, handler: RequestHandler
|
||||||
|
) -> Response:
|
||||||
|
"""Process request and tblock commonly known exploit attempts."""
|
||||||
|
if FILTERS.search(self._recursive_unquote(request.path)):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Filtered a potential harmful request to: %s", request.raw_path
|
||||||
|
)
|
||||||
|
raise HTTPBadRequest
|
||||||
|
|
||||||
|
if FILTERS.search(self._recursive_unquote(request.query_string)):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Filtered a request with a potential harmful query string: %s",
|
||||||
|
request.raw_path,
|
||||||
|
)
|
||||||
|
raise HTTPBadRequest
|
||||||
|
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
@middleware
|
@middleware
|
||||||
async def system_validation(
|
async def system_validation(
|
||||||
self, request: Request, handler: RequestHandler
|
self, request: Request, handler: RequestHandler
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
"""Test API security layer."""
|
"""Test API security layer."""
|
||||||
|
from http import HTTPStatus
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import pytest
|
import pytest
|
||||||
|
import urllib3
|
||||||
|
|
||||||
from supervisor.api import RestAPI
|
from supervisor.api import RestAPI
|
||||||
from supervisor.const import CoreState
|
from supervisor.const import CoreState
|
||||||
@ -11,6 +13,11 @@ from supervisor.coresys import CoreSys
|
|||||||
# pylint: disable=redefined-outer-name
|
# pylint: disable=redefined-outer-name
|
||||||
|
|
||||||
|
|
||||||
|
async def mock_handler(request):
|
||||||
|
"""Return OK."""
|
||||||
|
return web.Response(text="OK")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def api_system(aiohttp_client, run_dir, coresys: CoreSys):
|
async def api_system(aiohttp_client, run_dir, coresys: CoreSys):
|
||||||
"""Fixture for RestAPI client."""
|
"""Fixture for RestAPI client."""
|
||||||
@ -20,7 +27,10 @@ async def api_system(aiohttp_client, run_dir, coresys: CoreSys):
|
|||||||
os.environ = {"SUPERVISOR_NAME": "hassio_supervisor"}
|
os.environ = {"SUPERVISOR_NAME": "hassio_supervisor"}
|
||||||
await api.load()
|
await api.load()
|
||||||
|
|
||||||
|
api.webapp.middlewares.append(api.security.block_bad_requests)
|
||||||
api.webapp.middlewares.append(api.security.system_validation)
|
api.webapp.middlewares.append(api.security.system_validation)
|
||||||
|
api.webapp.router.add_get("/{all:.*}", mock_handler)
|
||||||
|
|
||||||
yield await aiohttp_client(api.webapp)
|
yield await aiohttp_client(api.webapp)
|
||||||
|
|
||||||
|
|
||||||
@ -62,3 +72,66 @@ async def test_api_security_system_startup(api_system, coresys: CoreSys):
|
|||||||
|
|
||||||
resp = await api_system.get("/supervisor/ping")
|
resp = await api_system.get("/supervisor/ping")
|
||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@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,
|
||||||
|
request_params,
|
||||||
|
fail_on_query_string,
|
||||||
|
api_system,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
loop,
|
||||||
|
) -> 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 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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user