Files
bare/docs/decisions/047-push-endpoint.md
T
mayatnikovandClaude Opus 5 8f67f4aa4d Этап 4: PWA и пуши — service worker, Web Push с VAPID, баннер установки
Сервер: пакет push на webpush-go (третья и последняя прямая зависимость),
подписка устройства, правило ADR-023 «пуш только молчащему устройству и только
один» через атомарный захват push_pending, удаление подписки на 404 и 410,
TTL сутки, urgency normal. В нагрузке только {title, body, chat} — тело
собирается из константы, плейнтекст туда не попадает даже по ошибке.

Клиент: service worker с версионированным кэшем оболочки и никогда — /api/*,
push и notificationclick, подписка на VAPID-ключ сервера, запрос разрешения
после первого отправленного сообщения, разделы настроек «уведомления»
и «установить приложение», баннер установки на iOS.

ADR-045: пуш адресован получателю — по букве ADR-023 он уходил бы и молчащему
устройству отправителя с бессмысленным заголовком из собственного ника.
ADR-047: сервер ходит на endpoint подписки, который выбирает браузер. Проверка
«только https» обходилась редиректом, а имя могло смотреть внутрь сети — теперь
запрет редиректов и проверка разрешённого адреса на уровне сокета.
ADR-048: пределы отправки — недоступный push-сервис одного аккаунта больше
не съедает пуши всего сервера.
ADR-049: явно выключенные уведомления сами не включаются обратно.

Попутно: webpush-go дописывает набивку в переданный срез, а одна нагрузка
уходила всем устройствам сообщения — гонка, пойманная go test -race.
Теперь у каждого задания своя копия.

Приёмка на боевом: подписки, hasPush, чужое устройство, Origin, оболочка
из девяти файлов, Service-Worker-Allowed. Отдельно шесть непубличных адресов
и endpoint на 3 КиБ — все отбиты.

Чеклист ручной проверки на iPhone и Android — в docs/plan.md.

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

4.9 KiB
Raw Blame History

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

Уточняет ADR-011 и ADR-023.

Контекст

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