Базовые принципы безопасности для Telegram-ботов: обработка команд, прав доступа и защита от повторов
Разберём типовые угрозы и ошибки: валидация входящих событий, анти-дубликаты, ограничение действий по ролям, защита от спуфинга и безопасные интеграции с внешними API. Дадим практический чек-лист для продакшена.
Содержание
Базовые принципы безопасности для Telegram-ботов: обработка команд, прав доступа и защита от повторов
Безопасность Telegram-бота — это не про «добавить кнопку и везде поставить токены». Это про архитектуру: как вы принимаете входящие события, как принимаете решения, кто и когда может вызвать действие, и что происходит при ошибках, повторах и сбоях интеграций.
В этой статье разберём самые частые угрозы и ошибки в продакшене: неправильная валидация входящих сообщений, отсутствие анти-дубликатов, отсутствие разграничения прав, уязвимости к спуфингу и опасные паттерны интеграции с внешними API. В конце — практический чек-лист, который можно использовать перед релизом.
Модель угроз: что именно нужно защищать
Прежде чем писать «какие-то фильтры», стоит зафиксировать, что для вас является активом:
- Данные: профили пользователей, платежные статусы, доступы к ресурсам, внутренние идентификаторы.
- Команды: действия бота (выдать доступ, изменить настройки, удалить данные, отменить подписку).
- Инфраструктура: сервер, базы, очереди задач, ключи API.
- Интеграции: внешние сервисы, которым бот делает запросы, и обратные webhooks/ответы.
Типовые сценарии угроз для Telegram-ботов:
- Спуфинг или подмена событий
Нападающий пытается отправить «фальшивое событие» вашему backend (например, если вы принимаете HTTP-запросы откуда-то ещё), или подменить параметры внутри команды. - Повторы
Telegram может доставлять update повторно (например, при сетевых сбоях, ошибках обработки, при повторных запросах на webhook). Если обработчик не идемпотентен, повтор может привести к двойному списанию/двойной выдаче. - Нарушение прав
Команды выполняются без проверки роли или с ошибочной логикой доступа. Иногда разработчики проверяют только, чтоuserId«не пустой», забывая про владельцев, админов, контракты состояний. - Эскалация через параметры команды
Например, команда/grant @usernameвыдаёт доступ, но не проверяет, имеет ли вызывающий соответствующие полномочия и действительно ли найденный пользователь должен быть выдан. - Утечки через внешние интеграции
Бот отправляет токены/секреты наружу, принимает непроверенные поля из внешнего API, не ограничивает таймауты и ретраи, что приводит к зависанию или каскадным ошибкам.
Хорошая новость: большинство проблем закрываются базовыми принципами — валидацией, авторизацией, идемпотентностью и аккуратными интеграциями.
Валидация входящих событий: как не принять «не то»
Чем опасна «слепая» обработка update
Частая ошибка: обработчик Telegram update предполагает, что поля присутствуют и корректны, и сразу выполняет бизнес-логику. На практике:
- В обновлениях могут быть разные типы payload (сообщение, callback_query, edited_message, канал/группа).
- Поля могут отсутствовать (например,
message.textне всегда есть; у callback_querydataвсегда строка, но может содержать неожиданный формат). - Длина текста может быть большой, а злоумышленник — подобрать «краевые» значения, чтобы вызвать неожиданное поведение в парсере.
Минимальный набор проверок
-
Проверяйте тип update и подтип
Отрабатывайте только те ветки, которые вам нужны. Всё остальное — логируйте и игнорируйте. -
Валидируйте формат команд
Для текстовых команд/command arg1 arg2:- нормализуйте пробелы,
- ограничивайте длину входа,
- валидируйте типы аргументов (числа, идентификаторы, URL, enum).
-
Проверяйте источник контекста
Если вы ожидаете команды в личке, отклоняйте группы и наоборот. Если вы ожидаете callback только из ваших inline-кнопок — проверяйтеcallback_query.message.chat.idи/или внутреннийstate.
Пример: безопасный парсинг команды в Node.js
function parseCommand(text) {
if (typeof text !== 'string') return null;
if (text.length > 200) return null; // ограничение на разумный максимум
const trimmed = text.trim();
if (!trimmed.startsWith('/')) return null;
// Пример: /grant 12345
const [rawCmd, ...rest] = trimmed.split(/\s+/);
const cmd = rawCmd.toLowerCase();
const args = rest.join(' ').trim();
return { cmd, args };
}
function parseGrantArgs(args) {
// ожидаем один аргумент: userId (число)
if (!args) return null;
if (args.length > 50) return null;
const userId = Number(args);
if (!Number.isInteger(userId) || userId <= 0) return null;
return { userId };
}
Ключевой момент: бизнес-логика вызывается только после того, как аргументы прошли валидацию. Если что-то не так — не «попробуем», а «откажем».
Защита от повторов: идемпотентность update-обработки
Почему повторы — реальная проблема
Telegram может повторять delivery. Кроме того, ваш обработчик может упасть после выполнения части логики и до фиксации «успешной обработки». Если вы не разделяете «получил» и «обработал» транзакционно, вы получаете двойные эффекты.
Два последствия особенно неприятны:
- Повторные списания/выдачи
Если бот изменяет состояние внешнего сервиса или базы без идемпотентности. - Гонка задач
Два параллельных воркера обрабатывают один и тот же update.
Практический подход: идемпотентные ключи
В большинстве фреймворков вы можете использовать update_id как базовый ключ. Но часто надёжнее строить idempotency key на уровне бизнес-команды, особенно если у вас callback/inline-buttons с одинаковыми данными.
Минимальная схема:
- Для каждого update/команды вычисляете idempotency key.
- Перед выполнением операции проверяете, был ли ключ уже обработан.
- Если ключ обработан — возвращаете «успех» без повторного действия.
- Делаете фиксацию ключа и бизнес-операции в одной транзакции (или хотя бы гарантируете порядок).
Пример: анти-дубликаты на PostgreSQL
Идея: таблица processed_updates с уникальным ограничением по ключу.
CREATE TABLE processed_updates (
idempotency_key TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
В коде (концептуально):
async function handleUpdate(db, update, actionFn) {
const key = `tg:update:${update.update_id}`;
try {
await db.query(
'INSERT INTO processed_updates(idempotency_key) VALUES($1)',
[key]
);
} catch (e) {
// если вставка уникального ключа провалилась — значит уже обработано
if (e.code === '23505') {
return; // идемпотентный повтор, игнорируем
}
throw e;
}
// ключ зафиксирован — теперь выполняем действие
await actionFn();
}
Подводные камни:
- Слишком грубо: если вы используете только
update_id, а ваши действия зависят от callback data, вы можете получить разные бизнес-операции в пределах одного update (редко, но бывает в сложных сценариях). - Нет фиксации при частичных провалах: если действие может падать после вставки ключа, вы получите «обработано», но фактически нет. Решение — транзакции/статусы:
processed_updatesможет хранитьstatus(done,failed) и повтор разрешается по стратегии.
Callback-команды требуют особого внимания
Если вы используете inline-кнопки, callback_query.data часто содержит параметры. Тогда ваша идемпотентность должна учитывать:
callback_query.id(если у вас есть доступ к нему),from.idиchat.id(илиmessage.chat.id),- содержимое
dataпосле валидации формата.
Прав доступа: проверяйте не «кто написал», а «кто имеет право на действие»
Разграничение ролей — это часть модели безопасности
Бот почти всегда выполняет действия разного уровня:
- обычные пользователи: запрос статуса, создание заявки, чтение информации,
- администраторы: выдача прав, управление интеграциями,
- владельцы/сервисные аккаунты: критические операции.
Ошибка №1: проверка ролей «в одном месте», а потом запуск бизнес-функций из других обработчиков без повторной проверки.
Правило: авторизация должна происходить перед выполнением конкретного действия, и должна быть явной.
Уточните контекст авторизации
В Telegram контекст — это:
from.id(кто нажал/написал),chat.idи тип чата (private, group, channel),- возможно, membership/админ права в группе (если вы проверяете через API).
Если ваш бот выдаёт доступ на внутреннем уровне (не групповые роли), то храните маппинг ролей в своей БД:
users(id, role, status, ...)admin_actions_allowed(role, action)или простая таблица правил.
Пример: авторизация по действию (action-based RBAC)
const ACTIONS = {
VIEW_STATUS: 'view_status',
GRANT_ACCESS: 'grant_access',
REVOKE_ACCESS: 'revoke_access',
};
function can(role, action) {
const matrix = {
user: [ACTIONS.VIEW_STATUS],
admin: [ACTIONS.VIEW_STATUS, ACTIONS.GRANT_ACCESS, ACTIONS.REVOKE_ACCESS],
owner: Object.values(ACTIONS),
};
return (matrix[role] || []).includes(action);
}
// Пример: вызываемую бизнес-функцию нельзя дернуть без can()
async function handleGrant({ db, telegramUserId, chatId, payload }) {
const user = await db.users.findByTelegramId(telegramUserId);
const role = user?.role ?? 'user';
if (!can(role, ACTIONS.GRANT_ACCESS)) {
return { ok: false, error: 'forbidden' };
}
// дальше — валидация payload, потом действие
// ...
return { ok: true };
}
Нюанс: не смешивайте авторизацию и валидацию. Сначала валидируете, что команда/параметры корректны, затем проверяете право на действие, и только потом выполняете.
Защита от спуфинга и подмены: где реально нужно напрячься
Что Telegram «делает сам», а что — нет
Telegram надежно доставляет update внутри своей системы, и классический спуфинг «подменить update_id» извне для Telegram API обычно невозможен без прямого доступа к инфраструктуре. Но спуфинг становится реальной угрозой на вашей стороне:
- если вы принимаете webhook и не проверяете секрет/подпись (в зависимости от механизма),
- если ваш backend доверяет полям, которые мог послать любой клиент,
- если вы используете callback data без проверки контекста — злоумышленник может попытаться сконструировать data вручную.
Базовые принципы защиты
-
Проверяйте подписи/секреты webhook (если используется)
При webhook-архитектуре убедитесь, что вы используете корректную проверку секретов. Если вы используете конкретный механизм подписи — следуйте официальной схеме, а не «на глаз». -
Не доверяйте callback data без server-side проверки
callback_query.data приходит от пользователя (хотя подписать Telegram так не позволяет, но злоумышленник может сформировать запросы к вашему backend, если у него есть к ним доступ). Поэтому:- валидируйте формат data,
- сверяйте, что callback относится к ожидаемому состоянию (например, в таблице
sessionsхранится state token, который вы сгенерировали и привязали к пользователю), - проверяйте что пользователь — тот же, кто создал исходное сообщение/кнопку (или хотя бы кто инициирует действие).
-
Старайтесь минимизировать доверие к user-supplied IDs
Например, команда/grant 12345может указывать целевого пользователя. Если вам нужно выдавать доступ «только тому, кого выбрали в прошлом шаге», используйте state и храните соответствие в БД.
Пример: безопасное state для многошаговых действий
Идея: при отправке inline-кнопок вы включаете в callback data короткий токен, который на сервере однозначно маппится на контекст.
- callback data:
grant:{token} - сервер:
token -> {actorId, targetId, action, expiresAt}
Пример схемы:
function parseCallbackData(data) {
if (typeof data !== 'string') return null;
if (!data.startsWith('grant:')) return null;
const token = data.slice('grant:'.length);
if (token.length < 20 || token.length > 200) return null;
return { token };
}
А дальше вы не доверяете targetId из callback (его там вообще может не быть), а берёте из БД по токену и проверяете, что actorId === from.id.
Безопасные интеграции с внешними API: где обычно ломается продакшен
Три главных проблемы интеграций
-
Таймауты и ретраи без ограничений
Если внешнее API «тормозит», ваш бот начинает держать воркеры занятыми и накапливает очередь. Затем внешнее API может «ожить», и вы устроите шторм ретраев. -
Отсутствие идемпотентности на уровне внешних вызовов
Если вы ретраите запрос на выдачу доступа, внешний сервис может выполнить действие дважды, если у него нет idempotency keys. -
Небезопасная обработка входных данных с внешних сервисов
Пример: внешний API возвращает поле, которое вы используете как ID, а там внезапно строка вместо числа — и это превращается в инъекцию в запросы или в логическую ошибку.
Что делать: стандартный контракт интеграции
- Всегда задавайте таймаут на запрос (connect и read).
- Ограничивайте ретраи (экспоненциальная задержка, максимум попыток, и ретраить только ошибки, которые имеют смысл).
- Нормализуйте и валидируйте ответ внешнего API.
- Идемпотентность: по возможности используйте idempotency keys и храните соответствие у себя.
Пример: fetch с таймаутом и контролем ретраев (Node.js)
async function withTimeout(promise, ms) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), ms);
try {
return await promise(controller);
} finally {
clearTimeout(id);
}
}
async function callExternal(apiUrl, { idempotencyKey }) {
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await withTimeout(
(controller) =>
fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({}),
signal: controller.signal,
}),
5000
);
} catch (e) {
const isLast = attempt === maxAttempts;
// В реальности стоит различать типы ошибок:
// таймауты/сетевые ошибки ретраим, 4xx — обычно нет.
if (isLast) throw e;
const backoffMs = 200 * Math.pow(2, attempt - 1);
await new Promise((r) => setTimeout(r, backoffMs));
}
}
}
Это не «магия», но это уже контур безопасности от каскадных зависаний.
Логи, мониторинг и секреты: безопасность после того, как код работает
Логи: аккуратно с персональными данными
Telegram обновления содержат много полезного, но логирование «сырых update целиком» часто приводит к:
- утечкам идентификаторов пользователей,
- хранению токенов (если вы случайно логируете заголовки),
- проблемам с соответствием требованиям (GDPR/локальные политики).
Практика:
- логируйте минимум: update_id, тип события, результат обработки,
- обрезайте длинные поля,
- исключайте секреты и токены.
Секреты: не в коде и не в логах
- Токен бота храните в переменных окружения/секрет-хранилищах.
- API ключи внешних сервисов — аналогично.
- Никогда не логируйте заголовки запросов, содержащие Authorization / API key.
Метрики: видеть проблему до пользователя
Что отслеживать:
- количество обновлений по типам,
- доля отклонённых команд (валидация/авторизация),
- доля повторов (анти-дубликаты),
- среднее/95p время обработки,
- ошибки интеграций внешних API,
- очереди/джобы (если используете).
Чек-лист безопасности для продакшена
Ниже — список пунктов,
Комментарии
Пока нет комментариев