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:
parent
e8aa60b982
commit
11aa914fbb
2 changed files with 27 additions and 1 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 вместо отказа"
|
||||
|
|
|
|||
Loading…
Reference in a new issue