Механизм Deep Linking в Telegram позволяет передавать произвольные данные боту в момент, когда пользователь переходит по специальной ссылке формата https://t.me/UsernameBot?start=payload. В отличие от стандартного нажатия на кнопку «Запустить», передача параметра payload даёт возможность связывать действия пользователя на внешней веб-платформе с его сессией в мессенджере — например, передавать ID рекламной кампании, привязывать аккаунт к заявке в CRM или активировать пригласительный токен.
Однако некорректная обработка этого параметра создаёт критические уязвимости: от IDOR (Insecure Direct Object References) до возможности подмены идентификаторов чужих заказов и повторной атаки путем дублирования запросов. В этой статье мы разберём архитектуру использования Deep Linking, жесткие ограничения Telegram Bot API и надежную схему обработки с защитой на PHP.
Ограничения Telegram Bot API для параметра start
Параметр payload передаётся вебхуком в составе обычного текстового сообщения, содержащего команду /start payload. Серверы Telegram накладывают строгие технические ограничения на формат этого параметра:
- Допустимые символы: исключительно латинские буквы (A-Z, a-z), цифры (0-9), дефис (
-) и символ подчёркивания (_). Спецсимволы, пробелы, знаки равенства и слэши не допускаются. - Длина строки: не более 64 байт. Попытка передать более длинную строку приведет к тому, что Telegram либо обрежет значение, либо ссылка просто не откроет бот с параметром.
- Формат доставки: при переходе по ссылке клиент Telegram автоматически подставляет payload в качестве аргумента к команде
/start. Если пользователь уже вел диалог с ботом, при нажатии на диплинк в чат будет отправлена команда/start payloadв виде обычного входящего сообщения.
Так как лимит в 64 символа не позволяет передать полноценный JSON или составной объект, разработчикам приходится выбирать между двумя стратегиями: сжатием структуры с подписью (HMAC) или использованием непрозрачного случайного токена (Opaque Token) с хранением данных на стороне БД.
Стратегия 1: Защита передаваемого ID с помощью HMAC
Если вам нужно передать открытый ID сущности (например, ID заявки или промокода) напрямую в параметре, никогда не передавайте его в чистом виде (?start=1054). Любой пользователь сможет перебрать числа и получить доступ к чужим данным. Чтобы предотвратить подмену, нужно подписать payload с помощью криптографического хеша HMAC-SHA256, усеченного до безопасной длины.
Пример генерации подписанного Deep Link на PHP:
<?php
declare(strict_types=1);
function generateSignedDeepLink(string $botUsername, string $entityType, int $entityId, string $secretKey): string
{
// Формируем базовую строку: e.g. "lead_1054"
$rawPayload = sprintf('%s_%d', $entityType, $entityId);
// Генерируем краткую HMAC-подпись (10 символов)
$hash = substr(hash_hmac('sha256', $rawPayload, $secretKey), 0, 10);
// Финальный payload: "lead_1054_a8f3b2c1e4"
$payload = sprintf('%s_%s', $rawPayload, $hash);
if (strlen($payload) > 64) {
throw new InvalidArgumentException('Payload exceeds Telegram 64-byte limit.');
}
return sprintf('https://t.me/%s?start=%s', $botUsername, $payload);
}
$botUsername = getenv('TELEGRAM_BOT_USERNAME') ?: 'MyCompanyBot';
$secretKey = getenv('APP_SECRET_KEY') ?: 'hard_to_guess_secret_key_99';
// Генерируем ссылку для привязки лида №1054
$deepLink = generateSignedDeepLink($botUsername, 'lead', 1054, $secretKey);
// Ссылка: https://t.me/MyCompanyBot?start=lead_1054_a8f3b2c1e4
Стратегия 2: Opaque Tokens и маппинг сущностей в БД
Подход с HMAC удобен тем, что не требует предварительной записи токена в БД, но он раскрывает структуру ваших ID. Для максимальной безопасности и при необходимости передать сложный контекст применяется подход Opaque Token: генерация случайного 14-символьного hex-идентификатора и запись его связки с сущностью в базу данных.
Пример генерации одноразовой ссылки с записью лида в БД:
<?php
declare(strict_types=1);
function createLeadDeepLink(PDO $pdo, string $botUsername, int $crmLeadId): string
{
// Генерируем ровно 14 hex-символов (7 байт)
$leadId = bin2hex(random_bytes(7));
$stmt = $pdo->prepare('
INSERT INTO telegram_deep_links (payload_token, entity_type, entity_id, is_used, created_at)
VALUES (:token, "lead", :entity_id, 0, NOW())
');
$stmt->execute([
'token' => $leadId,
'entity_id' => $crmLeadId,
]);
return sprintf('https://t.me/%s?start=%s', $botUsername, $leadId);
}
// Использование:
$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$botUsername = getenv('TELEGRAM_BOT_USERNAME') ?: 'MyCompanyBot';
$secureLink = createLeadDeepLink($pdo, $botUsername, 8492);
// Ссылка: https://t.me/MyCompanyBot?start=4f8a9b1c2d3e5f
Обработка входящего вебхука с проверкой идемпотентности
При обработке `/start payload` на стороне вебхука важно соблюдать три правила безопасности:
- Проверка Secret Token: валидация заголовка
X-Telegram-Bot-Api-Secret-Tokenдля защиты контроллера от посторонних HTTP-запросов. - Идемпотентность по update_id: Telegram может повторно присылать один и тот же update при сбоях сети. Нужно атомарно фиксировать
update_idв БД. - Безопасное сравнение строк: проверка HMAC через
hash_equals()для предотвращения атак по времени (timing attacks).
Ниже представлен полноценный обработчик вебхука на чистом PHP с выполнением всех требований:
<?php
declare(strict_types=1);
// 1. Проверка секретного токена вебхука
$secretToken = getenv('TELEGRAM_SECRET_TOKEN') ?: 'my_super_secret_webhook_token';
$receivedToken = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? '';
if (!hash_equals($secretToken, $receivedToken)) {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized access']);
exit;
}
$rawInput = file_get_contents('php://input');
$update = json_decode($rawInput, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($update['update_id'])) {
http_response_code(400);
echo json_encode(['error' => 'Invalid JSON payload']);
exit;
}
$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
// 2. Гарантия идемпотентности через обработку update_id
$stmt = $pdo->prepare('INSERT INTO telegram_processed_updates (update_id) VALUES (:id) ON CONFLICT (update_id) DO NOTHING');
$stmt->execute(['id' => $update['update_id']]);
if ($stmt->rowCount() === 0) {
// Данный update_id уже был успешно обработан ранее
http_response_code(200);
echo json_encode(['ok' => true, 'status' => 'already_processed']);
exit;
}
// 3. Анализ сообщения
$message = $update['message'] ?? null;
if ($message && isset($message['text'])) {
$chatId = (int)$message['chat']['id'];
$text = trim($message['text']);
if (str_strt_with($text, '/start ')) {
$payload = trim(substr($text, 7));
handleStartPayload($pdo, $chatId, $payload);
}
}
http_response_code(200);
echo json_encode(['ok' => true]);
function str_strt_with(string $haystack, string $needle): bool {
return strncmp($haystack, $needle, strlen($needle)) === 0;
}
function handleStartPayload(PDO $pdo, int $chatId, string $payload): void
{
$secretKey = getenv('APP_SECRET_KEY') ?: 'hard_to_guess_secret_key_99';
$parts = explode('_', $payload);
// Вариант 1: Проверка HMAC payload (lead_1054_a8f3b2c1e4)
if (count($parts) === 3) {
[$type, $idStr, $hash] = $parts;
$expectedHash = substr(hash_hmac('sha256', $type . '_' . $idStr, $secretKey), 0, 10);
if (hash_equals($expectedHash, $hash)) {
$leadId = (int)$idStr;
// Связываем Telegram Chat ID с сущностью лида
$stmt = $pdo->prepare('UPDATE crm_leads SET telegram_chat_id = :chat_id, status = "attached" WHERE id = :lead_id');
$stmt->execute(['chat_id' => $chatId, 'lead_id' => $leadId]);
sendTelegramResponse($chatId, "Ваша заявка №{$leadId} успешно привязана к аккаунту!");
return;
}
}
// Вариант 2: Проверка Opaque Token в БД
$stmt = $pdo->prepare('SELECT entity_type, entity_id, is_used FROM telegram_deep_links WHERE payload_token = :token LIMIT 1');
$stmt->execute(['token' => $payload]);
$linkData = $stmt->fetch(PDO::FETCH_ASSOC);
if ($linkData) {
if ((int)$linkData['is_used'] === 1) {
sendTelegramResponse($chatId, "Эта ссылка уже была использована ранее.");
return;
}
$entityId = (int)$linkData['entity_id'];
// Помечаем токен использованным и привязываем чат
$pdo->beginTransaction();
$updateToken = $pdo->prepare('UPDATE telegram_deep_links SET is_used = 1, used_by_chat_id = :chat_id WHERE payload_token = :token');
$updateToken->execute(['chat_id' => $chatId, 'token' => $payload]);
$updateLead = $pdo->prepare('UPDATE crm_leads SET telegram_chat_id = :chat_id WHERE id = :lead_id');
$updateLead->execute(['chat_id' => $chatId, 'lead_id' => $entityId]);
$pdo->commit();
sendTelegramResponse($chatId, "Спасибо! Данные успешно синхронизированы.");
return;
}
sendTelegramResponse($chatId, "Передан недействительный или устаревший параметр запуска.");
}
function sendTelegramResponse(int $chatId, string $text): void
{
$botToken = getenv('TELEGRAM_BOT_TOKEN');
if (!$botToken) {
return;
}
$url = sprintf('https://api.telegram.org/bot%s/sendMessage', $botToken);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
]),
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 3,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($response === false || $httpCode !== 200) {
$error = curl_error($ch);
curl_close($ch);
error_log("Telegram API Error: HTTP {$httpCode}, CurlError: {$error}");
return;
}
curl_close($ch);
$responseData = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($responseData['ok']) || $responseData['ok'] !== true) {
error_log("Telegram API Error response: