fix(security): токен сравнивался обычным !=, вопреки контракту

Раздел 3 контракта требует secrets.compare_digest, но в коде стояло
x_hub_token != required_token, а compare_digest не встречался в router/
вообще. Обычное сравнение строк выходит на первом несовпавшем символе и даёт
утечку по времени. Проверка стала уместной сейчас, когда хаб собираются
открыть по сети.

Сравнение идёт в БАЙТАХ, а не в строках: compare_digest со строками
запрещает не-ASCII и падает TypeError — токен с кириллицей давал бы 500
вместо честного отказа. Выяснено исполнением, а не чтением документации.

Проверено на живом приложении при web_api_host=0.0.0.0: без токена 401,
неверный 401, отличающийся одним символом 401, верный 200. Страница отдаётся
без токена (иначе его негде было бы ввести), в /api/settings токен не
попадает, в снапшоте нет access_token/refresh_token/client_secret.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-25 10:21:40 +07:00
parent e8aa60b982
commit 11aa914fbb
2 changed files with 27 additions and 1 deletions

View file

@ -1,4 +1,5 @@
import json
import secrets
import os
import sys
import threading
@ -57,7 +58,16 @@ def get_auth_token(x_hub_token: str = Header(None)) -> bool:
required_token = settings.get('web_api_token', '')
if not required_token:
raise HTTPException(status_code=500, detail="Server misconfigured: external bind requires a token")
if x_hub_token != required_token:
# Сравнение постоянного времени — требование раздела 3 контракта,
# которое до сих пор не было выполнено: стояло обычное !=, дающее
# утечку по времени посимвольного сравнения. Заголовок может быть
# None, поэтому приводим к строке до сравнения.
# Сравниваем в байтах: compare_digest со строками запрещает не-ASCII
# и падает TypeError, то есть токен с кириллицей давал бы 500 вместо
# честного отказа.
_given = str(x_hub_token or '').encode('utf-8')
_needed = str(required_token).encode('utf-8')
if not secrets.compare_digest(_given, _needed):
raise HTTPException(status_code=401, detail="Invalid X-Hub-Token")
return True

View file

@ -5,6 +5,7 @@ Tests adherence to docs/web-api/CONTRACT.md and A16 requirements.
from __future__ import annotations
import inspect
import json
from pathlib import Path
import pytest
@ -165,3 +166,18 @@ def test_client_distinguishes_loading_from_missing_data():
assert "!unavailableReason" in app_js or "! unavailableReason" in app_js, (
"состояние загрузки не подавляется при известной причине отказа"
)
def test_token_gate_is_constant_time_and_rejects_near_miss():
"""Токен сравнивается в постоянном времени и не течёт в /api/settings.
Раздел 3 контракта требует secrets.compare_digest, но в коде стояло
обычное !=. Проверка появилась перед тем, как хаб выставили в сеть.
Сравнение идёт в БАЙТАХ: compare_digest со строками запрещает не-ASCII и
падает TypeError, то есть нестандартный токен давал бы 500 вместо отказа.
"""
import antigravity_provider.router.web.server as web_server
src = inspect.getsource(web_server.get_auth_token)
assert "compare_digest" in src, "сравнение токена снова не постоянного времени"
assert ".encode(" in src, "сравнение не в байтах: не-ASCII токен даст 500 вместо отказа"