Защита Telegram Mini App от спуфинга initData: эмуляция атаки и валидация на бэкенде

При разработке Telegram Mini Apps (TWA) начинающие разработчики часто совершают критическую ошибку: доверяют объекту window.Telegram.WebApp.initDataUnsafe. Этот объект формируется на стороне клиента (внутри WebView) и может быть легко изменен злоумышленником через DevTools, прокси-серверы вроде Charles/Mitmproxy или инъекцию JavaScript.

В этой статье мы продемонстрируем, как злоумышленник может подделать ID пользователя для получения чужих прав доступа, и покажем полную цепочку защиты на бэкенде с валидацией HMAC-SHA256, ограничением времени жизни auth_date и timing-safe сравнением хешей. Также мы настроим фронтенд так, чтобы MainButton становился активным только после успешного ответа 200 OK от эндпоинта аутентификации.

Анатомия атаки: почему initDataUnsafe не защищен

Объект initDataUnsafe содержит распарсенную информацию о пользователе, чате и контексте запуска. Однако ключевое слово здесь — Unsafe. Фронтенд Telegram Mini App работает в браузере пользователя, а значит, любая переменная в памяти клиента может быть переопределена.

Если ваш бэкенд принимает запросы вида POST /api/user/balance { userId: 12345 } или верит сырому JSON из клиентской части, атакующий может сменить свой ID на ID администратора или пользователя с большим балансом.

Пример полезной нагрузки для воспроизведения атаки

Ниже представлен клиентский скрипт, демонстрирующий подмену данных. Злоумышленник формирует фальшивую строку параметров, внедряет произвольный user.id и отправляет её на сервер вместо легитимного Telegram.WebApp.initData:

// Воспроизведение атаки: создание фальшивой строки initData с поддельным user.id
const spoofedUser = {
id: 999999999, // ID целевой жертвы или администратора
first_name: "Attacker",
username: "hacker_account",
is_premium: true
};

// Формируем поддельный payload
const fakeParams = new URLSearchParams({
user: JSON.stringify(spoofedUser),
auth_date: Math.floor(Date.now() / 1000).toString(),
query_id: "AAGXXXXXXXXX",
// Поддельный или произвольный хеш, не совпадающий с подписью Telegram
hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
});

const spoofedInitData = fakeParams.toString();

// Отправляем фальшивые данные на бэкенд
fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData: spoofedInitData })
})
.then(res => res.json())
.then(data => console.log('Ответ сервера на фальшивку:', data));

Если сервер проверяет только наличие поля user.id внутри входящего тела запроса, система пропустит атакующую сторону. Единственный способ предотвратить подлог — верифицировать подпись hash, созданную с использованием приватного токена вашего бота.

Алгоритм проверки подлинности на бэкенде

Для проверки сырой строки initData необходимо реализовать следующий алгоритм:

  1. Распарсить строку параметров (Query String) и извлечь параметр hash.
  2. Удалить параметр hash из списка проверяемых ключей.
  3. Отсортировать оставшиеся пары key=value по алфавиту и объединить их через символ перевода строки . Это создаст data_check_string.
  4. Вычислить секретный ключ: HMAC-SHA256("WebAppData", bot_token).
  5. Вычислить итоговый хеш: HMAC-SHA256(data_check_string, secret_key) в формате hex.
  6. Сравнить полученный хеш с хешем из запроса с помощью timing-safe функции.
  7. Проверить возраст auth_date, чтобы исключить атаки повторного воспроизведения (Replay Attacks).

Реализация валидации на Node.js (Express)

В следующем примере реализована безопасная проверка подписи на Node.js с использованием встроенного модуля crypto. Важно использовать crypto.timingSafeEqual для предотвращения атак по времени (Timing Attacks):

import express from 'express';
import crypto from 'crypto';

const app = express();
app.use(express.json());

const BOT_TOKEN = process.env.BOT_TOKEN; // Токен бота из getenv
const MAX_AUTH_AGE_SECONDS = 86400; // 24 часа

