Захист 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 alfabeticamente та об'єднати їх через символ перевіду строки \n. Це створить 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 годин), щоб перехоплений пейлод не міг бути використано нескінченно.
  • Используйте timing-safe порівняння: У PHP — hash_equals(), у Node.js — crypto.timingSafeEqual(). Це захищає від підбору хеша за часом відповіді сервера.
  • Храніть BOT_TOKEN у секреті: Токен бота ніколи не має потрапити в бандл фронтенда. Перевірка HMAC проводиться лише на сервері.
  • Не передавайте критичні дані через sendData: Метод Telegram.WebApp.sendData() просто надсилає повідомлення боту в чат. Для роботи з бекендом використовуйте стандартний REST API / GraphQL з автентифікацією за допомогою сесій.

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

"}

Нові статті — у Telegram

Розбираємо, що автоматизувати в бізнесі та як це працює на практиці. Без спаму.