estimate_pdf (trade_in.py:347): TTL-проверка через .replace(tzinfo=UTC) перепривязывает, а не конвертирует timestamptz — при не-UTC session TZ просроченные оценки отдаются ка… #1564

Closed
opened 2026-06-16 17:05:34 +00:00 by bot-backend · 0 comments
Collaborator

Severity: medium · Категория: logic/timezone · Файл: backend/app/api/v1/trade_in.py:347

Файл: backend/app/api/v1/trade_in.py:347. В хендлере GET /estimate/{id}/pdf проверка TTL делается на Python-стороне: if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):. Колонка trade_in_estimates.expires_at — timestamptz (data/sql/115_trade_in_estimates.sql:31), psycopg v3 возвращает её как tz-aware datetime с tzinfo = offset session GUC TimeZone. В app/core/db.py engine создаётся без connect_args={'options': '-c TimeZone=UTC'}, а settings.database_url не задаёт ?options → session TZ берётся из серверного дефолта и может быть не-UTC (Beget VPS / ЕКБ-деплой). .replace(tzinfo=UTC) сохраняет wall-clock и лишь меняет ярлык, поэтому при session TZ=UTC+5 значение 15:00+05:00 (=10:00 UTC) превращается в 15:00+00:00 (=15:00 UTC) — сдвиг expiry на +5ч в будущее, и просроченная оценка проходит гейт (отдаёт PDF вместо 410) в течение offset-часов. Импакт: клиенту могут выдать PDF по фактически истёкшей (TTL 24ч) оценке; противоречит честному поведению соседнего GET /estimate/{id} (строка 292), который сравнивает серверно expires_at > NOW(). Условие проявления: session TimeZone != UTC (в чистом Docker-postgres дефолт UTC, тогда баг латентен). Фикс: либо row.expires_at.astimezone(UTC) < datetime.now(tz=UTC) (корректная конвертация, т.к. value уже aware), либо — предпочтительно — вынести проверку в SQL WHERE id = ... AND expires_at > NOW() как в GET и вернуть 410 при отсутствии строки после отдельного 404-запроса; либо принудительно задать session TZ=UTC на коннекте в db.py.

Почему баг: Параллельный хендлер GET /estimate/{id} (строки 291-292) делает корректную SQL-сравнение expires_at > NOW() (tz-safe, серверная сторона). PDF-хендлер — единственный, кто сравнивает на Python через .replace(); это даёт другой результат, чем GET. При не-UTC session TZ просроченная оценка (TTL 24ч) пройдёт проверку и отдаст PDF клиенту вместо 410 в течение TZ-offset часов (для +5 — до 5 часов после реального истечения). Правильно было бы row.expires_at.astimezone(UTC) или вынести проверку в SQL как в GET.

Как чинить: Заменить row.expires_at.replace(tzinfo=UTC) на row.expires_at.astimezone(UTC), либо добавить в SQL WHERE AND expires_at > NOW() и вернуть 410/404 по факту наличия строки (как сделано в GET /estimate/{id}). Альтернативно — закрепить session TZ=UTC на уровне коннекта.


deep-audit backend v2 (после PR #1543) · verify+harden, confidence 0.7 · unit B04

**Severity:** medium · **Категория:** logic/timezone · **Файл:** `backend/app/api/v1/trade_in.py:347` Файл: backend/app/api/v1/trade_in.py:347. В хендлере GET /estimate/{id}/pdf проверка TTL делается на Python-стороне: `if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):`. Колонка trade_in_estimates.expires_at — timestamptz (data/sql/115_trade_in_estimates.sql:31), psycopg v3 возвращает её как tz-aware datetime с tzinfo = offset session GUC TimeZone. В app/core/db.py engine создаётся без `connect_args={'options': '-c TimeZone=UTC'}`, а settings.database_url не задаёт `?options` → session TZ берётся из серверного дефолта и может быть не-UTC (Beget VPS / ЕКБ-деплой). `.replace(tzinfo=UTC)` сохраняет wall-clock и лишь меняет ярлык, поэтому при session TZ=UTC+5 значение `15:00+05:00` (=10:00 UTC) превращается в `15:00+00:00` (=15:00 UTC) — сдвиг expiry на +5ч в будущее, и просроченная оценка проходит гейт (отдаёт PDF вместо 410) в течение offset-часов. Импакт: клиенту могут выдать PDF по фактически истёкшей (TTL 24ч) оценке; противоречит честному поведению соседнего GET /estimate/{id} (строка 292), который сравнивает серверно `expires_at > NOW()`. Условие проявления: session TimeZone != UTC (в чистом Docker-postgres дефолт UTC, тогда баг латентен). Фикс: либо `row.expires_at.astimezone(UTC) < datetime.now(tz=UTC)` (корректная конвертация, т.к. value уже aware), либо — предпочтительно — вынести проверку в SQL `WHERE id = ... AND expires_at > NOW()` как в GET и вернуть 410 при отсутствии строки после отдельного 404-запроса; либо принудительно задать session TZ=UTC на коннекте в db.py. **Почему баг:** Параллельный хендлер GET /estimate/{id} (строки 291-292) делает корректную SQL-сравнение `expires_at > NOW()` (tz-safe, серверная сторона). PDF-хендлер — единственный, кто сравнивает на Python через .replace(); это даёт другой результат, чем GET. При не-UTC session TZ просроченная оценка (TTL 24ч) пройдёт проверку и отдаст PDF клиенту вместо 410 в течение TZ-offset часов (для +5 — до 5 часов после реального истечения). Правильно было бы `row.expires_at.astimezone(UTC)` или вынести проверку в SQL как в GET. **Как чинить:** Заменить `row.expires_at.replace(tzinfo=UTC)` на `row.expires_at.astimezone(UTC)`, либо добавить в SQL WHERE `AND expires_at > NOW()` и вернуть 410/404 по факту наличия строки (как сделано в GET /estimate/{id}). Альтернативно — закрепить session TZ=UTC на уровне коннекта. --- <sub>deep-audit backend v2 (после PR #1543) · verify+harden, confidence 0.7 · unit B04</sub>
bot-backend added the
week ревью 1
label 2026-06-16 17:05:34 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#1564
No description provided.