Безпека Telegram Mini Apps: як підробити initDataUnsafe і захистити PHP/Yii2 API від спуфінгу

Розробники Telegram Mini Apps часто припускаються фатальної помилки: вони довіряють даним, які надходять із JS-інтерфейсу window.Telegram.WebApp.initDataUnsafe. Назва цього об'єкта буквально кричить про його небезпеку (Unsafe), проте спокуса швидко отримати user.id і авторизувати користувача занадто велика.

У цій статті ми розберемо, як зловмисник може легко підробити ідентифікатор користувача на клієнті, надамо робочий приклад спуфінг-навантаження та напишемо надійний бекенд-валідатор на PHP (Yii2), який припинить будь-які спроби обходу авторизації. Також мы налаштуємо фронтенд так, щоб головна кнопка додатка (MainButton) активувалася лише після успішної перевірки сесії сервером.

Ілюзія безпеки: чому initDataUnsafe не можна вірити

Об'єкт initDataUnsafe формується всередині Telegram-клієнта і доступний у контексті WebView. Будь-який користувач, який запустив ваш Mini App через десктопну версію Telegram або веб-клієнт, може відкрити DevTools (інструменти розробника) і вручну змінити будь-яку властивість цього об'єкта перед відправкою запиту на ваш API.

Більше того, зловмиснику навіть не потрібен Telegram: він може розгорнути ваш Mini App у звичайному браузері, зімітувати об'єкт window.Telegram.WebApp і надсилати будь-які згенеровані дані на ваш сервер.

Приклад підробленого корисного навантаження (Spoof Payload)

Нижче наведено типовий приклад того, як зловмисник може підробити дані авторизації в консолі браузера або надіслати їх безпосередньо на ваш ендпоінт авторизації:

// Имитация объекта WebApp с подмененным user.id администратора
const spoofedTelegramContext = {
initDataUnsafe: {
query_id: "AAH_fake_query_id",
user: {
id: 99999999, // ID администратора или целевой жертвы
first_name: "Hacker",
last_name: "",
username: "evil_hacker",
language_code: "ru"
},
auth_date: Math.floor(Date.now() / 1000),
hash: "fake_hash_that_will_fail_proper_validation"
}
};

// Если ваш API принимает только user.id из тела запроса:
fetch('https://your-api.com/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: spoofedTelegramContext.initDataUnsafe.user.id
})
});

Якщо ваш бекенд не перевіряє підпис даних, а просто вірить переданому userId, зловмисник миттєво отримує доступ к чужому акаунту, балансу або адмін-панелі Mini App.

Єдиний правильний шлях: валідація сирого рядка initData

Для безпечної авторизації необхідно передавати на бекенд сирий рядок window.Telegram.WebApp.initData. Він є набором query-параметрів (включаючи hash), розділених символом амперсанда. Завдання сервера — перевірити, що цей хеш дійсно був згенерований Telegram на основі вашого секретного токена бота (bot_token).

Пишемо валідатор на Yii2 (PHP)

Створимо контролер у Yii2, який приймає сирий рядок initData, перевіряє його справжність, захищає від replay-атак за допомогою перевірки часу життя сесії (auth_date) і використовує безпечне за часом порівняння рядків (timing-safe comparison) для запобігання атакам за часом.

namespace app\controllers;

use Yii;
use yii\web\Controller;
use yii\web\Response;
use yii\web\BadRequestHttpException;
use yii\web\UnauthorizedHttpException;

