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

28 lines
4.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-047: Исходящий запрос к push-сервису
Уточняет [ADR-011](011-web-push.md) и [ADR-023](023-push-and-service-worker.md).
## Контекст
Адрес 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`: сервер по-прежнему открывает соединение к адресу, который назвал браузер получателя, и белого списка вендоров у нас нет.