Files
bare/docs/decisions/047-push-endpoint.md
mayatnikovandClaude Opus 5 f9ac83bef8 Развести номера ADR: 064/065/066 этой ветки → 073/074/075
Ветка отведена от 522ba89, где последним был ADR-063, и заняла
064–072. Тем временем в main через #3 и #4 пришли свои 064
(строка ввода — div вместо form), 065 (стороны сообщений)
и 066 (серверного перца нет).

Переезжают три ADR этой ветки, 067–072 остаются на месте:
их номера свободны, а внутренние ссылки менять незачем.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QR8uS3zGkybzWRW4GEY1oz
2026-08-24 11:23:12 +03:00

5.0 KiB
Raw Permalink Blame History

ADR-047: Исходящий запрос к push-сервису

Уточняет ADR-011 и ADR-023. Код причины из ответа push-сервиса в журнале — ADR-073.

Контекст

Адрес push-сервиса выбирает браузер получателя: клиент присылает endpoint из PushSubscription, сервер хранит его и на каждое сообщение сам открывает к нему соединение. Это единственное место, где сервер ходит наружу по адресу, который назвал пользователь. Свойство появилось на этапе 4, и в модели угроз его не было.

Проверки «endpoint — абсолютный https-url» для него мало. http.Client по умолчанию идёт за редиректами: один ответ 307 с настоящего https-хоста уводит запрос на plain http и на любой внутренний адрес — вместе с заголовком Authorization: vapid. Адрес может указывать внутрь и сразу: https://127.0.0.1:…, https://169.254.169.254/…, https://10.0.0.1/. Ответ наружу не пересылается, но 404 и 410 снимают подписку, а это видно в GET /api/devices полем hasPush: получается побитовое сканирование внутренней сети двумя своими аккаунтами.

Рядом — две недопроверки формы. Длина endpoint не ограничена ничем, кроме общего предела тела: адрес на 20 КиБ ложился в базу. p256dh проверялся только по длине, хотя 65 случайных байт точкой кривой не являются: отправка на такую подписку падает при каждом сообщении, а устройство остаётся с ней навсегда.

И журнал: адрес подписки уходил в строку отказа. Развернуть *url.Error мало — host и DNS-имя остаются внутри *net.OpError и ошибки резолвера, а docs/deploy.md обещает, что данных пользователя в журнале нет.

Решение

  • Редиректы не выполняются: CheckRedirect возвращает http.ErrUseLastResponse. Push-сервисы редиректов не шлют, а без этого требование https не значит ничего.
  • Соединение возможно только с публичным адресом. Проверка стоит на Control диалера, то есть на уже разрешённом адресе: имя, указывающее внутрь, не помогает. Непубличные — loopback, приватные сети (RFC 1918 и RFC 4193), link-local, multicast и неопределённый адрес.
  • PUT /api/devices/{id}/push отвергает 400 invalid литеральный непубличный адрес и endpoint длиннее 2 КиБ, а p256dh разбирает как точку P-256. Это ранний отсев формы; решает всё равно проверка при соединении.
  • Отказ отправки пишется в журнал классом: «таймаут», «имя не разрешилось», «адрес подписки не публичный», «отправка не удалась». Текст ошибки транспорта не печатается вовсе — внутри него адрес подписки.
  • Разрешение ходить на непубличные адреса есть в конфигурации, но из окружения не читается и в работе всегда выключено. Оно нужно тестам, где push-сервис вендора подменён сервером на 127.0.0.1.

Следствия

  • Сервер остаётся отправителем пушей и не становится инструментом запросов внутрь периметра: оракула hasPush по внутренним адресам больше нет.
  • Свой push-сервис на внутреннем адресе работать не будет. Для v1 это верно: подписку выдаёт браузер, а вендоры живут в интернете.
  • Остаток риска записан в docs/threat-model.md: сервер по-прежнему открывает соединение к адресу, который назвал браузер получателя, и белого списка вендоров у нас нет.