65042ee335
Верхняя граница пароля была объявлена в 64 СИМВОЛА и обоснована пределом bcrypt в 72 БАЙТА. Обоснование верно только для ASCII: у 64 символов длина от 64 до 256 байт. golang.org/x/crypto@v0.55.0 (bcrypt.go:96) отвечает на пароль длиннее 72 байт ErrPasswordTooLong, а не «молча отбрасывает остаток», как утверждал комментарий, — так вела себя редакция пакета до v0.28. Следствие: пароль из 64 кириллических букв (128 байт) проходил панель, оркестратор и DTO, а отказ приходил из хеширования — системной ошибкой на штатной смене пароля, а при установке падением старта админки, то есть сервером без администратора после INSTALL EXIT CODE: 0. Хуже самого дефекта было то, что тест закреплял это значение как ожидаемое. Вместе с ним закрыты три соседних расхождения того же контракта. Пароль триммился вопреки собственному контракту. util.HashPassword вёл проверку len(strings.TrimSpace(password)) < 6, а bootstrap читал strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")). Значение "abcde " принимали все двери продукта и не мог захешировать никто, а первая учётная запись создавалась не с тем паролем, который оператор записал в hy2xs.env. Панель считала длину в единицах UTF-16. Element Plus делегирует правила формы async-validator, а он сравнивает min/max с String.prototype.length: пароль из трёх эмодзи имел length 6, проходил минимум формы и получал отказ сервера, который панель не могла объяснить. hy2xs.env не был форматом. Значения писались интерполяцией, а читались split("=") с trim(); при этом файл читает не только оркестратор — он объявлен EnvironmentFile= в юните hy2xs-admin, и у незакавыченного значения systemd срезает краевые пробелы и трактует обратный слеш как escape. Что сделано: - контракт переехал в leaf-пакет apps/credential: его зовут util.HashPassword и dao, а service импортирует util — обратный импорт был бы циклическим, и именно поэтому HashPassword завёл собственную копию правила; - AdminPasswordMaxBytes = 72 объявлен отдельной константой и зеркально в оркестраторе и панели; сверяется тестами, читающими Go-исходник; - одно правило adminPassword вместо min=6,max=64 в тегах DTO (границу в байтах тегом валидатора не выразить) и код причины admin_password_format, называющий обе границы; - TrimSpace убран из хеширования и из bootstrap-пути; bootstrap проверяет контракт сам и падает с текстом, называющим переменную и файл; - панель считает code points и UTF-8 байты общим adminPasswordFormRule на обеих формах вместо встроенных min/max; - orchestrator/src/lib/envFile.ts — порт конечного автомата parse_env_file_internal из systemd и обратный ему кодировщик; экранируются только обратный слеш и двойная кавычка, оба из SHELL_NEED_ESCAPE. Обычные значения остаются без кавычек, поэтому релизные гейты не меняются. Тем же кодировщиком пишется bootstrap-admin.secret; - управляющие символы запрещены контрактом: формат KEY=VALUE их не несёт, а ввести такой пароль в форму входа всё равно нельзя; - отрицательная проба smoke сверяет конверт отказа (code 50000, invalid_credentials, отсутствие accessToken) вместо HTTP 200, а пароль генерирует, а не берёт из литерала; - положительная проба читает bootstrap-секрет парсером формата вместо grep | cut -d= -f2- с trim() — третьего по счёту слоя, срезавшего пробелы. Тесты: граничная таблица (36 x «я», 37 x «я», 18 и 19 эмодзи, 64 x «я», «abcde ») прогоняется в четырёх слоях; тест с 64 кириллическими буквами инвертирован; round-trip env-формата на значениях с кавычками, слешами и краевыми пробелами; bootstrap-путь на настоящей SQLite. 14 новых гейтов приёмки. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
323 lines
14 KiB
TypeScript
323 lines
14 KiB
TypeScript
/**
|
||
* Формат файлов `KEY=VALUE`, которые продукт пишет и читает: /etc/hy2xs/hy2xs.env
|
||
* и /etc/hy2xs/bootstrap-admin.secret.
|
||
*
|
||
* Зачем этот модуль существует. У hy2xs.env ДВА читателя, и один из них не наш:
|
||
* файл объявлен `EnvironmentFile=` в юните hy2xs-admin, то есть его разбирает
|
||
* systemd. Пока оркестратор писал значения интерполяцией
|
||
*
|
||
* `HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}`
|
||
*
|
||
* а читал их построчным `split("=")` с `trim()`, форматом это не являлось —
|
||
* это было совпадение поведения на значениях, у которых нет ни пробелов по
|
||
* краям, ни кавычек, ни обратных слешей. Продукт при этом ОБЕЩАЕТ оператору,
|
||
* что набор символов пароля не ограничен, а пробел по краям — часть значения.
|
||
* Обещание не выполнялось ни одним из двух читателей:
|
||
*
|
||
* - systemd у НЕ закавыченного значения срезает пробелы по краям и трактует
|
||
* `\` как escape (src/basic/env-file.c, состояние VALUE);
|
||
* - собственный парсер срезал пробелы своим `trim()`.
|
||
*
|
||
* То есть пароль с краевым пробелом терялся ещё до запуска админки, а пароль с
|
||
* обратным слешем приезжал изменённым.
|
||
*
|
||
* Поэтому здесь ровно две функции, и они обратны друг другу:
|
||
*
|
||
* parseEnvFile — разбор по правилам systemd;
|
||
* formatEnvAssignment — запись, которую systemd разберёт обратно побайтово.
|
||
*
|
||
* Правила разбора не выдуманы и не выведены из документации: они повторяют
|
||
* конечный автомат `parse_env_file_internal` из systemd/src/basic/env-file.c.
|
||
* Существенны четыре его свойства:
|
||
*
|
||
* 1. у НЕ закавыченного значения срезаются пробелы в конце, `\` уводит в
|
||
* escape, `\<перевод строки>` склеивает строки;
|
||
* 2. в одинарных кавычках всё literal до закрывающей кавычки — escape там
|
||
* НЕТ (это отличие от sh);
|
||
* 3. в двойных кавычках `\` уводит в escape, и escape «разворачивается»
|
||
* только для SHELL_NEED_ESCAPE — то есть для `"`, `\`, `` ` `` и `$`;
|
||
* для любого другого символа обратный слеш СОХРАНЯЕТСЯ вместе с ним;
|
||
* 4. подстановки переменных в env-файле нет вовсе: `$` внутри значения —
|
||
* обычный символ.
|
||
*
|
||
* Из (3) и (4) следует кодирование, которое переживает любое издание systemd:
|
||
* двойные кавычки и экранирование ТОЛЬКО `\` и `"`. Оба входят в
|
||
* SHELL_NEED_ESCAPE, поэтому разворачиваются одинаково и в действующем
|
||
* издании, и в тех, где escape в двойных кавычках снимался безусловно.
|
||
*/
|
||
|
||
/** Символы, которые systemd считает границей строки. */
|
||
const NEWLINE = "\n\r";
|
||
/** Символы, которые systemd считает пробельными. */
|
||
const WHITESPACE = " \t\n\r";
|
||
/** Начало комментария — только в позиции, где ожидается имя переменной. */
|
||
const COMMENTS = "#;";
|
||
/**
|
||
* SHELL_NEED_ESCAPE из systemd: внутри двойных кавычек обратный слеш перед этими
|
||
* символами снимается, перед любым другим — сохраняется.
|
||
*/
|
||
const SHELL_NEED_ESCAPE = '"\\`$';
|
||
|
||
type State =
|
||
| "PRE_KEY"
|
||
| "KEY"
|
||
| "PRE_VALUE"
|
||
| "VALUE"
|
||
| "VALUE_ESCAPE"
|
||
| "SINGLE_QUOTE_VALUE"
|
||
| "DOUBLE_QUOTE_VALUE"
|
||
| "DOUBLE_QUOTE_VALUE_ESCAPE"
|
||
| "COMMENT"
|
||
| "COMMENT_ESCAPE";
|
||
|
||
/**
|
||
* Разбирает содержимое env-файла ровно так, как это делает systemd.
|
||
*
|
||
* Строка без `=` — ОШИБКА, а не пропуск. systemd такую строку молча
|
||
* отбрасывает, и здесь это единственное намеренное расхождение: молчаливая
|
||
* потеря строки из /etc/hy2xs/hy2xs.env означала бы установку с настройкой,
|
||
* которую оператор задал, а продукт не увидел. Расхождение в сторону отказа
|
||
* безопасно — оно останавливает установку там, где её можно починить.
|
||
*/
|
||
export function parseEnvFile(content: string): Record<string, string> {
|
||
const result: Record<string, string> = {};
|
||
|
||
let state: State = "PRE_KEY";
|
||
let key = "";
|
||
let value = "";
|
||
let lastKeyWhitespace = -1;
|
||
let lastValueWhitespace = -1;
|
||
let line = 1;
|
||
|
||
const flush = (stripValueWhitespace: boolean): void => {
|
||
const name = lastKeyWhitespace < 0 ? key : key.slice(0, lastKeyWhitespace);
|
||
const raw =
|
||
stripValueWhitespace && lastValueWhitespace >= 0
|
||
? value.slice(0, lastValueWhitespace)
|
||
: value;
|
||
if (name !== "") {
|
||
result[name] = raw;
|
||
}
|
||
key = "";
|
||
value = "";
|
||
lastKeyWhitespace = -1;
|
||
lastValueWhitespace = -1;
|
||
};
|
||
|
||
for (const c of content) {
|
||
switch (state) {
|
||
case "PRE_KEY":
|
||
if (COMMENTS.includes(c)) {
|
||
state = "COMMENT";
|
||
} else if (!WHITESPACE.includes(c)) {
|
||
state = "KEY";
|
||
lastKeyWhitespace = -1;
|
||
key += c;
|
||
}
|
||
break;
|
||
|
||
case "KEY":
|
||
if (NEWLINE.includes(c)) {
|
||
// Имя без `=`. systemd молча отбрасывает такую строку; мы называем её.
|
||
throw new Error(`invalid env line ${line}: ${key.trim()}`);
|
||
} else if (c === "=") {
|
||
state = "PRE_VALUE";
|
||
lastValueWhitespace = -1;
|
||
} else {
|
||
if (!WHITESPACE.includes(c)) {
|
||
lastKeyWhitespace = -1;
|
||
} else if (lastKeyWhitespace < 0) {
|
||
lastKeyWhitespace = key.length;
|
||
}
|
||
key += c;
|
||
}
|
||
break;
|
||
|
||
case "PRE_VALUE":
|
||
if (NEWLINE.includes(c)) {
|
||
state = "PRE_KEY";
|
||
line += 1;
|
||
flush(false);
|
||
} else if (c === "'") {
|
||
state = "SINGLE_QUOTE_VALUE";
|
||
} else if (c === '"') {
|
||
state = "DOUBLE_QUOTE_VALUE";
|
||
} else if (c === "\\") {
|
||
state = "VALUE_ESCAPE";
|
||
} else if (!WHITESPACE.includes(c)) {
|
||
state = "VALUE";
|
||
value += c;
|
||
}
|
||
break;
|
||
|
||
case "VALUE":
|
||
if (NEWLINE.includes(c)) {
|
||
state = "PRE_KEY";
|
||
line += 1;
|
||
flush(true);
|
||
} else if (c === "\\") {
|
||
state = "VALUE_ESCAPE";
|
||
lastValueWhitespace = -1;
|
||
} else {
|
||
if (!WHITESPACE.includes(c)) {
|
||
lastValueWhitespace = -1;
|
||
} else if (lastValueWhitespace < 0) {
|
||
lastValueWhitespace = value.length;
|
||
}
|
||
value += c;
|
||
}
|
||
break;
|
||
|
||
case "VALUE_ESCAPE":
|
||
state = "VALUE";
|
||
// Экранированный перевод строки — склейка строк, и он съедается целиком.
|
||
if (!NEWLINE.includes(c)) {
|
||
value += c;
|
||
} else {
|
||
line += 1;
|
||
}
|
||
break;
|
||
|
||
case "SINGLE_QUOTE_VALUE":
|
||
// Escape внутри одинарных кавычек НЕТ: всё до закрывающей кавычки
|
||
// приезжает как есть. Это отличие от sh, и именно поэтому кодирование
|
||
// ниже использует двойные кавычки — в одинарных нельзя записать сам
|
||
// апостроф.
|
||
if (c === "'") {
|
||
state = "PRE_VALUE";
|
||
} else {
|
||
if (NEWLINE.includes(c)) {
|
||
line += 1;
|
||
}
|
||
value += c;
|
||
}
|
||
break;
|
||
|
||
case "DOUBLE_QUOTE_VALUE":
|
||
if (c === '"') {
|
||
state = "PRE_VALUE";
|
||
} else if (c === "\\") {
|
||
state = "DOUBLE_QUOTE_VALUE_ESCAPE";
|
||
} else {
|
||
if (NEWLINE.includes(c)) {
|
||
line += 1;
|
||
}
|
||
value += c;
|
||
}
|
||
break;
|
||
|
||
case "DOUBLE_QUOTE_VALUE_ESCAPE":
|
||
state = "DOUBLE_QUOTE_VALUE";
|
||
if (SHELL_NEED_ESCAPE.includes(c)) {
|
||
value += c;
|
||
} else if (!NEWLINE.includes(c)) {
|
||
// Обратный слеш СОХРАНЯЕТСЯ вместе с символом — «как делает
|
||
// настоящий shell», по формулировке самого systemd.
|
||
value += "\\" + c;
|
||
} else {
|
||
line += 1;
|
||
}
|
||
break;
|
||
|
||
case "COMMENT":
|
||
if (c === "\\") {
|
||
state = "COMMENT_ESCAPE";
|
||
} else if (NEWLINE.includes(c)) {
|
||
state = "PRE_KEY";
|
||
line += 1;
|
||
}
|
||
break;
|
||
|
||
case "COMMENT_ESCAPE":
|
||
state = "COMMENT";
|
||
if (NEWLINE.includes(c)) {
|
||
line += 1;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Хвост без перевода строки на конце файла.
|
||
switch (state) {
|
||
case "KEY":
|
||
throw new Error(`invalid env line ${line}: ${key.trim()}`);
|
||
case "PRE_VALUE":
|
||
flush(false);
|
||
break;
|
||
case "VALUE":
|
||
flush(true);
|
||
break;
|
||
case "VALUE_ESCAPE":
|
||
case "SINGLE_QUOTE_VALUE":
|
||
case "DOUBLE_QUOTE_VALUE":
|
||
case "DOUBLE_QUOTE_VALUE_ESCAPE":
|
||
// Незакрытая кавычка — испорченный файл, а не значение до конца файла.
|
||
// systemd в этом месте отдаёт то, что успел накопить; для конфигурации,
|
||
// от которой зависит доступ в панель, «что успели накопить» — не ответ.
|
||
throw new Error(`unterminated env value for ${key.trim()}`);
|
||
default:
|
||
break;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Значения, которые можно записать без кавычек.
|
||
*
|
||
* Набор намеренно узкий и не выведен из правил systemd: цель — чтобы уже
|
||
* существующие строки файла (пути, порты, домены, `50 mbps`, base64url-секреты)
|
||
* остались побайтово прежними, а всё хоть сколько-нибудь необычное уезжало в
|
||
* кавычки. Одиночные пробелы ВНУТРИ значения разрешены, по краям — нет: именно
|
||
* краевые systemd и срезает.
|
||
*/
|
||
const UNQUOTED_SAFE_VALUE = /^[A-Za-z0-9_\-.\/:@,=+%]+(?: [A-Za-z0-9_\-.\/:@,=+%]+)*$/;
|
||
|
||
/**
|
||
* Проверяет, что значение вообще представимо в этом формате.
|
||
*
|
||
* Управляющих символов формат не несёт: перевод строки — граница записи, а не
|
||
* данные. Отказ здесь громкий намеренно — молчаливая потеря части секрета
|
||
* означала бы установку, после которой невозможно войти, и причину, которой
|
||
* негде увидеться.
|
||
*/
|
||
export function assertEnvTransportable(name: string, value: string): string {
|
||
for (const character of value) {
|
||
const code = character.codePointAt(0) ?? 0;
|
||
if (code < 0x20 || code === 0x7f) {
|
||
throw new Error(
|
||
`${name} contains a control character (U+${code.toString(16).toUpperCase().padStart(4, "0")}): ` +
|
||
`формат KEY=VALUE, который читают systemd и оркестратор, управляющих символов не несёт`
|
||
);
|
||
}
|
||
}
|
||
return value;
|
||
}
|
||
|
||
/**
|
||
* Собирает строку `KEY=VALUE`, которую systemd разберёт обратно побайтово.
|
||
*
|
||
* Кавычки ставятся только когда они нужны, и это не косметика: пока запись
|
||
* остаётся прежней для обычных значений, релизные гейты и инструкции оператора,
|
||
* ищущие строку `grep '^HY2XS_UI_PORT=8080$'`, продолжают работать, а изменение
|
||
* формата видно ровно там, где оно что-то чинит.
|
||
*/
|
||
export function formatEnvAssignment(key: string, value: string): string {
|
||
assertEnvTransportable(key, value);
|
||
|
||
if (value === "" || UNQUOTED_SAFE_VALUE.test(value)) {
|
||
return `${key}=${value}`;
|
||
}
|
||
|
||
// Экранируются ТОЛЬКО `\` и `"`. Оба входят в SHELL_NEED_ESCAPE, поэтому
|
||
// разворачиваются обратно одинаково во всех изданиях systemd. Backtick и `$`
|
||
// внутри двойных кавычек — обычные символы: подстановки в env-файле нет.
|
||
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||
return `${key}="${escaped}"`;
|
||
}
|
||
|
||
/** Готовый файл из пар, каждая — через formatEnvAssignment. */
|
||
export function renderEnvFile(entries: readonly (readonly [string, string])[]): string {
|
||
return `${entries.map(([key, value]) => formatEnvAssignment(key, value)).join("\n")}\n`;
|
||
}
|