Files
av 29ca8d415c httpapi: точки метрики за период отдаются одним запросом
- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
2026-08-04 18:46:45 +03:00

12 KiB
Raw Permalink Blame History

Модель угроз

Периметр

Находки строятся против целевого периметра: сервис открыт в публичный интернет. Целевой контур — VPS rivendell (Timeweb) за Caddy, который терминирует TLS; сам сервис слушает plain HTTP на localhost контейнера. Наружу открыты два контура на разных поддоменах: приём (телефон, токен записи) и чтение вместе с MCP (агенты и приложения, токен чтения). Отдельного контура у MCP нет.

Сегодняшний контур другой, и это переходное состояние, а не модель. Сервис живёт на рабочей машине, телефон достаёт до него только по локальной сети, проверка токенов выключена сознательно, config.docker.toml коммитится без секретов. Сервис предупреждает на старте обоими сообщениями (write auth disabled, read auth disabled), но стартовать не отказывается.

Отсюда правило для проходов ревью: выключенная сегодня проверка токенов — не дефект, а объявленное состояние; дефектом является путь, который остаётся открытым и после включения токенов. Закрытие сегодняшнего контура — задача «Управление токенами и секретами», решается перед деплоем.

Цена контуров разная и определяет ранжирование: открытый приём означает мусор во входе, открытое чтение — выгрузку всей истории здоровья любому, кто нашёл порт.

Недоверенный вход

Отправитель контролирует целиком:

  • Тело доставки — JSON от Health Auto Export: имена метрик, единицы, значения, метки времени, имена источников и устройств, имена секций, id тренировок и записей, содержимое маршрута.
  • Заголовки доставки — включая automation-id, automation-aggregation, User-Agent, Accept-Language, Upload-Complete; они пишутся в delivery, участвуют в выводе слоя, а Accept-Language — ещё и в выводе кода категориального значения (тег ограничен по длине и по форме, не тег даёт пустую локаль). Заголовки полуправдивы: automation-aggregation реальной гранулярности не описывает (разведка, находка 33).
  • Размер тела — предела на одну сущность нет; наблюдалось 63 МиБ на одной координате и 768 МиБ пика кучи на теле 40 МиБ.

Позже к этому добавится содержимое родного экспорта Apple — zip-архив с export.xml, который выбирает человек, но формируется он устройством и по объёму (3,6 млн записей) глазами не проверяется.

Новый адресат недоверенного входа — терминал оператора. Подкоманда healthlog uncovered печатает имена секций, а имя это верхнеуровневый ключ чужого тела: длина у него ограничена разбором (64 байта, не больше 32 имён), содержимое — ничем. Печатается оно экранированным (%q), иначе управляющая последовательность из тела подделала бы строки вывода. Тот же вход попадает структурным атрибутом в лог свёртки, где его экранирует кодировщик slog.

Ответы внешних систем в недоверенный вход не входят: исходящих вызовов у сервиса нет.

Из чего строятся пути и ключи

  • Путь в архиве<storage.archive_dir>/raw/ГГГГ/ММ/ДД/<ulid>.json.gz. Дата берётся из времени приёма, имя файла — из ULID, сгенерированного нами. Ни один сегмент пути не берётся из тела или заголовков доставки — это и есть защита от выхода за пределы каталога, и она держится ровно на этом.
  • Координатный ключ точкиметрика + слой + начало + конец. Имя метрики приходит из тела и в путь на диске не попадает, но попадает в ключ, в лог, в ответ каталога и — с появлением маршрута точек — в адрес запроса и в заголовок ETag ответа. Любое значение из чужого JSON, попадающее в ключ, в лог, в отчёт или в заголовок, имеет названный предел длины. У метки ответа предел взят формой: в неё уезжает не имя, а хеш канонизированной формы запроса (128 бит). Причина не только в длине — имя законно содержит кавычку, которая по RFC 9110 кончает метку, и разбор обрезал бы её ровно там.
  • Имя метрики в адресе декодируется из пути ровно один раз. Второе декодирование превращает имя a%41b в имя aAb — то есть в имя другой метрики витрины, и маршрут отвечает 200 её данными. Путь построен и прогнан враждебным проходом ревью.
  • Ключ сущностирод секции + id из HealthKit для record, id для workout. id приходит из тела.
  • Ключ наблюдённого категориального значенияметрика + поле + значение. Значение приходит из тела дословно и уезжает в первичный ключ: предел на него назван числом (128 байт), число различных значений одной доставки ограничено (64), и граница применяется при накоплении, а не при выдаче — иначе накопитель растёт вместе с телом, а тело контролирует отправитель (измерено: миллион различных значений в теле 60 МиБ поднимал пик процесса с 780 до 1002 МиБ). Значение, которое разбор JSON подменил (невалидный UTF-8, одинокий суррогат), наблюдением не считается вовсе: в ключ обязано попасть то, что пришло, а не то, что получилось.
  • Файл базы и каталог архива — из конфига, не из запроса.

Что разграничивает доступ

Статический токен в заголовке Authorization: Bearer …; список допустимых токенов — в конфиге. HAE умеет слать произвольные заголовки, этого достаточно.

Токены раздельные: запись (приём) и чтение. Клиент, читающий данные, писать не может. MCP пользуется токеном чтения. Ролей, пользователей и сессий нет — данные одного человека, разграничение только по контурам.

Конфиг с токенами лежит отдельным томом под 0600; реальный config.toml не коммитится, секреты рендерит деплой.

Что чувствительнее чего

По убыванию:

  1. Данные о здоровье — значения точек, тела доставок, содержимое архива. Утечка необратима и невосполнима: это история конкретного человека за годы.
  2. Токен чтения — открывает всю ту же историю целиком.
  3. Токен записи — открывает загрязнение витрины; лечится пересборкой журнала, то есть обратимо.
  4. Метаданные потока — имена устройств, automation-id, объёмы и время доставок. Выдают распорядок дня и модель телефона.

Отсюда правило логов: тела запросов и значения точек — только на DEBUG и с обрезкой; токены — никогда, ни на каком уровне. Ничего из ./data не попадает ни в git, ни в логи выше DEBUG, ни в вывод агента — это проверяет task gate.

Что вне модели

Перечислено явно, чтобы враждебный проход не выдумывал угрозу сам.

  • Компрометация самой машины rivendell и её оператора. Получивший shell получает и базу, и архив, и конфиг; шифрования на покое нет.
  • Компрометация телефона и учётной записи Apple. Источник данных доверенный по построению.
  • TLS, сертификаты и защита от сетевых атак — целиком на Caddy; сервис слушает plain HTTP и об этом знает.
  • DoS и исчерпание ресурсов как злонамеренное действие. Пределы на размер тела и заголовков нужны против своего же телефона, который шлёт 63 МиБ честно; сценарий «злоумышленник выкачивает диск» не рассматривается — контур приёма закрыт токеном, а токен есть только у одного устройства.
  • Многопользовательность, ролевая модель, аудит доступа. Данные одного человека; журнала обращений к чтению нет и не планируется.
  • Стойкость статического токена к подбору. Токен длинный и генерируется вне сервиса; ограничения частоты запросов нет.
  • Подмена содержимого доставки в пути. Закрывается TLS на Caddy; подписи тела нет.