Механізм 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: