Files
bare/docs/decisions/047-push-endpoint.md
T
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

28 lines
5.0 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-сервиса в журнале — [ADR-073](073-push-failure-reason-in-log.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`: сервер по-прежнему открывает соединение к адресу, который назвал браузер получателя, и белого списка вендоров у нас нет.