- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
136 lines
12 KiB
Markdown
136 lines
12 KiB
Markdown
# Модель угроз
|
||
|
||
## Периметр
|
||
|
||
**Находки строятся против целевого периметра: сервис открыт в публичный
|
||
интернет.** Целевой контур — 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; подписи
|
||
тела нет.
|