fix(security): любой сайт во вкладке рядом мог управлять хабом

CORS был настроен как allow_origins=["*"] вместе с allow_credentials=True.
FastAPI в таком сочетании не отдаёт звёздочку, а ОТРАЖАЕТ присланный Origin
обратно. Проверено запросом к работающему хабу:

    Origin: https://evil.example.com
    -> HTTP 200
       access-control-allow-origin: https://evil.example.com
       access-control-allow-credentials: true

Опаснее всего это на localhost. get_auth_token требует токен только при
небlocalhost-привязке, то есть на 127.0.0.1 проверки нет вовсе. Значит любая
открытая рядом веб-страница могла прочитать /api/snapshot со всеми
аккаунтами, почтами и квотами и вызвать /api/action — удалить учётные
данные, переписать маршрутизацию, запустить вход OAuth. Ровно так на обеих
машинах владельца хаб и работает.

Собственному интерфейсу CORS не нужен: он отдаётся тем же сервером.
Межсайтовые запросы запрещены по умолчанию, список разрешённых источников
вынесен в настройку web_api_allowed_origins — он понадобится, когда одна
панель будет смотреть на несколько хабов.

Проверено после правки: заголовков access-control в ответе нет, браузер
такой запрос заблокирует; собственный интерфейс работает, снапшот приходит
(24 профиля, 6 ролей, индикатор «Live API»). 451 passed, ruff чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-25 19:57:05 +07:00
parent d6ec34d482
commit c35bc4868d

View file

@ -26,15 +26,48 @@ from antigravity_provider.updater.update_manager import get_installed_commit, Up
logger = logging.getLogger("hermes.router.web")
def _bootstrap_settings() -> Dict[str, Any]:
"""Настройки, нужные до объявления _web_settings (список источников CORS)."""
try:
from antigravity_provider.router.settings_service import get_hub_settings
return get_hub_settings() or {}
except Exception:
return {}
app = FastAPI(title="Hermes Hub Web API", version="1.0.0")
app.add_middleware(
# Межсайтовые запросы запрещены по умолчанию.
#
# Стояло allow_origins=["*"] вместе с allow_credentials=True. FastAPI в таком
# сочетании отражает любой присланный Origin обратно, поэтому проверено
# запросом: страница с https://evil.example.com получала
# access-control-allow-origin: https://evil.example.com
# access-control-allow-credentials: true
# на 200 от /api/snapshot.
#
# Опаснее всего это на localhost: там токен не требуется вовсе (см.
# get_auth_token), а значит ЛЮБОЙ сайт, открытый во вкладке рядом, мог читать
# снапшот со всеми аккаунтами и почтами и вызывать /api/action — удалять
# учётные данные, менять маршрутизацию, запускать входы OAuth.
#
# Собственному интерфейсу CORS не нужен: он отдаётся тем же сервером. Список
# разрешённых источников оставлен настройкой — он понадобится, когда одна
# панель будет смотреть на несколько хабов.
_cors_origins = [
o.strip()
for o in str(_bootstrap_settings().get("web_api_allowed_origins", "")).split(",")
if o.strip()
]
if _cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["X-Hub-Token", "Content-Type"],
)
def _web_settings() -> Dict[str, Any]: