# Безопасность вебхуков и проверка подписи

> Как устроена подпись, почему обязателен raw body, как проверять timestamp, примеры кода для Express, Laravel, Django и чистого Node, идемпотентная обработка и разбор частых ошибок.

## Коротко

Адрес вебхука открыт в интернете, а значит, кто угодно может отправить вам запрос «счёт оплачен». Единственное, что от этого защищает, — **проверка подписи**.

Подпись строится так:

```
signature = "sha256=" + hex( HMAC-SHA256( secret, timestamp + "." + rawBody ) )
```

Здесь `timestamp` — значение заголовка `X-Webhook-Timestamp`, а `rawBody` — тело запроса **в неизменённом байтовом виде**. Результат сравнивается со значением заголовка `X-Webhook-Signature`.

Обязательных проверок три: подпись сходится, `timestamp` не старше 5 минут, обработка идемпотентна.

## Почему обязателен raw body

Это самая частая ошибка. Большинство фреймворков автоматически разбирают тело в JSON, а когда вы собираете строку обратно, байты меняются: пробелы, порядок полей, представление Unicode-символов.

HMAC считается по байтам. Изменился один байт — подпись будет совсем другой.

| Неверно | Верно |
|---|---|
| `JSON.stringify(req.body)` | `req.body` как Buffer |
| `json.dumps(request.json)` | `request.body` или `request.get_data()` |
| `json_encode($data)` | `file_get_contents('php://input')` |

Правило: проверяйте подпись **до разбора тела в JSON**, и только потом парсите.

## Проверка timestamp

`X-Webhook-Timestamp` — время отправки в Unix-секундах. Если оно старше 5 минут, запрос принимать не нужно.

Это защита от повторной отправки: если кто-то перехватит когда-то валидный запрос и отправит его позже, подпись сойдётся, а время — нет.

Убедитесь, что часы вашего сервера идут верно: при расхождении в несколько минут вы начнёте отклонять настоящие запросы.

## Express (Node.js)

Главное — `express.json()` не должен применяться к этому маршруту.

```js
import crypto from 'node:crypto';
import express from 'express';

const app = express();

function verify(req) {
  const ts = req.get('X-Webhook-Timestamp') || '';
  const got = req.get('X-Webhook-Signature') || '';
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const mac = crypto
    .createHmac('sha256', process.env.QUTPAY_WEBHOOK_SECRET)
    .update(ts + '.')
    .update(req.body)          // Buffer, без изменений
    .digest('hex');
  const expected = 'sha256=' + mac;

  const a = Buffer.from(expected);
  const b = Buffer.from(got);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/qutpay-webhook', express.raw({ type: '*/*' }), (req, res) => {
  if (!verify(req)) return res.sendStatus(401);
  res.sendStatus(200);
  const { event, invoice } = JSON.parse(req.body.toString('utf8'));
  handle(event, invoice);
});
```

Для сравнения используйте `timingSafeEqual`, а не обычное равенство: побайтовое сравнение с ранним выходом позволяет подобрать подпись.

## Laravel (PHP)

Laravel тоже разбирает тело, но `$request->getContent()` отдаёт исходные байты. Этот маршрут нужно исключить из CSRF-проверки.

```php
public function handle(Request $request)
{
    $raw = $request->getContent();
    $ts  = $request->header('X-Webhook-Timestamp', '');
    $got = $request->header('X-Webhook-Signature', '');

    if (abs(time() - (int) $ts) > 300) {
        return response('stale', 401);
    }

    $mac = hash_hmac('sha256', $ts . '.' . $raw, config('services.qutpay.webhook_secret'));
    if (!hash_equals('sha256=' . $mac, $got)) {
        return response('bad signature', 401);
    }

    $payload = json_decode($raw, true);
    dispatch(new HandleQutPayEvent($payload));   // работу — в очередь

    return response('', 200);
}
```

В чистом PHP тело берётся через `file_get_contents('php://input')`, заголовки — через `getallheaders()`.

## Django (Python)

