diff --git a/app/config.py b/app/config.py index 4bbc08e..edbb19e 100644 --- a/app/config.py +++ b/app/config.py @@ -37,8 +37,9 @@ allow_ips = "127.0.0.1, ::1" # Пусто = сервис работает напрямую и заголовкам не доверяет. # При работе через start-https.bat (Caddy на этой же машине) ставьте 127.0.0.1 trust_proxy = "" -# Открывать ли /docs и /openapi.json. Они не требуют токена и показывают -# устройство API всем, кто знает адрес, поэтому по умолчанию выключены. +# Открывать ли /docs и /openapi.json. Они показывают устройство API, поэтому +# по умолчанию выключены и отвечают 404. Включённые - требуют адрес из списка +# и токен: в браузере его передают ссылкой вида /docs?token=ЗНАЧЕНИЕ. docs = false [processing] @@ -71,6 +72,12 @@ url = "" # чтобы принимающая сторона убедилась, что запрос от вас. secret = "" +[https] +# Домены для start-https.bat через запятую. Caddy получит на них сертификаты +# Let's Encrypt сам. Требуются открытые снаружи порты 80 и 443 и A-записи, +# указывающие на эту машину. Caddyfile создаётся из этих настроек. +domains = "" + [update] # Проверять обновления кода при каждом запуске. Обновляется только папка app, # это десятки килобайт: Python, библиотеки и модели остаются на месте. diff --git a/app/main.py b/app/main.py index bfd589f..39ee7ad 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,7 @@ import threading import time from contextlib import asynccontextmanager from pathlib import Path +from urllib.parse import quote from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile from fastapi.openapi.docs import get_swagger_ui_html @@ -21,7 +22,8 @@ from app.console import setup_logging from app.logbuffer import get as log_buffer from app.logbuffer import install as install_log_buffer from app.pipeline import ModelsMissing, Pipeline, to_wav16k -from app.security import check_token, client_address, ip_allowed, parse_allowlist +from app.security import (check_token, client_address, ip_allowed, + parse_allowlist, token_matches) from app.store import JobStatus, JobStore from app.webhook import deliver_async from app.version import __version__ @@ -181,8 +183,8 @@ async def lifespan(app: FastAPI): pool.shutdown(wait=False, cancel_futures=True) -# Штатные /docs и /openapi.json отключены: они не требуют токена. Вместо них -# ниже свои маршруты, закрытые тем же списком адресов, что и остальной сервис. +# Штатные /docs и /openapi.json отключены: они никого не проверяют. Вместо них +# ниже свои маршруты - выключенные по умолчанию и закрытые адресом и токеном. app = FastAPI( title="talkscore-asr", version=__version__, @@ -222,16 +224,40 @@ def guard(request: Request) -> None: raise HTTPException(status_code=401, detail="неверный или отсутствующий токен") +def docs_guard(request: Request) -> str: + """Пускает к документации и возвращает токен для ссылки на схему. + + Токен принимается и заголовком, и параметром ?token=. Браузер, открывая + страницу по ссылке, заголовок не подставит, а документация без браузера + теряет смысл. Раньше здесь проверялся только адрес - с выключенным + списком адресов это означало открытый доступ. + """ + if not settings.docs: + # 404, а не 403: подтверждать существование страницы незачем. + raise HTTPException(status_code=404, detail="Not Found") + ip_guard(request) + supplied = request.query_params.get("token") + if check_token(request.headers.get("authorization"), settings.token): + return supplied or "" + if token_matches(supplied, settings.token): + return supplied or "" + raise HTTPException(status_code=401, detail="неверный или отсутствующий токен") + + @app.get("/docs", include_in_schema=False) def docs_page(request: Request): - """Описание методов для браузера. Открывается только с разрешённых адресов.""" - ip_guard(request) - return get_swagger_ui_html(openapi_url="openapi.json", title="talkscore-asr") + """Описание методов для браузера. Требует адрес из списка и токен.""" + token = docs_guard(request) + # Схему Swagger запрашивает сам, уже без заголовка - протаскиваем токен + # в адрес, иначе страница откроется и тут же покажет ошибку доступа. + suffix = f"?token={quote(token)}" if token else "" + return get_swagger_ui_html(openapi_url=f"openapi.json{suffix}", + title="talkscore-asr") @app.get("/openapi.json", include_in_schema=False) def openapi_schema(request: Request) -> JSONResponse: - ip_guard(request) + docs_guard(request) schema = get_openapi(title=app.title, version=app.version, description=app.description, routes=app.routes) schema["components"] = schema.get("components", {}) diff --git a/app/security.py b/app/security.py index 2af77a8..906a1eb 100644 --- a/app/security.py +++ b/app/security.py @@ -3,7 +3,8 @@ import ipaddress import secrets from ipaddress import IPv4Network, IPv6Network -__all__ = ["check_token", "parse_allowlist", "ip_allowed", "client_address"] +__all__ = ["check_token", "token_matches", "parse_allowlist", "ip_allowed", + "client_address"] Network = IPv4Network | IPv6Network @@ -15,12 +16,26 @@ def check_token(header_value: str | None, expected: str) -> bool: иначе забытая настройка молча выставила бы сервис наружу. Сравнение идёт в постоянное время, чтобы токен нельзя было подобрать по таймингам. """ - if not expected or not header_value: + if not header_value: return False scheme, _, value = header_value.partition(" ") if scheme.lower() != "bearer": return False - return secrets.compare_digest(value.strip(), expected) + return token_matches(value, expected) + + +def token_matches(value: str | None, expected: str) -> bool: + """Сверяет голое значение токена, без схемы Bearer. + + Нужно для ссылок вида ?token=: браузер, открывая страницу по ссылке, + заголовок Authorization не подставит. + """ + if not expected or not value: + return False + # Сравниваем байты, а не строки: compare_digest на строках с не-ASCII + # бросает TypeError, и токен с кириллицей давал бы 500 вместо 401. + return secrets.compare_digest(value.strip().encode("utf-8"), + expected.encode("utf-8")) def parse_allowlist(raw: str) -> list[Network]: diff --git a/tests/test_api.py b/tests/test_api.py index 9f38352..61cfb3c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -163,16 +163,26 @@ class TestConfigFileIsNotCode: class TestDocsPage: """Страница с методами полезна, но открывать её всем подряд незачем.""" - def test_docs_available_when_ip_allowed(self, client): - # в тестовом конфиге список адресов пуст = ограничение выключено + @pytest.fixture(autouse=True) + def docs_on(self, client, monkeypatch): + """По умолчанию документация выключена, здесь проверяется включённая. + + Зависимость от client обязательна: та фикстура переимпортирует + app.main, и патч без неё лёг бы на выброшенный модуль. + """ + monkeypatch.setattr(sys.modules["app.main"].settings, "docs", True) + + def test_docs_available_with_token(self, client): assert client.get("/docs").status_code == 200 - def test_openapi_available_when_ip_allowed(self, client): + def test_openapi_available_with_token(self, client): assert client.get("/openapi.json").status_code == 200 - def test_docs_need_no_token(self, client): + def test_docs_need_token(self, client): + """Раньше страница открывалась без токена - с выключенным списком + адресов это означало открытый доступ из интернета.""" client.headers.pop("Authorization") - assert client.get("/docs").status_code == 200 + assert client.get("/docs").status_code == 401 def test_schema_declares_bearer_auth(self, client): schema = client.get("/openapi.json").json() @@ -208,12 +218,18 @@ def restricted_client(tmp_path, monkeypatch): class TestDocsAccessControl: - def test_docs_closed_for_foreign_ip(self, restricted_client): + def test_docs_closed_for_foreign_ip(self, restricted_client, monkeypatch): + monkeypatch.setattr(sys.modules["app.main"].settings, "docs", True) assert restricted_client.get("/docs").status_code == 403 - def test_openapi_closed_for_foreign_ip(self, restricted_client): + def test_openapi_closed_for_foreign_ip(self, restricted_client, monkeypatch): + monkeypatch.setattr(sys.modules["app.main"].settings, "docs", True) assert restricted_client.get("/openapi.json").status_code == 403 + def test_docs_hidden_when_disabled(self, restricted_client): + """Выключенная документация отвечает одинаково всем: её как бы нет.""" + assert restricted_client.get("/docs").status_code == 404 + def test_health_stays_open_for_foreign_ip(self, restricted_client): """Мониторинг должен работать всегда.""" assert restricted_client.get("/health").status_code == 200 @@ -283,3 +299,47 @@ class TestBrokenConfig: config = tmp_path / "config.toml" config.write_text('[security]\ntoken="a"\ntrust_proxy="127.0.0.1"\n', encoding="utf-8") assert load_settings(config).token == "a" + + + +class TestDocsAccess: + """Документация показывает устройство API, поэтому закрыта так же, как маршруты.""" + + @staticmethod + def module(): + return sys.modules["app.main"] + + @staticmethod + def anonymous(client, path): + """Фикстура подставляет верный токен всем запросам - здесь он мешает.""" + return client.get(path, headers={"Authorization": ""}) + + def test_disabled_docs_answer_404(self, client): + # Именно 404, а не 403: незачем подтверждать, что страница есть. + for path in ("/docs", "/openapi.json"): + assert client.get(path).status_code == 404 + + def test_enabled_docs_need_token(self, client, monkeypatch): + monkeypatch.setattr(self.module().settings, "docs", True) + for path in ("/docs", "/openapi.json"): + assert self.anonymous(client, path).status_code == 401 + assert self.anonymous(client, f"{path}?token=chuzhoy").status_code == 401 + + def test_token_in_query_opens_docs(self, client, monkeypatch): + """Браузер по ссылке заголовок не подставит, поэтому нужен ?token=.""" + monkeypatch.setattr(self.module().settings, "docs", True) + for path in ("/docs", "/openapi.json"): + assert self.anonymous(client, f"{path}?token={TOKEN}").status_code == 200 + + def test_token_in_header_opens_docs(self, client, monkeypatch): + monkeypatch.setattr(self.module().settings, "docs", True) + assert client.get("/docs").status_code == 200 + + def test_schema_link_carries_token(self, client, monkeypatch): + """Иначе страница откроется и тут же покажет ошибку доступа к схеме.""" + monkeypatch.setattr(self.module().settings, "docs", True) + monkeypatch.setattr(self.module().settings, "token", "token s probelom i-slashem") + page = self.anonymous(client, "/docs?token=token s probelom i-slashem").text + assert "openapi.json?token=token%20s%20probelom%20i-slashem" in page + # Незакодированный пробел разорвал бы адрес, и схема не загрузилась бы. + assert "openapi.json?token=token s" not in page diff --git a/tests/test_security.py b/tests/test_security.py index 681da75..5861e47 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,7 +1,7 @@ """Тесты доступа: токен и список разрешённых адресов.""" import pytest -from app.security import check_token, ip_allowed, parse_allowlist +from app.security import token_matches, check_token, ip_allowed, parse_allowlist class TestToken: @@ -62,3 +62,17 @@ class TestAllowlist: def test_unknown_client_ip_denied_when_list_set(self): nets = parse_allowlist("10.0.0.1") assert ip_allowed(None, nets) is False + + +class TestNonAsciiToken: + """compare_digest на строках с не-ASCII бросает TypeError: был бы 500 вместо 401.""" + + def test_cyrillic_token_matches_itself(self): + assert token_matches("секрет-ключ", "секрет-ключ") is True + + def test_cyrillic_token_rejects_other(self): + assert token_matches("другой", "секрет-ключ") is False + + def test_cyrillic_token_via_header(self): + assert check_token("Bearer секрет-ключ", "секрет-ключ") is True + assert check_token("Bearer чужой", "секрет-ключ") is False