Files
bare/docs/decisions/047-push-endpoint.md
T
mayatnikovandClaude Opus 5 cfd0ec0ce9 Пуши на iOS: sub уезжал как mailto:mailto:, Apple отвечал BadJwtToken
webpush-go в getVAPIDAuthorizationHeader приписывает "mailto:" всему,
что не начинается с "https:". В /etc/bare/env лежит правильная по
спецификации VAPID форма mailto:admin@xmatic.team, и в JWT уходил
sub = mailto:mailto:admin@xmatic.team. Apple отвергал токен, пуши
не работали ни разу за всё время.

Проверено экспериментом на живом endpoint: mailto:<адрес> → 201,
https://<хост> → 201, mailto:mailto:<адрес> → 403 BadJwtToken,
голый адрес без схемы → 403. Значит убрать схему из конфига нельзя:
адаптируется код, а не операционный файл.

Субъект нормализуется один раз при создании отправщика, форма
BARE_VAPID_SUBJECT проверяется при старте — негодное значение валит
запуск с понятной ошибкой, а не выключает пуши молча.

ADR-064: в журнал пишется код причины от push-сервиса. Без него отказ
был неотличим от любого другого 403, и дефект прожил незамеченным
весь этап 4 и всю закалку. Адрес подписки в журнал по-прежнему
не попадает: наружу идёт только короткий код из латиницы и цифр.

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

5.0 KiB
Raw Blame History

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

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

Контекст

Адрес 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: сервер по-прежнему открывает соединение к адресу, который назвал браузер получателя, и белого списка вендоров у нас нет.