Потеря кодировки при передаче через HTTP

При работе с библиотеками хеширования паролей в JavaScript проблема повреждения кодировки возникает значительно чаще, чем ошибки самого алгоритма хеширования. Наиболее опасная ситуация — когда пароль пользователя доходит до функции hash() уже в изменённом виде.

Даже один неверно интерпретированный байт полностью меняет итоговый хеш:

password123

и

password123�

создают совершенно разные значения.

Особенно критична ситуация для:

  • Unicode-символов;
  • кириллицы;
  • emoji;
  • составных UTF-8 символов;
  • символов с диакритикой;
  • смешанных кодировок.

Как выглядит проблема

Типичный сценарий:

  1. Пользователь вводит пароль:

    Привет123!
  2. Браузер отправляет строку через HTTP.

  3. Сервер интерпретирует тело запроса не как UTF-8.

  4. Вместо оригинальной строки получается:

    Привет123!
  5. Библиотека password-hash создаёт хеш уже от повреждённой строки.

  6. При следующем входе пароль не совпадает.


Особенности библиотеки Password-hash

Библиотека:

password-hash

работает со строками JavaScript, а строки JavaScript внутри движка представлены в UTF-16.

Пример:

const passwordHash = require('password-hash');

const hash = passwordHash.generate('Привет123');

Если строка уже испорчена до передачи в generate(), библиотека не способна определить это.


Где именно теряется кодировка

Ошибки Content-Type

Самая распространённая причина — отсутствие charset.

Неправильно:

Content-Type: application/json

Правильно:

Content-Type: application/json; charset=utf-8

Без указания UTF-8 сервер или промежуточный прокси могут интерпретировать данные в другой кодировке.


Неправильный body-parser

В старых приложениях Node.js часто используется неверная настройка middleware.

Проблемный пример:

app.use(express.urlencoded());

Корректнее:

app.use(express.urlencoded({
    extended: true
}));

И отдельно:

app.use(express.json());

Потеря кодировки в Buffer

Ошибка возникает при ручной работе с байтами.

Проблемный код:

const data = buffer.toString();

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

Безопаснее явно указывать UTF-8:

const data = buffer.toString('utf8');

Влияние Unicode на хеширование

Один символ — разные байты

Символ:

é

может существовать в двух формах:

NFC

Один Unicode-символ:

'é'

NFD

Буква + модификатор:

'é'

Визуально строки одинаковы, но бинарно различаются.

Проверка:

console.log('é' === 'é');

Результат:

false

Хеши также будут разными.


Нормализация Unicode

Перед хешированием рекомендуется нормализовать строки.

Пример:

const normalized = password.normalize('NFC');

Полный вариант:

const passwordHash = require('password-hash');

function createHash(password) {
    const normalized = password.normalize('NFC');

    return passwordHash.generate(normalized);
}

Проблемы JSON-кодирования

Повреждение escape-последовательностей

Некоторые прокси или старые backend-системы преобразуют Unicode:

Было:

{
    "password": "Привет"
}

Стало:

{
    "password": "\u041f\u0440\u0438\u0432\u0435\u0442"
}

Само по себе это допустимо.

Проблема начинается, когда строка декодируется дважды.


Двойное декодирование

Ошибка:

JSON.parse(JSON.parse(data));

или:

decodeURIComponent(decodedString);

выполняется повторно.

В результате символы ломаются.


Ошибки URL Encoding

encodeURIComponent()

При передаче паролей через query string возникает множество проблем.

Пример:

const encoded = encodeURIComponent(password);

Результат:

%D0%9F%D1%80%D0%B8%D0%B2%D0%B5%D1%82

Если сервер не выполняет корректное декодирование UTF-8, строка повреждается.


Почему нельзя передавать пароль в URL

Небезопасный пример:

GET /login?password=Привет123

Проблемы:

  • логирование URL;
  • кэширование;
  • разные кодировки;
  • ограничение длины;
  • проблемы reverse proxy;
  • утечка через историю браузера.

Пароли должны передаваться только в теле POST-запроса.


Влияние различных платформ

Windows-1251 против UTF-8

Старые системы Windows могут использовать:

cp1251

вместо:

utf-8

Тогда:

Привет

превращается в:

Ïðèâåò

Для библиотеки хеширования это уже другая строка.


Проверка реальных байтов

При диагностике полезно смотреть бинарные данные.

Пример:

const buffer = Buffer.from(password, 'utf8');

console.log(buffer);

Результат:

<Buffer d0 9f d1 80 d0 b8 ...>

Сравнение повреждённых строк

Пример диагностики:

const original = 'Привет';
const broken = 'Привет';

console.log(Buffer.from(original));
console.log(Buffer.from(broken));

Разница сразу станет заметна.


Проблемы multipart/form-data

При загрузке форм браузеры иногда используют разные charset.

