При работе с библиотеками хеширования паролей в JavaScript одна из самых недооценённых проблем — наличие лишних пробелов и невидимых Unicode-символов. Пользователь визуально вводит один пароль, а система фактически получает другую строку. В результате возникают:
Даже один невидимый символ способен полностью изменить итоговый хеш.
const password1 = "secret123";
const password2 = "secret123 ";
console.log(password1 === password2); // false
После хеширования эти строки создадут совершенно разные значения.
Наиболее распространённый случай:
"password"
"password "
" password"
Визуально различия почти незаметны.
Символ \t:
const password = "admin\t123";
Пользователь может случайно вставить табуляцию при копировании пароля.
Символы:
\n\r\r\nОсобенно часто появляются при вставке из буфера обмена.
const password = "qwerty\n";
Unicode-символ:
U+00A0
Внешне выглядит как обычный пробел, но отличается бинарно.
const password = "test 123";
Невидимые Unicode-символы:
| Символ | Код |
|---|---|
| Zero Width Space | U+200B |
| Zero Width Non-Joiner | U+200C |
| Zero Width Joiner | U+200D |
| Byte Order Mark | U+FEFF |
Пример:
const password = "admin\u200B123";
Строка визуально выглядит как "admin123".
Большинство JavaScript-библиотек хеширования:
bcryptbcryptjsargon2cryptopassword-hashне удаляют пробелы автоматически.
Библиотека получает строку в исходном виде:
hash("password ");
и создаёт хеш именно для строки с пробелом.
Некоторые пользователи намеренно используют пробелы:
"correct horse battery staple "
Автоматический trim() изменит пароль.
Если раньше пароль сохранялся без обрезки, а затем появилась нормализация:
password = password.trim();
старые пользователи перестанут входить в систему.
Опасная ситуация:
// frontend
password.trim()
// backend
без trim()
или наоборот.
В итоге:
Обрезка пробелов допустима только при заранее определённой политике проекта.
Например:
Пример:
function normalizePassword(password) {
return password.trim();
}
Метод trim() удаляет только пробельные символы по краям
строки.
Он не удаляет:
Пример:
const password = "admin\u200B123";
console.log(password.trim() === password);
// true
function containsInvisibleChars(password) {
return /[\u200B-\u200D\uFEFF]/.test(password);
}
function containsControlChars(password) {
return /[\x00-\x1F\x7F]/.test(password);
}
function containsUnsafeUnicode(password) {
return /[\u200B-\u200D\uFEFF\u00A0]/.test(password);
}
На практике используются три основных подхода.
Наиболее безопасный криптографически вариант.
const hashValue = hash(password);
Преимущества:
Недостатки:
Пароль не изменяется, но запрещаются опасные символы.
function validatePassword(password) {
if (/[\u200B-\u200D\uFEFF]/.test(password)) {
throw new Error("Недопустимые символы");
}
}
Это наиболее распространённый подход.
Иногда применяется в корпоративных системах.
function normalizePassword(password) {
return password
.trim()
.normalize("NFKC");
}
Подходит только при строгом контроле всей инфраструктуры.
Unicode позволяет записывать визуально одинаковые символы разными способами.
Пример:
const a = "é";
const b = "e\u0301";
console.log(a === b); // false
Для хеширования это разные строки.
const normalized = password.normalize("NFKC");
Форматы:
| Метод | Назначение |
|---|---|
| NFC | Каноническая композиция |
| NFD | Каноническая декомпозиция |
| NFKC | Совместимая композиция |
| NFKD | Совместимая декомпозиция |
Для паролей чаще применяется:
.normalize("NFKC")
Нормализация тоже изменяет пароль.
Например:
"Ⅳ" → "IV"
Поэтому изменение политики нормализации после запуска проекта крайне опасно.
import passwordHash from "password-hash";
function validatePassword(password) {
if (/[\u200B-\u200D\uFEFF]/.test(password)) {
throw new Error("Пароль содержит невидимые символы");
}
if (password.length < 8) {
throw new Error("Слишком короткий пароль");
}
}
function register(password) {
validatePassword(password);
return passwordHash.generate(password);
}
import passwordHash from "password-hash";
function login(password, storedHash) {
return passwordHash.verify(password, storedHash);
}
Критически важно:
Опасный код:
const normalized = password.trim().normalize("NFKC");
Если позже:
password.trim();
будет применён повторно в другом месте системы, поведение может стать непредсказуемым.
Правильная архитектура:
function preparePassword(password) {
return password.normalize("NFKC");
}
Использование:
const prepared = preparePassword(password);
Все операции должны проходить через один модуль.
Нельзя логировать пароль полностью.
Неправильно:
console.log(password);
Правильно:
console.log(password.length);
или:
console.log([...password].map(c => c.charCodeAt(0)));
Только в защищённой debug-среде.
Многие невидимые символы попадают из clipboard.
Особенно часто:
Пример очистки:
function sanitizeClipboardInput(password) {
return password.replace(/[\u200B-\u200D\uFEFF]/g, "");
}
Нормализация может изменить длину строки.
Проверка должна выполняться после обработки:
const normalized = password.normalize("NFKC");
if (normalized.length > 128) {
throw new Error("Слишком длинный пароль");
}
Алгоритм bcrypt учитывает только первые 72 байта.
Невидимые Unicode-символы могут неожиданно увеличить размер.
Buffer.byteLength(password, "utf8");
Пример:
const bytes = Buffer.byteLength(password, "utf8");
if (bytes > 72) {
throw new Error("Пароль слишком длинный для bcrypt");
}
Полезный инструмент диагностики:
function inspectPassword(password) {
return [...password].map(char => ({
char,
code: char.codePointAt(0).toString(16)
}));
}
Результат:
[
{ char: 'a', code: '61' },
{ char: '', code: '200b' }
]
В production-системах необходимо заранее определить:
| Правило | Решение |
|---|---|
| Удалять пробелы по краям | Да/Нет |
| Разрешать Unicode | Да/Нет |
| Выполнять normalize() | Да/Нет |
| Запрещать invisible chars | Да/Нет |
| Использовать одинаковую обработку везде | Обязательно |
Изменение этих правил после запуска системы может привести к массовым проблемам авторизации.
import passwordHash from "password-hash";
function preparePassword(password) {
if (typeof password !== "string") {
throw new Error("Некорректный тип");
}
const normalized = password.normalize("NFKC");
if (/[\u200B-\u200D\uFEFF]/.test(normalized)) {
throw new Error("Обнаружены невидимые символы");
}
if (Buffer.byteLength(normalized, "utf8") > 72) {
throw new Error("Пароль слишком длинный");
}
return normalized;
}
export function createHash(password) {
const prepared = preparePassword(password);
return passwordHash.generate(prepared);
}
export function verifyPassword(password, hash) {
const prepared = preparePassword(password);
return passwordHash.verify(prepared, hash);
}