```python
import hmac, hashlib, time, json
from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def qutpay_webhook(request):
    raw = request.body                      # bytes, без изменений
    ts = request.headers.get("X-Webhook-Timestamp", "")
    got = request.headers.get("X-Webhook-Signature", "")

    if abs(time.time() - int(ts or 0)) > 300:
        return HttpResponse("stale", status=401)

    mac = hmac.new(
        settings.QUTPAY_WEBHOOK_SECRET.encode(),
        ts.encode() + b"." + raw,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest("sha256=" + mac, got):
        return HttpResponse("bad signature", status=401)

    payload = json.loads(raw)
    handle(payload)
    return HttpResponse(status=200)
```

Во Flask тело берётся через `request.get_data()`, остальное совпадает.

## Node без фреймворка

```js
import http from 'node:http';

http.createServer((req, res) => {
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const raw = Buffer.concat(chunks);     // неизменённые байты
    if (!verifyRaw(raw, req.headers)) {
      res.writeHead(401).end();
      return;
    }
    res.writeHead(200).end();
    handle(JSON.parse(raw.toString('utf8')));
  });
}).listen(3000);
```

## Идемпотентная обработка

Даже при верной подписи **одно событие может прийти несколько раз**: оборвалась сеть, вы не успели ответить 200, мы повторили. Всего повторов до 11.

Поэтому выполняйте обработку **ровно один раз** по паре `(invoice.id, status)`. Самый простой способ — записывать эту пару в свою базу с уникальным ключом:

```sql
CREATE TABLE qutpay_events (
  invoice_id TEXT NOT NULL,
  status     TEXT NOT NULL,
  handled_at TIMESTAMPTZ DEFAULT now(),
  PRIMARY KEY (invoice_id, status)
);
```

```js
const ins = await db.query(
  'INSERT INTO qutpay_events (invoice_id, status) VALUES ($1, $2) ON CONFLICT DO NOTHING',
  [invoice.id, invoice.status],
);
if (ins.rowCount === 0) return;   // уже обработано
await fulfil(invoice);
```

Подробнее: [Идемпотентность](/kb/ru/idempotency).

## Частые ошибки

| Что сделано | Результат | Решение |
|---|---|---|
| Тело разобрали в JSON и собрали строку обратно | Подпись не сойдётся никогда | Используйте raw body |
| `timestamp` не добавлен в подпись | Подпись не сходится | Сохраните порядок `timestamp + "." + rawBody` |
| Префикс `sha256=` не учтён при сравнении | Подпись не сходится | Сравнивайте с префиксом либо уберите его с обеих сторон |
| Секрет скопирован с лишним пробелом в конце | Подпись не сходится | Скопируйте секрет заново |
| Часы сервера сбиты | Настоящие запросы отклоняются как «старые» | Синхронизируйте время |
| Подпись не проверяется вовсе | Кто угодно пришлёт фальшивое «оплачено» | Включите проверку |
| Адрес закрыт авторизацией | Вебхук не приходит вообще | Оставьте адрес открытым и защитите подписью |

Если подпись не сходится: [Подпись вебхука не сходится](/kb/ru/webhook-signature-mismatch).

## Дополнительные меры

- Сделайте адрес неугадываемым: например `/qutpay-webhook-8f3a…`. Подпись это не заменяет, но убирает лишний шум.
- HTTPS обязателен, в боевом режиме другого и не принимается.
- Не держите секрет в коде, храните его в секретных переменных окружения.
- Логируйте запросы с неверной подписью — так видно и атаку, и собственную ошибку.
- Полный список: [Безопасность интеграции: чек-лист](/kb/ru/security-checklist).

## Вопросы и ответы

**Можно не проверять подпись?** Нельзя. На такой адрес кто угодно пришлёт «счёт оплачен» и заберёт товар бесплатно.

**Достаточно ли фильтра по IP?** Нет. IP-адреса меняются, а подпись подтверждает сами данные.

**Когда меняется секрет?** Только когда вы сами его меняете. Новый секрет действует с момента создания, поэтому обновляйте сервер одновременно.

**Почему в теле нет точного времени оплаты?** Kaspi не передаёт время платежа, поэтому мы можем указать только момент, когда узнали о нём сами.

**Как проверить свою реализацию?** Кнопкой проверки в разделе **Интеграции** кабинета, а затем симуляцией оплаты в песочнице.