Особенно это касается:

  • старых мобильных браузеров;
  • устаревших Java backend;
  • legacy PHP;
  • multipart parser без UTF-8.

Безопасная схема передачи пароля

Клиент

fetch('/login', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json; charset=utf-8'
    },
    body: JSON.stringify({
        password
    })
});

Сервер

app.use(express.json());

app.post('/login', (req, res) => {
    const password = req.body.password.normalize('NFC');

    const hash = passwordHash.generate(password);

    res.send(hash);
});

Проверка корректности кодировки

Валидация UTF-8

Пример проверки:

function isValidUTF8(str) {
    try {
        return Buffer.from(str, 'utf8').toString('utf8') === str;
    } catch {
        return false;
    }
}

Проблемы reverse proxy

Некоторые прокси:

  • изменяют заголовки;
  • удаляют charset;
  • перекодируют body;
  • меняют transfer encoding.

Особенно часто это происходит в:

  • старых Nginx-конфигурациях;
  • legacy Apache;
  • IIS;
  • корпоративных gateway.

Потеря кодировки в логировании

Некоторые системы логирования:

console.log(password);

сохраняют данные в ANSI-кодировке.

При последующем анализе создаётся ложное впечатление, что пароль был испорчен ещё до сервера.


Почему bcrypt и password-hash реагируют одинаково

Любая библиотека хеширования работает с байтами.

Неважно, используется ли:

  • bcrypt;
  • scrypt;
  • argon2;
  • password-hash;
  • PBKDF2.

Изменение хотя бы одного байта создаёт полностью другой хеш.


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

Иногда проблема выявляется через длину.

Пример:

console.log(password.length);

Повреждённая UTF-8 строка часто становится длиннее.


Диагностика через hex

Полезный метод:

const hex = Buffer
    .from(password, 'utf8')
    .toString('hex');

console.log(hex);

Пример:

d09fd180d0b8d0b2d0b5d182

Использование Base64

Иногда пароль временно кодируют в Base64 для безопасной транспортировки.

Пример:

const encoded = Buffer
    .from(password, 'utf8')
    .toString('base64');

Декодирование:

const decoded = Buffer
    .from(encoded, 'base64')
    .toString('utf8');

Однако это не замена HTTPS и не метод защиты пароля.


Проверка перед хешированием

Безопасный подход:

function preparePassword(password) {
    if (typeof password !== 'string') {
        throw new Error('Invalid password type');
    }

    return password.normalize('NFC');
}

Полный безопасный пример

const express = require('express');
const passwordHash = require('password-hash');

const app = express();

app.use(express.json());

function preparePassword(password) {
    if (typeof password !== 'string') {
        throw new Error('Password must be string');
    }

    return password.normalize('NFC');
}

app.post('/register', (req, res) => {
    try {
        const password = preparePassword(req.body.password);

        const hash = passwordHash.generate(password);

        res.json({
            success: true,
            hash
        });

    } catch (err) {
        res.status(400).json({
            error: err.message
        });
    }
});

Типичные симптомы проблемы

Хеш меняется при одинаковом пароле

Причина:

  • разные кодировки;
  • разная нормализация Unicode;
  • повреждение proxy;
  • повторное декодирование.

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

Часто связано с:

  • мобильными устройствами;
  • локальными настройками ОС;
  • старыми браузерами;
  • национальными раскладками;
  • нестандартными символами.

Символы повышенного риска

Наиболее проблемные категории:

  • кириллица;
  • emoji;
  • китайские иероглифы;
  • арабская вязь;
  • комбинированные Unicode-символы;
  • символы с диакритикой.

Почему ASCII почти никогда не ломается

Символы:

a-z
A-Z
0-9

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

Поэтому проблема часто проявляется только у пользователей с неанглийскими паролями.


Рекомендации для production-систем

Обязательные меры

  • использовать UTF-8 везде;
  • явно задавать charset;
  • нормализовать Unicode;
  • не передавать пароли в URL;
  • использовать HTTPS;
  • проверять middleware;
  • избегать ручной работы с Buffer без указания кодировки;
  • тестировать кириллицу и emoji;
  • логировать hex-представление при диагностике.

Тестирование Unicode-паролей

Пример тестового набора:

const passwords = [
    'Привет123',
    '密码123',
    'пароль?',
    'éèêë',
    'مرحبا123'
];

Проверка:

passwords.forEach(password => {
    const hash = passwordHash.generate(
        password.normalize('NFC')
    );

    console.log(password, hash);
});

Почему проблема особенно опасна

Пользователь уверен, что вводит правильный пароль.

Система тоже корректно сравнивает хеши.

Ошибка находится между ними — на уровне передачи и интерпретации байтов.

Из-за этого диагностика может занимать часы или даже дни, особенно в распределённых системах с несколькими proxy, CDN, балансировщиками и микросервисами.