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