Обрезка пробелов и невидимые символы в пароле

При работе с библиотеками хеширования паролей в 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";

Zero-width символы

Невидимые 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".


Как Password-hash работает со строками

Большинство JavaScript-библиотек хеширования:

  • bcrypt
  • bcryptjs
  • argon2
  • crypto
  • password-hash

не удаляют пробелы автоматически.

Библиотека получает строку в исходном виде:

hash("password ");

и создаёт хеш именно для строки с пробелом.


Опасность автоматической обрезки пробелов

Потеря валидных паролей

Некоторые пользователи намеренно используют пробелы:

"correct horse battery staple "

Автоматический trim() изменит пароль.


Несовместимость старых аккаунтов

Если раньше пароль сохранялся без обрезки, а затем появилась нормализация:

password = password.trim();

старые пользователи перестанут входить в систему.


Разное поведение клиента и сервера

Опасная ситуация:

// frontend
password.trim()

// backend
без trim()

или наоборот.

В итоге:

  • хешируется одна строка;
  • проверяется другая.

Когда допустимо использовать trim()

Обрезка пробелов допустима только при заранее определённой политике проекта.

Например:

  • пароль не должен начинаться с пробела;
  • пароль не должен заканчиваться пробелом;
  • система документирует это поведение.

Пример:

function normalizePassword(password) {
    return password.trim();
}

Почему trim() не решает проблему полностью

Метод trim() удаляет только пробельные символы по краям строки.

Он не удаляет:

  • невидимые символы внутри строки;
  • zero-width символы;
  • некоторые Unicode-разделители.

Пример:

const password = "admin\u200B123";

console.log(password.trim() === password);
// true

Проверка невидимых символов

Поиск zero-width символов

function containsInvisibleChars(password) {
    return /[\u200B-\u200D\uFEFF]/.test(password);
}

Проверка всех управляющих символов

function containsControlChars(password) {
    return /[\x00-\x1F\x7F]/.test(password);
}

Полная проверка проблемных Unicode-символов

function containsUnsafeUnicode(password) {
    return /[\u200B-\u200D\uFEFF\u00A0]/.test(password);
}

Безопасная стратегия обработки паролей

На практике используются три основных подхода.


Подход 1. Не изменять пароль вообще

Наиболее безопасный криптографически вариант.

const hashValue = hash(password);

Преимущества:

  • отсутствует скрытая модификация;
  • сохраняется точность;
  • поведение прозрачно.

Недостатки:

  • пользователи могут случайно вводить невидимые символы.

Подход 2. Валидация без модификации

Пароль не изменяется, но запрещаются опасные символы.

function validatePassword(password) {
    if (/[\u200B-\u200D\uFEFF]/.test(password)) {
        throw new Error("Недопустимые символы");
    }
}

Это наиболее распространённый подход.


Подход 3. Полная нормализация

Иногда применяется в корпоративных системах.

function normalizePassword(password) {
    return password
        .trim()
        .normalize("NFKC");
}

Подходит только при строгом контроле всей инфраструктуры.


Unicode-нормализация

Проблема одинаковых символов

Unicode позволяет записывать визуально одинаковые символы разными способами.

Пример:

const a = "é";
const b = "e\u0301";

console.log(a === b); // false

Для хеширования это разные строки.


Использование normalize()

const normalized = password.normalize("NFKC");

Форматы:

Метод Назначение
NFC Каноническая композиция
NFD Каноническая декомпозиция
NFKC Совместимая композиция
NFKD Совместимая декомпозиция

Для паролей чаще применяется:

.normalize("NFKC")

Риски Unicode-нормализации

Нормализация тоже изменяет пароль.

Например:

"Ⅳ" → "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);
}

Критически важно:

  • использовать одинаковую обработку;
  • не изменять пароль между регистрацией и логином;
  • не применять разные правила на frontend и backend.

Ошибка двойной нормализации

Опасный код:

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.

Особенно часто:

  • из мессенджеров;
  • PDF-файлов;
  • Word-документов;
  • email-клиентов.

Пример очистки:

function sanitizeClipboardInput(password) {
    return password.replace(/[\u200B-\u200D\uFEFF]/g, "");
}

Ограничение длины после нормализации

Нормализация может изменить длину строки.

Проверка должна выполняться после обработки:

const normalized = password.normalize("NFKC");

if (normalized.length > 128) {
    throw new Error("Слишком длинный пароль");
}

Влияние на bcrypt

Алгоритм 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);
}