Документация выключена по умолчанию и закрыта токеном

Маршруты /docs и /openapi.json проверяли только адрес. С выключенным
списком адресов это означало открытый доступ к описанию API из интернета.
Теперь они отвечают 404, пока docs не включён явно, а включённые требуют
адрес из списка и токен - заголовком либо ссылкой /docs?token=ЗНАЧЕНИЕ.

Попутно исправлено сравнение токена: compare_digest на строках с не-ASCII
бросает TypeError, и токен с кириллицей давал 500 вместо 401.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vladimir Bryzgalov
2026-08-16 18:07:30 +05:00
co-authored by Claude Opus 5
parent 2039d95a86
commit e7deb47196
5 changed files with 142 additions and 20 deletions
+33 -7
View File
@@ -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", {})