class AuthController extends Controller
{
// Отключаем CSRF для API-запросов авторизации
public $enableCsrfValidation = false;

/**
* Авторизация пользователя по initData
*/
public function actionLogin()
{
Yii::$app->response->format = Response::FORMAT_JSON;
$request = Yii::$app->request;

// Получаем initData из заголовка Authorization или POST-параметров
$authHeader = $request->getHeaders()->get('Authorization');
$initData = '';

if ($authHeader && preg_match('/^Bearer\s+(.*?)$/i', $authHeader, $matches)) {
$initData = $matches[1];
} else {
$initData = $request->post('initData', '');
}

if (empty($initData)) {
throw new BadRequestHttpException('Отсутствуют данные авторизации (initData)');
}

try {
$userData = $this->validateInitData($initData);

// Здесь вы можете найти пользователя в БД по $userData[\'id\'] или создать нового
// И выдать JWT-токен или установить сессию

return [
'success' => true,
'user' => $userData,
];
} catch (\Exception $e) {
Yii::warning('Попытка несанкционированного доступа: ' . $e->getMessage(), 'telegram_auth');
throw new UnauthorizedHttpException($e->getMessage());
}
}

/**
* Валидация строки initData по алгоритму Telegram
*/
private function validateInitData(string $initData): array
{
$botToken = getenv('TELEGRAM_BOT_TOKEN');
if (!$botToken) {
throw new \Exception('Токен бота не сконфигурирован на сервере');
}

// 1. Парсим query-строку
parse_str($initData, $params);

if (!isset($params['hash']) || !isset($params['auth_date'])) {
throw new \Exception('Неверный формат initData: отсутствует hash или auth_date');
}

$tgHash = $params['hash'];
unset($params['hash']); // Хэш не участвует в формировании подписи

// 2. Проверяем актуальность данных (защита от replay-атак)
// Разрешаем сессии не старше 24 часов (86400 секунд)
$authDate = (int)$params['auth_date'];
if (time() - $authDate > 86400) {
throw new \Exception('Срок действия сессии истек (auth_date expired)');
}

// 3. Сортируем параметры по алфавиту (ksort)
ksort($params);

// 4. Формируем строку проверки данных (data_check_string)
$dataCheckArr = [];
foreach ($params as $key => $value) {
$dataCheckArr[] = "{$key}={$value}";
}
$dataCheckString = implode("\n", $dataCheckArr);

// 5. Генерируем секретный ключ на основе токена бота
// Используем константную строку "WebAppData" в качестве соли
$secretKey = hash_hmac('sha256', $botToken, 'WebAppData', true);

// 6. Вычисляем проверочный хэш
$calculatedHash = hash_hmac('sha256', $dataCheckString, $secretKey);

// 7. Безопасное сравнение хэшей (защита от атак по времени)
if (!hash_equals($calculatedHash, $tgHash)) {
throw new \Exception('Ошибка валидации подписи. Данные подделаны!');
}

// Декодируем объект пользователя
if (!isset($params['user'])) {
throw new \Exception('Данные пользователя отсутствуют в initData');
}

$user = json_decode($params['user'], true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('Ошибка декодирования JSON пользователя');
}

return $user;
}
}

Інтеграція на клієнті: блокування MainButton до авторизації

Щоб захистити бізнес-логіку додатка, ми повинні заблокувати інтерфейс Mini App доти, доки бекенд не підтвердить успішну авторизацію. Чудовий патерн — тримати головну кнопку MainButton прихованою або неактивною і показувати її лише після отримання HTTP-статусу 200 від нашого Yii2 API.

Нижче наведено приклад реалізації на React з використанням стандартного SDK:

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

const MiniApp = () => {
const [authorized, setAuthorized] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
const tg = window.Telegram?.WebApp;
if (!tg) {
setError('Запустите приложение внутри Telegram');
setLoading(false);
return;
}

// Инициализация: скрываем главную кнопку
tg.MainButton.hide();

const rawInitData = tg.initData;
if (!rawInitData) {
setError('Не удалось получить данные авторизации Telegram');
setLoading(false);
return;
}

// Отправляем сырые данные на наш Yii2 бэкенд
fetch('/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ initData: rawInitData })
})
.then(async (response) => {
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Ошибка авторизации на сервере');
}
return response.json();
})
.then((data) => {
if (data.success) {
setAuthorized(true);

// Активируем и показываем MainButton только после успешного ответа API
tg.MainButton.setText('Оформить заказ');
tg.MainButton.show();
tg.MainButton.onClick(() => {
tg.showAlert('Заказ оформлен!');
});
}
})
.catch((err) => {
setError(err.message);
tg.showAlert(`Критическая ошибка: ${err.message}`);
})
.finally(() => {
setLoading(false);
});
}, []);

if (loading) return <div>Загрузка и проверка сессии...</div>;
if (error) return <div style={{ color: 'red' }}>Ошибка: {error}</div>;

return (
<div style={{ padding: '15px' }}>
<h1>Добро пожаловать в магазин!</h1>
<p>Сессия успешно подтверждена сервером. Кнопка внизу экрана активна.</p>
</div>
);
};

export default MiniApp;

Важливі нюанси безпеки

  • Термін життя auth_date: Завжди обмежуйте час життя сесії. Якщо зловмисник перехопить валідний рядок initData, він зможе використовувати його нескінченно, якщо ви не перевіряєте різницю в часі між сервером і параметром auth_date. Оптимальний ліміт — 24 години.
  • Використання hash_equals: Звичайне порівняння рядків через == або === вразливе до атак за часом (Timing Attacks). Функція hash_equals() у PHP гарантує, що порівняння завжди займає однакову кількість часу, незалежно від того, в якому символі сталася невідповідність.
  • Секретний ключ: Ніколи не хардкодьте токен бота в кодовій базі. Використовуйте переменные окружения (getenv) або захищені параметри конфігурації Yii2 (Yii::$app->params).

Якщо вам потрібна професійна розробка складних Telegram-бот ів та Mini Apps, команда BotCreator допоможе реалізувати проєкт будь-якої складності.

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

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