fix(utf8): закрыть lossy-границы конфигурации и API
This commit is contained in:
@@ -278,8 +278,8 @@ control plane показывал всех пиров офлайн. Теперь
|
||||
| Набор символов логина | `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `AdminUsernameCharset` |
|
||||
| Длина пароля | 6-64 символа Unicode | `AdminPasswordMinLength` / `AdminPasswordMaxLength` |
|
||||
| Размер пароля | не более 72 байт в UTF-8 | `AdminPasswordMaxBytes` |
|
||||
| Домен пароля | значение, загружаемое systemd из `EnvironmentFile=` | `IsEnvTransportableText` |
|
||||
| Набор символов пароля | не ограничен, кроме `Cc` и `U+FEFF` | `hasForbiddenRune` |
|
||||
| Домен пароля | документированный домен systemd `EnvironmentFile=`: валидный UTF-8 без NUL, U+FEFF, суррогатов и noncharacters | `IsEnvTransportableText` |
|
||||
| Набор символов пароля | не ограничен, кроме `Cc` | `hasForbiddenRune` |
|
||||
| Пробелы по краям пароля | часть значения, не снимаются | — |
|
||||
|
||||
Контракт живёт в отдельном **leaf-пакете**, а не в `service`, и это не
|
||||
@@ -323,7 +323,11 @@ U+FDD0..U+FDEF noncharacters
|
||||
(cp & 0xFFFE) == 0xFFFE U+FFFE, U+FFFF, U+1FFFE, … U+10FFFF
|
||||
```
|
||||
|
||||
плюс встроенный NUL и любую невалидную последовательность UTF-8.
|
||||
плюс встроенный NUL, U+FEFF и любую невалидную последовательность UTF-8.
|
||||
|
||||
U+FEFF запрещён публичной документацией EnvironmentFile. Реализация systemd
|
||||
v257.13 случайно пропускает его (`0xFEFF & 0xFFFE == 0xFEFE`); HY2XS следует
|
||||
документированному контракту, а не закрепляет ошибку конкретной версии.
|
||||
|
||||
Пока контракт этого не знал, пароль `abcde` + `U+FDD0` — шесть символов, восемь
|
||||
байт, ни одного управляющего — проходил панель, оркестратор, DTO и хеширование,
|
||||
@@ -335,13 +339,17 @@ U+FDD0..U+FDEF noncharacters
|
||||
суррогат на `U+FFFD`. Без этой проверки не было бы отказа — было бы тихое
|
||||
изменение пароля по дороге в файл.
|
||||
|
||||
**Политика HY2XS — наше решение.** Сверх домена systemd запрещены управляющие
|
||||
**Политика HY2XS — наше решение.** Сверх транспортного домена запрещены управляющие
|
||||
символы Unicode целиком (категория `Cc`: `U+0000..U+001F`, `U+007F`,
|
||||
`U+0080..U+009F`) и `U+FEFF`. Формат их несёт — `0xFEFF & 0xFFFE` это `0xFEFE`,
|
||||
и systemd такое значение принимает, — но ни один из них невозможно ни увидеть в
|
||||
`U+0080..U+009F`). Их невозможно ни увидеть в
|
||||
поле ввода, ни повторить при следующем входе: они умеют ровно одно — запереть
|
||||
оператора снаружи.
|
||||
|
||||
На HTTP-границе проверяется не только уже декодированная Go-строка. Сырые JSON
|
||||
байты должны быть валидным UTF-8, а `\uXXXX` — не содержать непарных UTF-16
|
||||
суррогатов. Это делается до `encoding/json`, который иначе молча заменил бы оба
|
||||
дефекта на допустимый U+FFFD и мог бы аутентифицировать другое значение.
|
||||
|
||||
### Почему границ у пароля две
|
||||
|
||||
Их две потому, что они в **разных единицах**, и вывести одну из другой нельзя.
|
||||
|
||||
@@ -553,7 +553,8 @@ grep -c '^ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/bootstrap-admin.secret
|
||||
| --- | --- |
|
||||
| 6-64 символа Unicode | форма входа и форма смены пароля |
|
||||
| не более 72 байт в UTF-8 | bcrypt (`ErrPasswordTooLong`) |
|
||||
| без управляющих символов | формат `KEY=VALUE`, который читает systemd |
|
||||
| валидный UTF-8 без NUL, U+FEFF, суррогатов и noncharacters | документированный домен systemd `EnvironmentFile=` |
|
||||
| без управляющих символов Unicode (`Cc`) | политика HY2XS: значение должно воспроизводиться в однострочной форме входа |
|
||||
|
||||
Границы независимы: у 64 символов длина от 64 до 256 байт. Пароль из 64
|
||||
кириллических букв — это 128 байт, и он отвергается, хотя в границу символов
|
||||
|
||||
@@ -597,10 +597,12 @@ production-профилем, а не ищет подстроки. Проверя
|
||||
(`check_utf8ness_and_warn`), и отказ там — `-EINVAL`, то есть **незагруженный
|
||||
файл окружения** и юнит, который не стартует. `unichar_is_valid` отвергает
|
||||
суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`, а сам
|
||||
`utf8_is_valid` — встроенный NUL и невалидный UTF-8.
|
||||
`utf8_is_valid` — встроенный NUL и невалидный UTF-8. Публичная документация
|
||||
EnvironmentFile дополнительно запрещает U+FEFF. Реализация v257.13 случайно
|
||||
пропускает его из-за маски; HY2XS следует документированному контракту.
|
||||
|
||||
`isEnvTransportable` в `lib/envFile.ts` повторяет ровно это множество и ничего
|
||||
сверх него. Управляющие символы формат несёт — внутри двойных кавычек перевод
|
||||
`isEnvTransportable` в `lib/envFile.ts` повторяет документированное множество.
|
||||
Управляющие символы формат несёт — внутри двойных кавычек перевод
|
||||
строки накапливается как обычный байт и переживает round-trip, — и запрещает их
|
||||
контракт учётных данных, а не транспорт. Приписывать формату чужие запреты
|
||||
нельзя: именно так проверка и пропустила noncharacters, о которых ничего не
|
||||
@@ -610,6 +612,13 @@ production-профилем, а не ищет подстроки. Проверя
|
||||
вправе их содержать, а `TextEncoder` молча заменит непарный суррогат на
|
||||
`U+FFFD` — то есть без проверки в файл уехал бы **другой** секрет, а не отказ.
|
||||
|
||||
Сам файл читается только как байты и декодируется через
|
||||
`TextDecoder("utf-8", { fatal: true, ignoreBOM: true })`. Обычный
|
||||
`Bun.file(...).text()` запрещён на этой границе: он заменяет повреждённые байты
|
||||
на U+FFFD. `ignoreBOM: true` сохраняет BOM как U+FEFF, чтобы тот не исчез до
|
||||
транспортной проверки. Исходный текст целиком проверяется **до** разбора ключей:
|
||||
запрещённый символ не может спрятаться в комментарии или неизвестной переменной.
|
||||
|
||||
### Непригодная конфигурация отвергается до первой мутации
|
||||
|
||||
`validateRuntimeEnvTransport` вызывается из `parseRuntimeEnv`, а не при записи
|
||||
@@ -681,6 +690,13 @@ production-профилем, а не ищет подстроки. Проверя
|
||||
`redact-config` и diagnostics-бандл используют **структурную** редакцию: YAML
|
||||
разбирается и обходится как дерево.
|
||||
|
||||
Diagnostics не копирует env/YAML и не перенаправляет сырой journal/systemctl
|
||||
сразу в staging. Сначала данные читаются или захватываются в память, проходят
|
||||
редакцию и лишь затем записываются с режимом `0600`. Некорректный UTF-8 в
|
||||
конфигурационном файле даёт безопасный маркер пропуска без исходных байтов.
|
||||
Вывод каждой внешней команды ограничен 8 МиБ на поток и при усечении явно
|
||||
помечается; архив создаётся сразу под `umask 077`.
|
||||
|
||||
Это не косметика. Построчное правило `auth:\s*(.*)` подставляло маркер в
|
||||
заголовок mapping'а и оставляло нетронутым вложенный
|
||||
`auth.http.url` с `access_token=<секрет>`, то есть бандл уносил machine token
|
||||
|
||||
@@ -153,7 +153,7 @@ anycast. Отсутствие A-записи фатально при любом
|
||||
| Переменная | Требование | Значение по умолчанию |
|
||||
| --- | --- | --- |
|
||||
| `HY2XS_ADMIN_USER` | 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `hy2xsadmin` |
|
||||
| `HY2XS_ADMIN_INITIAL_PASSWORD` | 6-64 символа Unicode **и** не более 72 байт в UTF-8; значение, загружаемое systemd из `EnvironmentFile=`; набор не ограничен, кроме `Cc` и `U+FEFF` | генерируется |
|
||||
| `HY2XS_ADMIN_INITIAL_PASSWORD` | 6-64 символа Unicode **и** не более 72 байт в UTF-8; документированный транспортный домен systemd, включая запрет U+FEFF; набор не ограничен, кроме `Cc` | генерируется |
|
||||
|
||||
Значение вне контракта **роняет установку** с явным текстом, называющим границы
|
||||
и набор. Так и должно быть: отказ, пришедший установщику, чинится одной строкой
|
||||
@@ -193,7 +193,7 @@ HY2XS_ADMIN_INITIAL_PASSWORD="пароль с пробелом на конце "
|
||||
Есть два ограничения набора символов, и они разного происхождения.
|
||||
|
||||
**Домен systemd.** Значение обязано быть загружаемым из `EnvironmentFile=`:
|
||||
валидный UTF-8 из Unicode scalar values, без NUL, без суррогатов и без
|
||||
валидный UTF-8 из Unicode scalar values, без NUL, U+FEFF, суррогатов и
|
||||
noncharacters (`U+FDD0..U+FDEF` и все `*FFFE`/`*FFFF`). Это не наше правило —
|
||||
systemd прогоняет значение через `utf8_is_valid` и отвечает `-EINVAL`, то есть
|
||||
файл окружения **не загружается** и юнит не стартует. Оркестратор проверяет
|
||||
@@ -201,9 +201,13 @@ systemd прогоняет значение через `utf8_is_valid` и отв
|
||||
или obfs-пароль сломали бы загрузку юнита ровно так же.
|
||||
|
||||
**Политика HY2XS.** Сверх этого запрещены управляющие символы Unicode
|
||||
(категория `Cc`) и `U+FEFF`. Формат их несёт, но ввести такой пароль в
|
||||
(категория `Cc`). Формат их несёт, но ввести такой пароль в
|
||||
однострочное поле формы входа всё равно нельзя.
|
||||
|
||||
Ограничение U+FEFF берётся из публичной документации systemd. В реализации
|
||||
v257.13 он случайно проходит через `unichar_is_valid` из-за маски; HY2XS не
|
||||
делает эту ошибку версии частью собственного контракта.
|
||||
|
||||
Отказ по любому из двух правил приходит **при разборе конфигурации**, то есть до
|
||||
первой необратимой операции над хостом: `preflight-install` и `install` видят
|
||||
его одинаково.
|
||||
|
||||
@@ -117,10 +117,10 @@
|
||||
|
||||
| Вход | Ожидание |
|
||||
| --- | --- |
|
||||
| NUL, `U+FDD0`, `U+FDEF`, `U+FFFE`, `U+FFFF`, `U+1FFFF`, `U+10FFFF` | отказ ЗАПИСИ: `utf8_is_valid` отвергает такое значение, и EnvironmentFile не загрузится |
|
||||
| NUL, `U+FEFF`, `U+FDD0`, `U+FDEF`, `U+FFFE`, `U+FFFF`, `U+1FFFF`, `U+10FFFF` | отказ ЗАПИСИ: публичный контракт EnvironmentFile запрещает такое значение |
|
||||
| одиночный суррогат `U+D800` | отказ — иначе `TextEncoder` молча заменил бы его на `U+FFFD`, то есть подменил бы секрет |
|
||||
| `U+FDCF`, `U+FDF0`, `U+FFFD`, `U+10FFFD`, `U+1F600` | принимаются: правило описывает диапазон, а не окрестность |
|
||||
| `\n`, `\r`, `\t`, `U+007F`, `U+0085`, `U+FEFF` | формат их НЕСЁТ и round-trip сохраняет; запрещает их контракт учётных данных, а не транспорт |
|
||||
| `\n`, `\r`, `\t`, `U+007F`, `U+0085` | формат их НЕСЁТ и round-trip сохраняет; запрещает их контракт учётных данных, а не транспорт |
|
||||
|
||||
### A4b. Непригодная конфигурация отвергается до первой мутации
|
||||
|
||||
@@ -591,18 +591,27 @@ wildcard-маршрутом фронтенда или дублирующая р
|
||||
- управляющие символы Unicode целиком, то есть `Cc`: `\n`, `\r`, `\t`, `NUL`,
|
||||
`DEL` и C1 (`U+0085`, `U+009F`). Раньше проверялись только C0 и DEL, а
|
||||
документация обещала «без управляющих символов» — то есть была шире кода;
|
||||
- значения вне домена systemd (`U+FDD0`, `U+FDEF`, `U+FFFE`, `U+FFFF`,
|
||||
- значения вне документированного домена systemd (`U+FEFF`, `U+FDD0`, `U+FDEF`, `U+FFFE`, `U+FFFF`,
|
||||
`U+1FFFF`, `U+10FFFF`, невалидный UTF-8) отвергаются: с ними
|
||||
`/etc/hy2xs/hy2xs.env` не загрузится и юнит не стартует;
|
||||
- соседи запрещённых диапазонов (`U+FDCF`, `U+FDF0`, `U+FFFD`) принимаются:
|
||||
правило описывает множество systemd, а не окрестность подозрительных значений;
|
||||
- `U+FEFF` отвергается **политикой продукта**, а не форматом:
|
||||
`0xFEFF & 0xFFFE == 0xFEFE`, и systemd такое значение принимает. Отдельный
|
||||
тест `TestEnvTransportDomainMatchesSystemd` закрепляет, что домен транспорта
|
||||
не шире и не уже множества systemd, а
|
||||
- `U+FEFF` отвергается **транспортным доменом**: публичная документация systemd
|
||||
запрещает его, хотя реализация v257.13 случайно пропускает из-за маски
|
||||
`0xFEFF & 0xFFFE == 0xFEFE`. Тест
|
||||
`TestEnvTransportDomainMatchesDocumentedSystemdContract` закрепляет публичный контракт, а
|
||||
`TestProductPolicyIsWiderThanTransportDomain` — что политика и домен остаются
|
||||
различимы.
|
||||
|
||||
`orchestrator/test/strict-text-read.test.ts` подаёт reader'у реальные байтовые
|
||||
последовательности `0xFF`, оборванную `0xC3` и `ED A0 80`. Ни одна из них не
|
||||
превращается в U+FFFD; начальный BOM сохраняется как U+FEFF и доходит до
|
||||
транспортного отказа.
|
||||
|
||||
`apps/controller/json_body_test.go` и HTTP-тест входа доказывают то же на API:
|
||||
повреждённый UTF-8 и непарные `\uD800`/`\uDC00` отвергаются до стандартного
|
||||
Go-декодера, а настоящий U+FFFD остаётся допустимым значением.
|
||||
|
||||
`apps/controller/validator_test.go` — ПРОВОДКА, а не контракт: теги
|
||||
`credentialStr` и `adminPassword` прогоняются через production-валидатор и
|
||||
обязаны отвечать так же, как функции контракта, на тех же граничных значениях.
|
||||
@@ -932,4 +941,3 @@ Hysteria. Вторая не была замечена только потому,
|
||||
Отсутствие legacy-маршрута доказывает не `grep` по исходникам, а
|
||||
`TestRouterHasNoLegacyNamespace` на таблице маршрутов собранного роутера —
|
||||
и существование этого теста само проверяется контрактом.
|
||||
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
35. **оператор входит в панель**: `POST /api/auth/login` с bootstrap-учётными данными из `/etc/hy2xs/bootstrap-admin.secret` отвечает `code: 20000` и непустым `accessToken`. Заведомо неверные учётные данные (со СЛУЧАЙНЫМ паролем, а не с литералом) дают HTTP 200 с конвертом отказа: `code: 50000`, причина `invalid_credentials` и отсутствие `accessToken`
|
||||
36. пароль предельной длины, назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными. Границ **две** — 6-64 символа Unicode и не более 72 байт в UTF-8 (предел bcrypt): пароль из 36 кириллических букв (72 байта) принимается, из 37 (74 байта) — отвергается конвертом валидации, а не системной ошибкой
|
||||
37. `HY2XS_ADMIN_INITIAL_PASSWORD` с пробелом по краям доезжает до учётной записи неизменным: значение записано в `hy2xs.env` в двойных кавычках, и вход выполняется ровно им, а не обрезанным
|
||||
37a. `HY2XS_ADMIN_INITIAL_PASSWORD` со значением вне домена systemd (`U+FDD0`, `U+FFFF`, невалидный UTF-8) **роняет `preflight-install`** — то есть отказ приходит до первой мутации хоста, а не после установки пакетов; сервер остаётся нетронутым
|
||||
37a. `HY2XS_ADMIN_INITIAL_PASSWORD` со значением вне документированного домена systemd (`U+FEFF`, `U+FDD0`, `U+FFFF`) или файл с байтами `0xFF`, оборванной `0xC3`, `ED A0 80` **роняет `preflight-install`** — отказ приходит до первой мутации хоста; сервер остаётся нетронутым
|
||||
37b. API входа и импорта отвергает повреждённый UTF-8 и непарные UTF-16 surrogate escape до `encoding/json`; настоящий U+FFFD работает как обычный символ
|
||||
38. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки
|
||||
|
||||
## C0. Панель обязана впускать, а не слушать порт
|
||||
@@ -237,4 +238,3 @@ idle timeout проходил семантическую проверку. То
|
||||
|
||||
Го- и TS-санитайзеры описывают один контракт и покрыты зеркальными тестами:
|
||||
граница определяется значением, а не именем ключа.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user