Improve /auth API request sanitation (#5843)

* Add basic test coverage for /auth API

* Check /auth API is called from an add-on

Currently the /auth API is only available for add-ons. Return 403
for calls not originating from an add-on.

* Handle bad json in auth API

Use the API specific JSON load helper which raises an APIError. This
causes the API to return a 400 error instead of a 500 error when the
JSON is invalid.

* Avoid redefining name 'mock_check_login'

* Update tests/api/test_auth.py
This commit is contained in:
Stefan Agner
2025-04-25 15:17:25 +02:00
committed by GitHub
parent 0e55e6e67b
commit 9470f44840
3 changed files with 134 additions and 3 deletions

View File

@@ -6,8 +6,11 @@ from unittest.mock import AsyncMock, patch
from aiohttp.test_utils import TestClient
import pytest
from supervisor.addons.addon import Addon
from supervisor.coresys import CoreSys
from tests.const import TEST_ADDON_SLUG
LIST_USERS_RESPONSE = [
{
"id": "a1d90e114a3b4da4a487fe327918dcef",
@@ -67,6 +70,13 @@ LIST_USERS_RESPONSE = [
]
@pytest.fixture(name="mock_check_login")
def fixture_mock_check_login(coresys: CoreSys):
"""Patch sys_auth.check_login."""
with patch.object(coresys.auth, "check_login", new_callable=AsyncMock) as mock:
yield mock
async def test_password_reset(
api_client: TestClient, coresys: CoreSys, caplog: pytest.LogCaptureFixture
):
@@ -106,3 +116,124 @@ async def test_list_users(
"group_ids": ["system-admin"],
},
]
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
async def test_auth_json_success(
api_client: TestClient, mock_check_login: AsyncMock, install_addon_ssh: Addon
):
"""Test successful JSON auth."""
mock_check_login.return_value = True
resp = await api_client.post("/auth", json={"username": "test", "password": "pass"})
assert resp.status == 200
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
async def test_auth_json_invalid_credentials(
api_client: TestClient, mock_check_login: AsyncMock, install_addon_ssh: Addon
):
"""Test failed JSON auth due to invalid credentials."""
mock_check_login.return_value = False
resp = await api_client.post(
"/auth", json={"username": "test", "password": "wrong"}
)
# Do we really want the API to return 400 here?
assert resp.status == 400
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
async def test_auth_json_empty_body(api_client: TestClient, install_addon_ssh: Addon):
"""Test JSON auth with empty body."""
resp = await api_client.post(
"/auth", data="", headers={"Content-Type": "application/json"}
)
assert resp.status == 400
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
async def test_auth_json_invalid_json(api_client: TestClient, install_addon_ssh: Addon):
"""Test JSON auth with malformed JSON."""
resp = await api_client.post(
"/auth", data="{not json}", headers={"Content-Type": "application/json"}
)
assert resp.status == 400
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
async def test_auth_urlencoded_success(
api_client: TestClient, mock_check_login: AsyncMock, install_addon_ssh: Addon
):
"""Test successful URL-encoded auth."""
mock_check_login.return_value = True
resp = await api_client.post(
"/auth",
data="username=test&password=pass",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert resp.status == 200
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
async def test_auth_urlencoded_failure(
api_client: TestClient, mock_check_login: AsyncMock, install_addon_ssh: Addon
):
"""Test URL-encoded auth with invalid credentials."""
mock_check_login.return_value = False
resp = await api_client.post(
"/auth",
data="username=test&password=fail",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
# Do we really want the API to return 400 here?
assert resp.status == 400
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
async def test_auth_unsupported_content_type(
api_client: TestClient, install_addon_ssh: Addon
):
"""Test auth with unsupported content type."""
resp = await api_client.post(
"/auth", data="something", headers={"Content-Type": "text/plain"}
)
# This probably should be 400 here for better consistency
assert resp.status == 401
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
async def test_auth_basic_auth(
api_client: TestClient, mock_check_login: AsyncMock, install_addon_ssh: Addon
):
"""Test auth with BasicAuth header."""
mock_check_login.return_value = True
resp = await api_client.post(
"/auth", headers={"Authorization": "Basic dGVzdDpwYXNz"}
)
assert resp.status == 200
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
async def test_auth_basic_auth_failure(
api_client: TestClient, mock_check_login: AsyncMock, install_addon_ssh: Addon
):
"""Test auth with BasicAuth header and failure."""
mock_check_login.return_value = False
resp = await api_client.post(
"/auth", headers={"Authorization": "Basic dGVzdDpwYXNz"}
)
assert resp.status == 401
@pytest.mark.parametrize("api_client", ["local_example"], indirect=True)
async def test_auth_addon_no_auth_access(
api_client: TestClient, install_addon_example: Addon
):
"""Test auth where add-on is not allowed to access auth API."""
resp = await api_client.post("/auth", json={"username": "test", "password": "pass"})
assert resp.status == 403
async def test_non_addon_token_no_auth_access(api_client: TestClient):
"""Test auth where add-on is not allowed to access auth API."""
resp = await api_client.post("/auth", json={"username": "test", "password": "pass"})
assert resp.status == 403