Deep linking t.me/Bot?start=payload: валидация, защита HMAC и маппинг в БД

Механизм 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` на стороне вебхука важно соблюдать три правила безопасности:

  1. Проверка Secret Token: валидация заголовка X-Telegram-Bot-Api-Secret-Token для защиты контроллера от посторонних HTTP-запросов.
  2. Идемпотентность по update_id: Telegram может повторно присылать один и тот же update при сбоях сети. Нужно атомарно фиксировать update_id в БД.
  3. Безопасное сравнение строк: проверка 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:

Новые статьи — в Telegram

Разбираем, что автоматизировать в бизнесе и как это работает на практике. Без спама.