fix(utf8): закрыть lossy-границы конфигурации и API
This commit is contained in:
@@ -491,6 +491,46 @@ func TestLoginEndpointIssuesTokenForValidCredentials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Повреждённые байты и непарные surrogate escape не имеют права превратиться
|
||||
// в U+FFFD до проверки пароля. Сам U+FFFD остаётся обычным допустимым символом.
|
||||
func TestLoginEndpointRejectsLossyJSONBeforeAuthentication(t *testing.T) {
|
||||
password := "abcde" + string(rune(0xFFFD))
|
||||
id := newAuthTestDB(t, "hy2xsadmin", password)
|
||||
|
||||
for _, body := range [][]byte{
|
||||
[]byte{'{', '"', 'u', 's', 'e', 'r', 'n', 'a', 'm', 'e', '"', ':', '"', 'h', 'y', '2', 'x', 's', 'a', 'd', 'm', 'i', 'n', '"', ',', '"', 'p', 'a', 's', 's', '"', ':', '"', 0xFF, '"', '}'},
|
||||
[]byte(`{"username":"hy2xsadmin","pass":"abcde\uD800"}`),
|
||||
[]byte(`{"username":"hy2xsadmin","pass":"abcde\uDC00"}`),
|
||||
} {
|
||||
status, result := postLoginRaw(t, body, false)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("повреждённое тело дало HTTP %d", status)
|
||||
}
|
||||
if result.Code != constant.CodeInvalidError {
|
||||
t.Fatalf("повреждённое тело не отклонено как invalid: %+v", result)
|
||||
}
|
||||
var found bool
|
||||
for _, reason := range result.Errors {
|
||||
found = found || reason.Code == constant.ErrCodeBodyInvalid
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("нет причины %q: %+v", constant.ErrCodeBodyInvalid, result)
|
||||
}
|
||||
}
|
||||
admin, err := dao.GetAdminUser("id = ?", id)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось проверить состояние администратора: %v", err)
|
||||
}
|
||||
if admin.LastLoginAt != nil && *admin.LastLoginAt != 0 {
|
||||
t.Fatalf("отклонённое JSON-тело изменило last_login_at: %d", *admin.LastLoginAt)
|
||||
}
|
||||
|
||||
_, valid := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
|
||||
if valid.Code != constant.CodeSuccess {
|
||||
t.Fatalf("настоящий U+FFFD ошибочно отклонён: %+v", valid)
|
||||
}
|
||||
}
|
||||
|
||||
// Тот же запрос ЗА gin.Recovery: именно так он шёл в бою.
|
||||
//
|
||||
// В RC2 здесь был HTTP 500 — панику валидатора Recovery превращал в системную
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
func Hysteria2Auth(c *gin.Context) {
|
||||
var req dto.Hysteria2AuthDto
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if err := strictBindJSON(c, &req); err != nil {
|
||||
vo.Hysteria2AuthBadRequest(c)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const jsonBodyMaxBytes = 1024 * 1024
|
||||
|
||||
var errJSONBodyTooLarge = errors.New("тело JSON превышает 1 МиБ")
|
||||
|
||||
// strictBindJSON не позволяет стандартному encoding/json молча заменить
|
||||
// повреждённый UTF-8 или непарный UTF-16 surrogate escape на U+FFFD.
|
||||
//
|
||||
// Это критично для секретов: U+FFFD сам по себе разрешён, поэтому без этой
|
||||
// границы другое байтовое значение могло аутентифицироваться как пароль,
|
||||
// который фактически хранится в базе. После проверки тело восстанавливается и
|
||||
// передаётся штатному Gin binder, чтобы сохранить действующий API-контракт.
|
||||
func strictBindJSON(c *gin.Context, dst any) error {
|
||||
body := c.Request.Body
|
||||
if body == nil {
|
||||
return errors.New("пустое тело JSON")
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(body, jsonBodyMaxBytes+1))
|
||||
_ = body.Close()
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("не удалось прочитать тело JSON: %w", err)
|
||||
}
|
||||
if len(raw) > jsonBodyMaxBytes {
|
||||
return errJSONBodyTooLarge
|
||||
}
|
||||
if err := validateStrictJSONEncoding(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.ShouldBindJSON(dst)
|
||||
}
|
||||
|
||||
// validateStrictJSONEncoding проверяет только границу кодировки. Синтаксис,
|
||||
// типы и структуру затем проверяет encoding/json; здесь отдельно закрывается
|
||||
// его документированное replacement-поведение.
|
||||
func validateStrictJSONEncoding(raw []byte) error {
|
||||
if !utf8.Valid(raw) {
|
||||
return errors.New("тело JSON содержит некорректный UTF-8")
|
||||
}
|
||||
|
||||
inString := false
|
||||
for i := 0; i < len(raw); i++ {
|
||||
switch raw[i] {
|
||||
case '"':
|
||||
inString = !inString
|
||||
case '\\':
|
||||
if !inString {
|
||||
continue
|
||||
}
|
||||
i++
|
||||
if i >= len(raw) {
|
||||
return errors.New("незавершённая escape-последовательность JSON")
|
||||
}
|
||||
if raw[i] != 'u' {
|
||||
continue
|
||||
}
|
||||
|
||||
code, ok := parseJSONHex4(raw, i+1)
|
||||
if !ok {
|
||||
return errors.New("некорректная Unicode escape-последовательность JSON")
|
||||
}
|
||||
i += 4
|
||||
switch {
|
||||
case code >= 0xD800 && code <= 0xDBFF:
|
||||
// Старший суррогат допустим только как первая половина пары и
|
||||
// только в соседней escape-последовательности \uXXXX.
|
||||
if i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
|
||||
return errors.New("непарный старший UTF-16 суррогат в JSON")
|
||||
}
|
||||
low, lowOK := parseJSONHex4(raw, i+3)
|
||||
if !lowOK || low < 0xDC00 || low > 0xDFFF {
|
||||
return errors.New("непарный старший UTF-16 суррогат в JSON")
|
||||
}
|
||||
i += 6
|
||||
case code >= 0xDC00 && code <= 0xDFFF:
|
||||
return errors.New("непарный младший UTF-16 суррогат в JSON")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseJSONHex4(raw []byte, start int) (uint16, bool) {
|
||||
if start < 0 || start+4 > len(raw) {
|
||||
return 0, false
|
||||
}
|
||||
var value uint16
|
||||
for _, character := range raw[start : start+4] {
|
||||
value <<= 4
|
||||
switch {
|
||||
case character >= '0' && character <= '9':
|
||||
value += uint16(character - '0')
|
||||
case character >= 'a' && character <= 'f':
|
||||
value += uint16(character-'a') + 10
|
||||
case character >= 'A' && character <= 'F':
|
||||
value += uint16(character-'A') + 10
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package controller
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateStrictJSONEncoding(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
wantErr bool
|
||||
}{
|
||||
{"обычный UTF-8", []byte(`{"password":"пароль"}`), false},
|
||||
{"настоящий U+FFFD", []byte(`{"password":"abcde�"}`), false},
|
||||
{"валидная surrogate pair", []byte(`{"value":"\uD83D\uDE00"}`), false},
|
||||
{"экранированный обратный слеш", []byte(`{"value":"\\uD800"}`), false},
|
||||
{"байт FF", []byte{'{', '"', 'x', '"', ':', '"', 0xFF, '"', '}'}, true},
|
||||
{"оборванный UTF-8", []byte{'{', '"', 'x', '"', ':', '"', 0xC3, '"', '}'}, true},
|
||||
{"UTF-8 суррогата", []byte{'{', '"', 'x', '"', ':', '"', 0xED, 0xA0, 0x80, '"', '}'}, true},
|
||||
{"непарный старший", []byte(`{"value":"\uD800"}`), true},
|
||||
{"старший перед обычным", []byte(`{"value":"\uD800\u0041"}`), true},
|
||||
{"непарный младший", []byte(`{"value":"\uDC00"}`), true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
gotErr := validateStrictJSONEncoding(test.body) != nil
|
||||
if gotErr != test.wantErr {
|
||||
t.Fatalf("ошибка=%v, ожидалась=%v", gotErr, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,10 @@ func ImportPeer(c *gin.Context) {
|
||||
vo.Fail("the file is too big", c)
|
||||
return
|
||||
}
|
||||
if err = validateStrictJSONEncoding(content); err != nil {
|
||||
vo.Fail("content contains invalid UTF-8 or UTF-16", c)
|
||||
return
|
||||
}
|
||||
|
||||
var peerExports []bo.PeerExport
|
||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// Разбор загруженного файла проверяется без базы: все три отказа наступают до
|
||||
// первого обращения к слою данных, и это часть контракта — испорченный файл не
|
||||
// должен доходить до транзакции.
|
||||
func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
||||
func postImportBytes(t *testing.T, fileName string, content []byte) apiResult {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -27,7 +27,7 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось собрать форму: %v", err)
|
||||
}
|
||||
if _, err := part.Write([]byte(content)); err != nil {
|
||||
if _, err := part.Write(content); err != nil {
|
||||
t.Fatalf("не удалось записать файл в форму: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
@@ -49,6 +49,24 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
||||
t.Helper()
|
||||
return postImportBytes(t, fileName, []byte(content))
|
||||
}
|
||||
|
||||
func TestImportPeerRejectsInvalidUTF8(t *testing.T) {
|
||||
for _, content := range [][]byte{
|
||||
{'[', '"', 0xFF, '"', ']'},
|
||||
{'[', '"', 0xC3, '"', ']'},
|
||||
{'[', '"', 0xED, 0xA0, 0x80, '"', ']'},
|
||||
} {
|
||||
result := postImportBytes(t, "peers.json", content)
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("повреждённый UTF-8 принят: %+v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия: json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл с
|
||||
// хвостом принимался целиком, оператор видел «импорт выполнен», а вторая
|
||||
// половина файла молча не применялась.
|
||||
|
||||
@@ -109,7 +109,7 @@ func validateField[T interface{}](c *gin.Context, field T) (T, error) {
|
||||
case http.MethodGet:
|
||||
bindErr = c.ShouldBindQuery(&field)
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||
bindErr = c.ShouldBindJSON(&field)
|
||||
bindErr = strictBindJSON(c, &field)
|
||||
}
|
||||
if bindErr != nil {
|
||||
vo.FailValidation(
|
||||
@@ -246,7 +246,7 @@ func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
|
||||
"maxBytes": fmt.Sprintf("%d", credential.AdminPasswordMaxBytes),
|
||||
}
|
||||
described.Message = fmt.Sprintf(
|
||||
"поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов",
|
||||
"поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF",
|
||||
field,
|
||||
credential.AdminPasswordMinLength,
|
||||
credential.AdminPasswordMaxLength,
|
||||
|
||||
@@ -131,10 +131,9 @@ func IsValidAdminUsername(username string) bool {
|
||||
// же класс дефекта, ради уничтожения которого контракт и существует, только на
|
||||
// один слой ниже.
|
||||
//
|
||||
// U+FEFF в этот список НЕ входит: `0xFEFF & 0xFFFE` — это `0xFEFE`, и systemd
|
||||
// такое значение принимает (комментарий `/* BOM */` в его исходнике относится к
|
||||
// U+xFFFE и является неточностью самого systemd). Запрет U+FEFF — отдельное
|
||||
// продуктовое решение, см. hasForbiddenRune.
|
||||
// Публичная документация systemd также запрещает U+FEFF. Реализация v257.13
|
||||
// случайно пропускает его из-за маски в `unichar_is_valid`; продукт следует
|
||||
// документированному контракту, а не этому расхождению реализации.
|
||||
func isUnicodeNoncharacter(r rune) bool {
|
||||
if r >= 0xFDD0 && r <= 0xFDEF {
|
||||
return true
|
||||
@@ -144,8 +143,7 @@ func isUnicodeNoncharacter(r rune) bool {
|
||||
|
||||
// IsEnvTransportableText сообщает, переживёт ли значение файл окружения.
|
||||
//
|
||||
// Это ДОМЕН systemd, а не политика HY2XS: здесь перечислено ровно то, что
|
||||
// `utf8_is_valid` отвергает, и ничего сверх того. Продуктовые ограничения
|
||||
// Это документированный ДОМЕН systemd, а не политика HY2XS. Продуктовые ограничения
|
||||
// (границы длины, запрет управляющих символов) живут отдельно — иначе
|
||||
// невозможно объяснить, какое из двух правил нарушено и чем это грозит.
|
||||
func IsEnvTransportableText(value string) bool {
|
||||
@@ -159,7 +157,7 @@ func IsEnvTransportableText(value string) bool {
|
||||
for _, r := range value {
|
||||
// NUL — валидный UTF-8, но границей строки в C; systemd отвергает его
|
||||
// отдельной веткой (`embedded NUL`).
|
||||
if r == 0 {
|
||||
if r == 0 || r == 0xFEFF {
|
||||
return false
|
||||
}
|
||||
if isUnicodeNoncharacter(r) {
|
||||
@@ -178,7 +176,7 @@ func IsEnvTransportableText(value string) bool {
|
||||
// (IsEnvTransportableText);
|
||||
// 6-64 code points — граница, которую видит оператор;
|
||||
// <= 72 UTF-8 bytes — граница, которую ставит bcrypt;
|
||||
// Cc и U+FEFF — продуктовая политика, см. hasForbiddenRune.
|
||||
// Cc — продуктовая политика, см. hasForbiddenRune.
|
||||
//
|
||||
// Длина в СИМВОЛАХ, а не в байтах, потому что именно так её считает
|
||||
// go-playground/validator (`utf8.RuneCountInString` в baked_in.go) и так её
|
||||
@@ -226,22 +224,13 @@ func IsValidAdminPassword(password string) bool {
|
||||
// символов» — то есть документация была шире кода ровно на C1. Проверяется
|
||||
// теперь то, что обещано.
|
||||
//
|
||||
// И отдельно U+FEFF. Формат его несёт: `0xFEFF & 0xFFFE` — это `0xFEFE`, и
|
||||
// `unichar_is_valid` такое значение ПРИНИМАЕТ (комментарий `/* BOM */` в
|
||||
// исходнике systemd относится к U+xFFFE и является его собственной
|
||||
// неточностью). Запрет здесь — решение продукта: невидимый символ нулевой
|
||||
// ширины в пароле нельзя ни увидеть в поле ввода, ни повторить при следующем
|
||||
// входе, то есть он умеет ровно одно — запереть оператора снаружи. Ровно тем же
|
||||
// обоснованием запрещены и управляющие символы: перевод строки невозможно
|
||||
// ввести в однострочное поле формы входа.
|
||||
// U+FEFF здесь уже не проверяется: он принадлежит документированному домену
|
||||
// EnvironmentFile и отвергается IsEnvTransportableText.
|
||||
func hasForbiddenRune(value string) bool {
|
||||
for _, r := range value {
|
||||
if r < 0x20 || (r >= 0x7F && r <= 0x9F) {
|
||||
return true
|
||||
}
|
||||
if r == 0xFEFF {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -146,8 +146,7 @@ func TestAdminPasswordBoundaries(t *testing.T) {
|
||||
{"U+FDF0 рядом с диапазоном", "abcde" + string(rune(0xFDF0)), true},
|
||||
{"U+FFFD (replacement)", "abcde" + string(rune(0xFFFD)), true},
|
||||
|
||||
// U+FEFF формат несёт (0xFEFF & 0xFFFE == 0xFEFE), и отвергается он
|
||||
// продуктовой политикой, а не systemd.
|
||||
// U+FEFF отвергается документированным транспортным доменом systemd.
|
||||
{"U+FEFF", "abcde" + string(rune(0xFEFF)), false},
|
||||
}
|
||||
|
||||
@@ -187,18 +186,18 @@ func TestAdminPasswordLimitsAreIndependent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Домен транспорта повторяет множество systemd ТОЧНО — не шире и не уже.
|
||||
// Домен транспорта повторяет публично документированное множество systemd.
|
||||
//
|
||||
// Шире означало бы, что HY2XS отвергает значение, которое systemd загрузил бы;
|
||||
// уже — что значение проходит все двери продукта и делает файл окружения
|
||||
// незагружаемым, а юнит — не стартующим. Второе и было дефектом: контракт знал
|
||||
// про управляющие символы и не знал про noncharacters.
|
||||
// Шире означало бы собственный запрет сверх публичного контракта; уже — что
|
||||
// значение проходит все двери продукта вопреки документации EnvironmentFile.
|
||||
// Второе и было дефектом: контракт знал про управляющие символы и не знал про
|
||||
// noncharacters.
|
||||
//
|
||||
// Границы взяты из `unichar_is_valid` (systemd, src/basic/utf8.c) и проверяются
|
||||
// вместе с соседями: правило обязано отвергать ровно диапазон, а не окрестность.
|
||||
func TestEnvTransportDomainMatchesSystemd(t *testing.T) {
|
||||
func TestEnvTransportDomainMatchesDocumentedSystemdContract(t *testing.T) {
|
||||
rejected := []rune{
|
||||
0x0000, // embedded NUL — отдельная ветка utf8_is_valid
|
||||
0x0000, 0xFEFF, // NUL и BOM запрещены публичным контрактом
|
||||
0xFDD0, 0xFDEF, // U+FDD0..U+FDEF
|
||||
0xFFFE, 0xFFFF,
|
||||
0x1FFFE, 0x1FFFF,
|
||||
@@ -206,21 +205,20 @@ func TestEnvTransportDomainMatchesSystemd(t *testing.T) {
|
||||
}
|
||||
for _, r := range rejected {
|
||||
if IsEnvTransportableText(string(r)) {
|
||||
t.Errorf("U+%04X принят: systemd откажется загружать такой EnvironmentFile", r)
|
||||
t.Errorf("U+%04X принят вопреки документированному контракту EnvironmentFile", r)
|
||||
}
|
||||
}
|
||||
|
||||
accepted := []rune{
|
||||
0x0020, 0x007F, 0x0085, // управляющие формат НЕСЁТ; их запрещает политика продукта
|
||||
0xFDCF, 0xFDF0, // соседи зарезервированного диапазона
|
||||
0xFEFF, // 0xFEFF & 0xFFFE == 0xFEFE — systemd принимает
|
||||
0xFFFD, // replacement character — обычный символ
|
||||
0x10FFFD, // последний scalar value, не noncharacter
|
||||
0x1F600, // 😀
|
||||
}
|
||||
for _, r := range accepted {
|
||||
if !IsEnvTransportableText(string(r)) {
|
||||
t.Errorf("U+%04X отвергнут: правило шире множества systemd", r)
|
||||
t.Errorf("U+%04X отвергнут: правило шире документированного множества systemd", r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +237,7 @@ func TestEnvTransportDomainMatchesSystemd(t *testing.T) {
|
||||
// одно из двух правил отсутствует.
|
||||
func TestProductPolicyIsWiderThanTransportDomain(t *testing.T) {
|
||||
// Управляющие символы формат несёт — их запрещает HY2XS.
|
||||
for _, r := range []rune{0x0A, 0x0D, 0x09, 0x7F, 0x85, 0x9F, 0xFEFF} {
|
||||
for _, r := range []rune{0x0A, 0x0D, 0x09, 0x7F, 0x85, 0x9F} {
|
||||
password := "abcde" + string(r)
|
||||
if !IsEnvTransportableText(password) && r != 0x00 {
|
||||
t.Errorf("U+%04X: ожидалось, что формат его несёт", r)
|
||||
@@ -248,6 +246,9 @@ func TestProductPolicyIsWiderThanTransportDomain(t *testing.T) {
|
||||
t.Errorf("U+%04X принят паролем вопреки политике продукта", r)
|
||||
}
|
||||
}
|
||||
if IsEnvTransportableText("abcde" + string(rune(0xFEFF))) {
|
||||
t.Error("U+FEFF принят вопреки документированному контракту EnvironmentFile")
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль НЕ триммится нигде, включая сам контракт.
|
||||
|
||||
+1
-1
@@ -163,7 +163,7 @@ func ensureSecureBootstrapAdmin() error {
|
||||
if !credential.IsValidAdminPassword(adminPassword) {
|
||||
return fmt.Errorf(
|
||||
"HY2XS_ADMIN_INITIAL_PASSWORD не удовлетворяет контракту панели: "+
|
||||
"от %d до %d символов, не более %d байт в UTF-8, без управляющих символов.\n"+
|
||||
"от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF.\n"+
|
||||
"Набор символов не ограничен, пробелы по краям являются частью пароля.\n"+
|
||||
"Учётная запись администратора с таким значением создана НЕ будет: "+
|
||||
"установка завершилась бы успешно, а войти в панель было бы нельзя.\n"+
|
||||
|
||||
@@ -82,8 +82,9 @@ export const ADMIN_PASSWORD_MAX_BYTES = 72;
|
||||
* `/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`, и
|
||||
* перед принятием пары прогоняет значение через `utf8_is_valid`. Отказ там —
|
||||
* это не предупреждение, а незагруженный файл окружения и, следовательно,
|
||||
* юнит, который не стартует. Отвергаются NUL, суррогаты, `U+FDD0..U+FDEF` и все
|
||||
* code points вида `*FFFE`/`*FFFF`. Панель обязана знать это множество: иначе
|
||||
* юнит, который не стартует. Публичный контракт отвергает NUL, U+FEFF,
|
||||
* суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`. Реализация
|
||||
* v257.13 случайно пропускает U+FEFF; панель следует документации. Иначе
|
||||
* она предложила бы оператору назначить пароль, после которого сервер
|
||||
* перестанет подниматься.
|
||||
*
|
||||
@@ -93,7 +94,7 @@ export const ADMIN_PASSWORD_MAX_BYTES = 72;
|
||||
* не отказ.
|
||||
*
|
||||
* Второе — продуктовая политика: управляющие символы Unicode целиком (Cc — C0,
|
||||
* DEL, C1) и U+FEFF. Формат их несёт, запрещает их HY2XS: ни один из них
|
||||
* DEL, C1). U+FEFF запрещён документированным транспортным доменом. Ни один из них
|
||||
* невозможно ни увидеть в поле ввода, ни повторить при следующем входе, то есть
|
||||
* они умеют ровно одно — запереть оператора снаружи.
|
||||
*
|
||||
@@ -109,6 +110,7 @@ export function isValidAdminPassword(value: string): boolean {
|
||||
// Домен systemd: значение вне него не доедет до сервера вовсе.
|
||||
if (
|
||||
code === 0 ||
|
||||
code === 0xfeff ||
|
||||
(code >= 0xd800 && code <= 0xdfff) ||
|
||||
(code >= 0xfdd0 && code <= 0xfdef) ||
|
||||
(code & 0xfffe) === 0xfffe
|
||||
@@ -116,8 +118,8 @@ export function isValidAdminPassword(value: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Политика HY2XS: Cc и U+FEFF.
|
||||
if (code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0xfeff) {
|
||||
// Политика HY2XS: Cc. U+FEFF уже отвергнут транспортной веткой.
|
||||
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export default {
|
||||
credentials: {
|
||||
usernameFormat: "Username: {min} to {max} characters from {charset}",
|
||||
passwordFormat:
|
||||
"Password: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters",
|
||||
"Password: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters or U+FEFF",
|
||||
},
|
||||
dashboard: {
|
||||
stale: "Dashboard data is stale. Retrying automatically...",
|
||||
@@ -168,7 +168,7 @@ export default {
|
||||
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
|
||||
credential_format: "“{field}”: {min} to {max} characters from {charset}",
|
||||
admin_password_format:
|
||||
"“{field}”: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters",
|
||||
"“{field}”: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters or U+FEFF",
|
||||
rule_violated: "“{field}”: value is not acceptable",
|
||||
validation_failed: "Validation failed",
|
||||
body_invalid:
|
||||
|
||||
@@ -36,7 +36,7 @@ export default {
|
||||
// байта; сообщение «не длиннее {max} символов» отправило бы оператора
|
||||
// сокращать пароль, отвергнутый не за это.
|
||||
passwordFormat:
|
||||
"Пароль: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов",
|
||||
"Пароль: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF",
|
||||
},
|
||||
dashboard: {
|
||||
stale:
|
||||
@@ -189,7 +189,7 @@ export default {
|
||||
credential_format:
|
||||
"«{field}»: от {min} до {max} символов из набора {charset}",
|
||||
admin_password_format:
|
||||
"«{field}»: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов",
|
||||
"«{field}»: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF",
|
||||
rule_violated: "«{field}»: значение не подходит",
|
||||
validation_failed: "Проверка данных не пройдена",
|
||||
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
|
||||
|
||||
@@ -59,7 +59,7 @@ func HashPassword(password string) (string, error) {
|
||||
if !credential.IsValidAdminPassword(password) {
|
||||
return "", fmt.Errorf(
|
||||
"пароль администратора не удовлетворяет контракту: от %d до %d символов, "+
|
||||
"не более %d байт в UTF-8, без управляющих символов",
|
||||
"не более %d байт в UTF-8, без управляющих символов и U+FEFF",
|
||||
credential.AdminPasswordMinLength,
|
||||
credential.AdminPasswordMaxLength,
|
||||
credential.AdminPasswordMaxBytes,
|
||||
|
||||
Reference in New Issue
Block a user