function verifyInitData(initDataString) {
if (!initDataString) {
return { valid: false, reason: 'initData отсутствует' };
}

const urlParams = new URLSearchParams(initDataString);
const hash = urlParams.get('hash');

if (!hash) {
return { valid: false, reason: 'Хеш не найден в параметрах' };
}

// Удаляем hash перед генерацией data_check_string
urlParams.delete('hash');

// Сортировка ключей по алфавиту
const params = Array.from(urlParams.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`);

const dataCheckString = params.join('
');

// 1. HMAC-SHA256 с константой "WebAppData" и токеном бота
const secretKey = crypto
.createHmac('sha256', 'WebAppData')
.update(BOT_TOKEN)
.digest();

// 2. HMAC-SHA256 от data_check_string с полученным secretKey
const calculatedHash = crypto
.createHmac('sha256', secretKey)
.update(dataCheckString)
.digest('hex');

// Timing-safe сравнение хешей
const calculatedBuffer = Buffer.from(calculatedHash, 'utf8');
const receivedHashBuffer = Buffer.from(hash, 'utf8');

if (calculatedBuffer.length !== receivedHashBuffer.length ||
!crypto.timingSafeEqual(calculatedBuffer, receivedHashBuffer)) {
return { valid: false, reason: 'Невалидная цифровое подпись (HMAC mismatch)' };
}

// Проверка актуальности auth_date
const authDate = parseInt(urlParams.get('auth_date') || '0', 10);
const now = Math.floor(Date.now() / 1000);

if (now - authDate > MAX_AUTH_AGE_SECONDS) {
return { valid: false, reason: 'Срок действия данных истек' };
}

const userRaw = urlParams.get('user');
const user = userRaw ? JSON.parse(userRaw) : null;

return { valid: true, user };
}

app.post('/api/auth', (req, res) => {
const { initData } = req.body;
const verification = verifyInitData(initData);

if (!verification.valid) {
return res.status(401).json({ success: false, error: verification.reason });
}

// Генерация сессии или JWT на основе подлинного user.id
return res.json({
success: true,
user: {
id: verification.user.id,
username: verification.user.username
}
});
});

Альтернативная реализация валидации на PHP

Если бэкенд вашей системы написан на PHP (Laravel, Yii2 или чистый PHP), проверка выполнятся аналогично с помощью hash_hmac и hash_equals:

<?php

function verifyInitData(string $initData, string $botToken, int $maxAgeSeconds = 86400): array
{
if (empty($initData)) {
return ['valid' => false, 'reason' => 'Пустой initData'];
}

parse_str($initData, $params);

if (!isset($params['hash'])) {
return ['valid' => false, 'reason' => 'Отсутствует hash'];
}

$hash = $params['hash'];
unset($params['hash']);

// Сортировка по алфавиту
ksort($params);

$dataCheckArr = [];
foreach ($params as $key => $val) {
$dataCheckArr[] = $key . '=' . $val;
}
$dataCheckString = implode("
", $dataCheckArr);

// Вычисление secret_key
$secretKey = hash_hmac('sha256', $botToken, 'WebAppData', true);

// Вычисление финального хеша
$calculatedHash = hash_hmac('sha256', $dataCheckString, $secretKey);

// Timing-safe сравнение
if (!hash_equals($calculatedHash, $hash)) {
return ['valid' => false, 'reason' => 'Подпись не совпадает'];
}

// Проверка срока жизни auth_date
$authDate = (int)($params['auth_date'] ?? 0);
if ((time() - $authDate) > $maxAgeSeconds) {
return ['valid' => false, 'reason' => 'Данные устарели'];
}

$user = isset($params['user']) ? json_decode($params['user'], true) : null;

return ['valid' => true, 'user' => $user];
}

// Пример обработки Webhook / HTTP POST запроса
header('Content-Type: application/json');

$input = json_decode(file_get_contents('php://input'), true);
$initData = $input['initData'] ?? '';
$botToken = getenv('BOT_TOKEN');

$result = verifyInitData($initData, $botToken);

if (!$result['valid']) {
http_response_code(401);
echo json_encode(['error' => $result['reason']]);
exit;
}

echo json_encode(['success' => true, 'userId' => $result['user']['id']]);

Интеграция с React и управление MainButton

Безопасный интерфейс Mini App не должен позволять пользователю совершать целевые действия (например, отправлять заказ или подтверждать перевод) до тех пор, пока бэкенд не подтвердит валидность сессии.

Ниже приведён React-компонент, который держит MainButton скрытой или неактивной до получения ответа 200 OK от эндпоинта /api/auth:

import React, { useEffect, useState } from 'react';

export function MiniApp() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [error, setError] = useState(null);

useEffect(() => {
const tg = window.Telegram?.WebApp;
if (!tg) return;

tg.ready();
// Скрываем MainButton до успешной авторизации
tg.MainButton.hide();

const rawInitData = tg.initData;

// Отправляем сырую строку initData на валидацию
fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData: rawInitData })
})
.then(async (res) => {
if (res.status === 200) {
const data = await res.json();
setIsAuthenticated(true);

// Включаем и настраиваем MainButton строго после HTTP 200
tg.MainButton.setText('ПОДТВЕРДИТЬ ЗАКАЗ');
tg.MainButton.onClick(() => {
console.log('Действие под подлинным userId:', data.user.id);
tg.close();
});
tg.MainButton.show();
} else {
const errData = await res.json();
setError(errData.error || 'Ошибка проверки подписи');
tg.showAlert('Ошибка доступа: данные подделываются или устарели.');
}
})
.catch(() => {
setError('Сетевая ошибка при проверке подписи');
});
}, []);

if (error) {
return <div style={{ color: 'red', padding: 20 }}>Доступ отклонен: {error}</div>;
}

if (!isAuthenticated) {
return <div style={{ padding: 20 }}>Аутентификация и проверка подписи...</div>;
}

return (
<div style={{ padding: 20 }}>
<h1>Защищенная область Mini App</h1>
<p>Сессия успешно подтверждена бэкендом.</p>
</div>
);
}

Чек-лист безопасности для разработки Telegram Mini Apps

  • Никогда не используйте initDataUnsafe для логики доступа: Используйте только сырую строку initData и проверяйте подпись на бэкенде.
  • Сверяйте auth_date: Всегда ограничивайте время жизни данных (например, не более 24 часов), чтобы перехваченный payload нельзя было использовать бесконечно.
  • Используйте timing-safe сравнение: В PHP — hash_equals(), в Node.js — crypto.timingSafeEqual(). Это защищает от подбора хеша по времени ответа сервера.
  • Храните BOT_TOKEN в секрете: Токен бота никогда не должен попадать в бандл фронтенда. Проверка HMAC производится только на сервере.
  • Не передавайте критичные данные через sendData: Метод Telegram.WebApp.sendData() просто отправляет сообщение боту в чат. Для работы с бэкендом используйте стандартный REST API / GraphQL с авторизацией по токенам сессий.

Если вам нужна помощь в проектировании безопасной архитектуры Telegram Mini Apps или интеграции приложений с корпоративными системами, специалисты BotCreator помогут реализовать проект под ключ.

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

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