270 lines
15 KiB
Go
270 lines
15 KiB
Go
package credential
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством.
|
|
//
|
|
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
|
|
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
|
|
// Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя
|
|
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила
|
|
// означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
|
|
// оператора в панель.
|
|
//
|
|
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
|
|
// классе символов уронит его, а не вход администратора на живом сервере.
|
|
func TestAdminUsernameCharsetIsUnchanged(t *testing.T) {
|
|
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
|
|
|
|
for _, symbol := range strings.Split(historical, "") {
|
|
candidate := "admin" + symbol
|
|
if !IsValidAdminUsername(candidate) {
|
|
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
|
|
}
|
|
}
|
|
|
|
for _, rejected := range []string{
|
|
"", // не задано
|
|
"admi", // короче шести символов
|
|
strings.Repeat("a", 33), // длиннее тридцати двух
|
|
"admin пробел", // пробел
|
|
"админ1", // кириллица
|
|
"admin\n1", // перевод строки
|
|
"admin'1", // апостроф вне набора
|
|
} {
|
|
if IsValidAdminUsername(rejected) {
|
|
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Границы длины логина принадлежат ОДНОМУ правилу, и человекочитаемый набор
|
|
// обязан описывать тот же класс, что и регексп.
|
|
//
|
|
// Расхождение здесь тихо превращает сообщение об отказе в дезинформацию:
|
|
// оператор читает набор, которого правило не принимает.
|
|
func TestAdminUsernameBoundsAgreeWithRule(t *testing.T) {
|
|
if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength-1)) {
|
|
t.Errorf("логин длиной %d принят при минимуме %d", AdminUsernameMinLength-1, AdminUsernameMinLength)
|
|
}
|
|
if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength)) {
|
|
t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMinLength)
|
|
}
|
|
if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength)) {
|
|
t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMaxLength)
|
|
}
|
|
if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength+1)) {
|
|
t.Errorf("логин длиной %d принят при максимуме %d", AdminUsernameMaxLength+1, AdminUsernameMaxLength)
|
|
}
|
|
|
|
// Человекочитаемый набор перечисляет ровно те же знаки препинания, что и
|
|
// класс регекспа. Пробелы в нём — разделители групп, а не допустимый символ.
|
|
punctuation := strings.Fields(AdminUsernameCharset)
|
|
for _, symbol := range strings.Split(punctuation[len(punctuation)-1], "") {
|
|
if !IsValidAdminUsername("admin" + symbol) {
|
|
t.Errorf("набор из сообщения обещает символ %q, но правило его не принимает", symbol)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Длина пароля считается В СИМВОЛАХ, а предел bcrypt — В БАЙТАХ, и это разные
|
|
// границы.
|
|
//
|
|
// Здесь был дефект. Верхняя граница в 64 символа объявлялась «заведомо ниже 72
|
|
// байт», и это верно ТОЛЬКО для ASCII. Тест, закреплявший поведение, вдобавок
|
|
// требовал приёма 64 кириллических букв — то есть 128 байт — и тем самым
|
|
// фиксировал как ожидаемое ровно то значение, на котором продукт ломался:
|
|
// оркестратор его пропускал, DTO пропускало, а bcrypt отвечал
|
|
// ErrPasswordTooLong уже при создании учётной записи.
|
|
func TestAdminPasswordBoundaries(t *testing.T) {
|
|
// Проверка самой таблицы: если «я» перестанет быть двухбайтовой, а «😀» —
|
|
// четырёхбайтовым, случаи ниже проверяли бы не то, что написано.
|
|
if got := len(strings.Repeat("я", 36)); got != AdminPasswordMaxBytes {
|
|
t.Fatalf("подготовка: 36 x «я» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes)
|
|
}
|
|
if got := len(strings.Repeat("😀", 18)); got != AdminPasswordMaxBytes {
|
|
t.Fatalf("подготовка: 18 x «😀» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
password string
|
|
accepted bool
|
|
}{
|
|
{"ровно минимум", strings.Repeat("a", AdminPasswordMinLength), true},
|
|
{"на символ короче минимума", strings.Repeat("a", AdminPasswordMinLength-1), false},
|
|
{"ровно максимум в ASCII", strings.Repeat("a", AdminPasswordMaxLength), true},
|
|
{"на символ длиннее максимума", strings.Repeat("a", AdminPasswordMaxLength+1), false},
|
|
|
|
// Граница bcrypt проходит ВНУТРИ разрешённого диапазона символов.
|
|
{"36 x «я» = 72 байта", strings.Repeat("я", 36), true},
|
|
{"37 x «я» = 74 байта", strings.Repeat("я", 37), false},
|
|
{"18 x «😀» = 72 байта", strings.Repeat("😀", 18), true},
|
|
{"19 x «😀» = 76 байт", strings.Repeat("😀", 19), false},
|
|
|
|
// Исходный дефект: 64 символа, 128 байт. Прежний тест требовал ПРИЁМА.
|
|
{"64 x «я» = 128 байт", strings.Repeat("я", AdminPasswordMaxLength), false},
|
|
|
|
// Пробелы по краям — часть пароля, а не мусор ввода.
|
|
{"пробел на конце", "abcde ", true},
|
|
{"пробел в начале", " abcde", true},
|
|
{"только пробелы", strings.Repeat(" ", AdminPasswordMinLength), true},
|
|
|
|
// Набор символов не ограничен ничем, кроме управляющих.
|
|
{"кириллица", "пароль-администратора", true},
|
|
{"знаки препинания", `p@$$w0rd"\'#;`, true},
|
|
{"перевод строки", "abcde\n", false},
|
|
{"возврат каретки", "abcde\r", false},
|
|
{"табуляция", "abcde\t", false},
|
|
{"нулевой байт", "abcde\x00", false},
|
|
{"DEL", "abcde\x7f", false},
|
|
|
|
// C1: раньше документация обещала «без управляющих символов», а код
|
|
// проверял только C0 и DEL — то есть обещание было шире проверки.
|
|
{"C1 NEL", "abcde" + string(rune(0x85)), false},
|
|
{"C1 верхняя граница", "abcde" + string(rune(0x9F)), false},
|
|
|
|
// Домен systemd. Каждое из этих значений — шесть символов, ни одного
|
|
// управляющего, укладывается в 72 байта, — и при этом делает
|
|
// /etc/hy2xs/hy2xs.env НЕзагружаемым: `check_utf8ness_and_warn`
|
|
// отвечает -EINVAL, и юнит hy2xs-admin не стартует.
|
|
{"noncharacter U+FDD0", "abcde" + string(rune(0xFDD0)), false},
|
|
{"noncharacter U+FDEF", "abcde" + string(rune(0xFDEF)), false},
|
|
{"noncharacter U+FFFE", "abcde" + string(rune(0xFFFE)), false},
|
|
{"noncharacter U+FFFF", "abcde" + string(rune(0xFFFF)), false},
|
|
{"noncharacter U+1FFFF", "abcde" + string(rune(0x1FFFF)), false},
|
|
{"noncharacter U+10FFFF", "abcde" + string(rune(0x10FFFF)), false},
|
|
{"невалидный UTF-8", "abcde\xff", false},
|
|
|
|
// Соседи noncharacters приниматься обязаны: правило описывает ровно
|
|
// множество systemd, а не «всё подозрительное рядом».
|
|
{"U+FDCF рядом с диапазоном", "abcde" + string(rune(0xFDCF)), true},
|
|
{"U+FDF0 рядом с диапазоном", "abcde" + string(rune(0xFDF0)), true},
|
|
{"U+FFFD (replacement)", "abcde" + string(rune(0xFFFD)), true},
|
|
|
|
// U+FEFF отвергается документированным транспортным доменом systemd.
|
|
{"U+FEFF", "abcde" + string(rune(0xFEFF)), false},
|
|
}
|
|
|
|
for _, testCase := range cases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
if got := IsValidAdminPassword(testCase.password); got != testCase.accepted {
|
|
t.Errorf(
|
|
"IsValidAdminPassword = %v, ожидалось %v (%d символов, %d байт)",
|
|
got, testCase.accepted,
|
|
utf8.RuneCountInString(testCase.password), len(testCase.password),
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Предел в байтах не выводится из предела в символах, и обратное неверно тоже.
|
|
//
|
|
// Проверка защищает от «упрощения», при котором одну из двух констант объявят
|
|
// производной от другой: у 64 символов длина от 64 до 256 байт.
|
|
func TestAdminPasswordLimitsAreIndependent(t *testing.T) {
|
|
if AdminPasswordMaxBytes != 72 {
|
|
t.Fatalf(
|
|
"предел bcrypt изменён на %d: GenerateFromPassword отвечает ErrPasswordTooLong строго при len > 72",
|
|
AdminPasswordMaxBytes,
|
|
)
|
|
}
|
|
|
|
// Пароль, укладывающийся в символы и не укладывающийся в байты, обязан
|
|
// существовать — иначе байтовая граница недостижима и ничего не проверяет.
|
|
long := strings.Repeat("я", AdminPasswordMaxLength)
|
|
if utf8.RuneCountInString(long) > AdminPasswordMaxLength {
|
|
t.Fatal("подготовка: значение длиннее предела в символах, проверяется не та граница")
|
|
}
|
|
if IsValidAdminPassword(long) {
|
|
t.Fatal("пароль в 128 байт принят: байтовая граница не проверяется")
|
|
}
|
|
}
|
|
|
|
// Домен транспорта повторяет публично документированное множество systemd.
|
|
//
|
|
// Шире означало бы собственный запрет сверх публичного контракта; уже — что
|
|
// значение проходит все двери продукта вопреки документации EnvironmentFile.
|
|
// Второе и было дефектом: контракт знал про управляющие символы и не знал про
|
|
// noncharacters.
|
|
//
|
|
// Границы взяты из `unichar_is_valid` (systemd, src/basic/utf8.c) и проверяются
|
|
// вместе с соседями: правило обязано отвергать ровно диапазон, а не окрестность.
|
|
func TestEnvTransportDomainMatchesDocumentedSystemdContract(t *testing.T) {
|
|
rejected := []rune{
|
|
0x0000, 0xFEFF, // NUL и BOM запрещены публичным контрактом
|
|
0xFDD0, 0xFDEF, // U+FDD0..U+FDEF
|
|
0xFFFE, 0xFFFF,
|
|
0x1FFFE, 0x1FFFF,
|
|
0x10FFFE, 0x10FFFF,
|
|
}
|
|
for _, r := range rejected {
|
|
if IsEnvTransportableText(string(r)) {
|
|
t.Errorf("U+%04X принят вопреки документированному контракту EnvironmentFile", r)
|
|
}
|
|
}
|
|
|
|
accepted := []rune{
|
|
0x0020, 0x007F, 0x0085, // управляющие формат НЕСЁТ; их запрещает политика продукта
|
|
0xFDCF, 0xFDF0, // соседи зарезервированного диапазона
|
|
0xFFFD, // replacement character — обычный символ
|
|
0x10FFFD, // последний scalar value, не noncharacter
|
|
0x1F600, // 😀
|
|
}
|
|
for _, r := range accepted {
|
|
if !IsEnvTransportableText(string(r)) {
|
|
t.Errorf("U+%04X отвергнут: правило шире документированного множества systemd", r)
|
|
}
|
|
}
|
|
|
|
// Невалидный UTF-8 — не «строка со странными символами», а байты, которые
|
|
// systemd отвергает первой же проверкой.
|
|
for _, invalid := range []string{"\xff", "\xc3", "abc\xed\xa0\x80"} {
|
|
if IsEnvTransportableText(invalid) {
|
|
t.Errorf("невалидный UTF-8 %q принят", invalid)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Продуктовая политика и домен systemd — РАЗНЫЕ множества, и это видно.
|
|
//
|
|
// Пока они были склеены, невозможно было ни объяснить отказ, ни заметить, что
|
|
// одно из двух правил отсутствует.
|
|
func TestProductPolicyIsWiderThanTransportDomain(t *testing.T) {
|
|
// Управляющие символы формат несёт — их запрещает HY2XS.
|
|
for _, r := range []rune{0x0A, 0x0D, 0x09, 0x7F, 0x85, 0x9F} {
|
|
password := "abcde" + string(r)
|
|
if !IsEnvTransportableText(password) && r != 0x00 {
|
|
t.Errorf("U+%04X: ожидалось, что формат его несёт", r)
|
|
}
|
|
if IsValidAdminPassword(password) {
|
|
t.Errorf("U+%04X принят паролем вопреки политике продукта", r)
|
|
}
|
|
}
|
|
if IsEnvTransportableText("abcde" + string(rune(0xFEFF))) {
|
|
t.Error("U+FEFF принят вопреки документированному контракту EnvironmentFile")
|
|
}
|
|
}
|
|
|
|
// Пароль НЕ триммится нигде, включая сам контракт.
|
|
//
|
|
// Пока `util.HashPassword` вёл собственную проверку через strings.TrimSpace,
|
|
// значение `"abcde "` считалось корректным всеми дверями продукта и не могло
|
|
// быть захешировано.
|
|
func TestAdminPasswordKeepsSurroundingWhitespace(t *testing.T) {
|
|
// Шесть символов, из которых последний — пробел. По длине без пробела это
|
|
// пять символов, то есть прежняя проверка отказала бы.
|
|
const password = "abcde "
|
|
if utf8.RuneCountInString(strings.TrimSpace(password)) >= AdminPasswordMinLength {
|
|
t.Fatal("подготовка: значение проходит и после тримминга, проверяется не то")
|
|
}
|
|
if !IsValidAdminPassword(password) {
|
|
t.Error("пароль с краевым пробелом отклонён: пробел объявлен частью значения")
|
|
}
|
|
}
|