fix(backend): Excel formula-injection (#1244) + chat read off event loop (#1245) #1307

Merged
bot-backend merged 1 commit from fix/excel-injection-chat-threadpool-1244-1245 into main 2026-06-13 13:19:44 +00:00
4 changed files with 137 additions and 7 deletions

View file

@ -63,7 +63,10 @@ async def ask(
оркестратор сам отдаёт детерминированный ответ (llm_used=False + fallback_reason).
"""
try:
report, run_id = get_report_for_chat(db, payload.cad_num)
# Sync SQLAlchemy чтение (тянет крупный JSONB §22-отчёт) — мостим через
# run_in_threadpool, чтобы НЕ блокировать event loop (тот же приём, что и
# синхронный LLM `complete` в _answer_via_llm ниже).
report, run_id = await run_in_threadpool(get_report_for_chat, db, payload.cad_num)
except Exception:
# Read-only сбой БД — не валим клиента 500-кой, отдаём pending (как
# get_parcel_forecast). Клиент может повторить.

View file

@ -53,6 +53,11 @@ _ADVISORY_MARKER: str = (
)
# Округление чисел по умолчанию (скоры/индексы ∈ [0,1] и доли — 3 знака читаемо).
_ROUND_DIGITS: int = 3
# OWASP CSV/formula-injection: лидирующие символы, с которых Excel/LibreOffice трактуют
# строку как ФОРМУЛУ/команду при открытии. Внешние/скрейпинг-данные (comm_name из DOM.РФ,
# headline/usp_text и пр.) с таким префиксом нейтрализуем (см. `_fmt`).
# https://owasp.org/www-community/attacks/CSV_Injection
_FORMULA_INJECTION_PREFIXES: tuple[str, ...] = ("=", "+", "-", "@", "\t", "\r")
# Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария
# (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт
# список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит
@ -103,12 +108,27 @@ def _normalize(report: Any) -> dict[str, Any]:
return {}
def _sanitize_formula(text: str) -> str:
"""Нейтрализовать formula/DDE-injection: префикс `'` строкам-«формулам». PURE.
openpyxl сохраняет строку с лидирующим `= + - @ \\t \\r` как ФОРМУЛУ (data_type='f'),
и Excel/LibreOffice исполнит её при открытии. Внешние/скрейпинг-данные (comm_name из
DOM.РФ, headline/usp_text/advisory и пр.) попадают в ячейки через `_fmt`, поэтому
экранируем по OWASP-набору: ведём строку апострофом Excel показывает литеральный
текст и трактует ячейку как текст, формула не исполняется. Безопасные строки как есть.
"""
if text.startswith(_FORMULA_INJECTION_PREFIXES):
return "'" + text
return text
def _fmt(value: Any) -> Any:
"""Привести значение к ячейке Excel: None → "", float → округление. PURE.
Числа округляем до `_ROUND_DIGITS` (но bool оставляем как есть это не число для
округления). Строки/прочие скаляры как есть. Контейнеры (dict/list) сворачиваем
в компактную строку (на случай неожиданно-вложенного значения не падаем).
округления). Строки экранируем от formula/DDE-injection (`_sanitize_formula`)
внешние/скрейпинг-данные с лидирующим `= + - @` не должны стать формулой. Контейнеры
(dict/list) сворачиваем в строку и тоже экранируем (на случай вложенного значения).
"""
if value is None:
return _DASH
@ -116,10 +136,12 @@ def _fmt(value: Any) -> Any:
return "да" if value else "нет"
if isinstance(value, float):
return round(value, _ROUND_DIGITS)
if isinstance(value, (str, int)):
if isinstance(value, str):
return _sanitize_formula(value)
if isinstance(value, int):
return value
if isinstance(value, (dict, list)):
return str(value)
return _sanitize_formula(str(value))
return value
@ -200,8 +222,12 @@ def _write_title(ws: Worksheet, row: int, text: str) -> int:
def _write_kv(ws: Worksheet, row: int, label: str, value: Any) -> int:
"""Строка «метка → значение» (label жирный, value через `_fmt`). PURE-side."""
label_cell = ws.cell(row=row, column=1, value=label)
"""Строка «метка → значение» (label жирный, value через `_fmt`). PURE-side.
Метка тоже экранируется от formula-injection: часть меток это КЛЮЧИ из секций
отчёта (`key_numbers`/`market_metrics`/`factors`), потенциально внешнего происхождения.
"""
label_cell = ws.cell(row=row, column=1, value=_sanitize_formula(label))
label_cell.font = Font(bold=True)
label_cell.alignment = _LEFT_TOP
value_cell = ws.cell(row=row, column=2, value=_fmt(value))

View file

@ -227,6 +227,37 @@ def test_ask_no_run_returns_pending() -> None:
assert "Запустите анализ участка" in body["answer"]
def test_ask_report_read_bridged_through_threadpool(monkeypatch: pytest.MonkeyPatch) -> None:
"""#1245: синхронное чтение §22-отчёта мостится через run_in_threadpool (не в loop).
`get_report_for_chat` sync SQLAlchemy (тянет крупный JSONB) и НЕ должен блокировать
event loop. Оборачиваем `run_in_threadpool` в шпион: проверяем, что чтение отчёта
реально диспатчится через него (как и синхронный LLM `complete` в _answer_via_llm).
"""
from app.services.chat import retrieval as retrieval_mod
dispatched: list[str] = []
real_run_in_threadpool = chat_router.run_in_threadpool
async def _spy(func: Any, *args: Any, **kwargs: Any) -> Any:
dispatched.append(getattr(func, "__name__", repr(func)))
return await real_run_in_threadpool(func, *args, **kwargs)
monkeypatch.setattr(chat_router, "run_in_threadpool", _spy)
client = _client_with_db()
with patch(
"app.services.chat.retrieval.latest_run_for",
return_value=_make_run(_report()),
):
resp = client.post(
"/api/v1/chat/ask",
json={"cad_num": _CAD, "message": "резюме"},
)
assert resp.status_code == 200, resp.text
# Синхронное чтение отчёта прошло через threadpool (имя sync-функции зафиксировано).
assert retrieval_mod.get_report_for_chat.__name__ in dispatched
def test_ask_db_error_returns_pending_not_500() -> None:
"""Сбой БД на read-only → pending (не 500), клиент может повторить."""
client = _client_with_db()

View file

@ -31,6 +31,7 @@ from app.services.exporters.excel import (
_SHEET_SCORING,
_SHEET_SUMMARY,
_fmt,
_sanitize_formula,
_scenario_deficit_index,
export_report_xlsx,
)
@ -403,3 +404,72 @@ class TestGracefulPartialReport:
assert len(payload) > 0
wb = _reload(payload)
assert _SHEET_SUMMARY in wb.sheetnames
# ── #1244: formula/DDE-injection — внешние строки не должны стать формулой Excel ──
# openpyxl сохраняет строку с лидирующим `= + - @ \t \r` как data_type='f' (формула),
# Excel/LibreOffice исполнит её при открытии. Внешние/скрейпинг-поля (comm_name из
# DOM.РФ, headline/usp_text/advisory) попадают в ячейки через `_fmt` → должны быть
# нейтрализованы префиксом `'` (OWASP CSV-injection) и трактоваться как ТЕКСТ.
class TestFormulaInjection:
def test_sanitize_prefixes_dangerous_strings(self) -> None:
# Каждый OWASP-префикс получает ведущий апостроф.
for payload in ("=SUM(A1:A2)", "+1+1", "-2+3", "@cmd", "\tTAB", "\rCR"):
assert _sanitize_formula(payload) == "'" + payload
def test_sanitize_leaves_safe_strings_untouched(self) -> None:
for safe in ("ЖК Альфа", "комфорт", "250 000", "0.34", ""):
assert _sanitize_formula(safe) == safe
def test_fmt_escapes_formula_string(self) -> None:
# Строка-«формула» через _fmt → текст с апострофом (не исполняемая формула).
assert _fmt("=1+1") == "'=1+1"
assert _fmt("@SUM(1)") == "'@SUM(1)"
def test_fmt_does_not_touch_numbers_or_safe_text(self) -> None:
# Числа/безопасный текст не трогаем: отрицательный float — это значение, не строка.
assert _fmt(-0.5) == -0.5
assert _fmt(-3) == -3
assert _fmt("ЖК Бета") == "ЖК Бета"
def test_injected_competitor_name_written_as_text_not_formula(self) -> None:
# comm_name из скрейпинга DOM.РФ с лидирующим `=` → ячейка text, не формула.
report = SiteFinderReport(
market_now=ReportMarketNow(
competitors=[
{"obj_id": 1, "comm_name": "=cmd|'/c calc'!A1", "relevance_weight": 0.8}
],
),
)
ws = _reload(export_report_xlsx(report))[_SHEET_MARKET_NOW]
injected = [
c
for col in ws.iter_cols()
for c in col
if isinstance(c.value, str) and "calc" in c.value
]
assert injected, "имя конкурента должно присутствовать в книге"
for cell in injected:
assert cell.data_type == "s", "ячейка должна быть текстом, не формулой ('f')"
assert cell.value.startswith("'="), "опасный префикс должен быть экранирован"
def test_injected_headline_and_usp_written_as_text(self) -> None:
# headline (Сводка) и usp_text (Продукт ТЗ) — тоже внешние → не формула.
report = SiteFinderReport(
exec_summary=ReportExecSummary(headline="=HYPERLINK('http://evil','x')"),
product_tz=ReportProductTz(
usp=[{"segment": "1-Студия", "usp_text": "+evil()"}],
),
)
payload = export_report_xlsx(report)
wb = _reload(payload)
formula_cells = [
c
for ws in wb.worksheets
for col in ws.iter_cols()
for c in col
if c.data_type == "f"
]
assert not formula_cells, f"ни одна ячейка не должна быть формулой: {formula_cells}"