Нюансы base64-кодирования строк в JavaScript

Источник: «The nuances of base64 encoding strings in JavaScript»
Кодирование и декодирование base64 — это распространённая форма преобразования двоичного содержимого для представления его в виде безопасного для веб-приложений текста. Она широко используется для URL данных, таких как встроенные изображения.

Что происходит, когда вы применяете кодирование и декодирование base64 к строкам в JavaScript? В этой заметке рассматриваются нюансы и типичные "подводные камни", которых следует избегать.

btoa() и atob()

Основными функциями для кодирования и декодирования base64 в JavaScript являются btoa() и atob(). btoa() преобразует строку в base64-кодированную строку, а atob() декодирует обратно.

Далее приведён быстрый пример:

// Действительно простая строка, состоящая только из кодовых точек меньше 128.
const asciiString = 'hello';

// Это будет работать. Она напечатает:
// Encoded string: [aGVsbG8=]
const asciiStringEncoded = btoa(asciiString);
console.log(`Encoded string: [${asciiStringEncoded}]`);

// Это будет работать. Она напечатает:
// Decoded string: [hello]
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`Decoded string: [${asciiStringDecoded}]`);

К сожалению, как отмечается в документации MDN, это работает только со строками, содержащими символы ASCII, или символы, которые могут быть представлены одним байтом. Другими словами, это не работает с Unicode.

Чтобы посмотреть, что произойдёт, попробуйте выполнить следующий код:

// Образец строки, представляющий собой комбинацию малых, средних и больших кодовых точек.
// Данный образец строки имеет валидный формат UTF-16.
// 'hello' имеет кодовые точки, каждая из которых меньше 128.
// '⛳' - это одна 16-разрядная кодовая единица.
// '❤️' - это две 16-битные кодовые единицы, U+2764 и U+FE0F (сердце и вариант).
// '🧀' - это 32-битная кодовая точка (U+1F9C0), которая также может быть представлена как суррогатная пара двух 16-битных кодовых единиц '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';

// Это не будет работать. Будет напечатано:
// DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.
try {
const validUTF16StringEncoded = btoa(validUTF16String);
console.log(`Encoded string: [${validUTF16StringEncoded}]`);
} catch (error) {
console.log(error);
}

Любой из emojis в строке приведёт к ошибке. Почему Unicode вызывает эту проблему?

Чтобы понять это, давайте сделаем шаг назад и разберёмся со строками, как в информатике, так и в JavaScript.

Строки в Unicode и JavaScript

Unicode — это современный глобальный стандарт кодирования символов, т.е. присвоения номера определённому символу для использования его в компьютерных системах. Для более глубокого изучения Unicode обратитесь к этой статье W3C.

Примеры символов в Unicode и соответствующих им номеров:

Числа, обозначающие каждый символ, называются "кодовыми точками". Можно считать, что "кодовые точки" — это адрес каждого символа. В эмодзи "красное сердце" на самом деле две кодовые точки: одна для сердца, а другая — для "изменения" цвета, чтобы он всегда был красным.

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

В коде Unicode существует два общих способа преобразования этих кодовых точек в последовательности байтов, которые могут быть последовательно интерпретированы компьютером: UTF-8 и UTF-16.

В упрощённом виде это выглядит следующим образом:

Важно отметить, что JavaScript обрабатывает строки в формате UTF-16. Это нарушает работу таких функций, как btoa(), которые фактически работают в предположении, что каждый символ в строке соответствует одному байту. Об этом прямо говорится в MDN:

Метод btoa() создаёт из двоичной строки (т.е. строки, в которой каждый символ рассматривается как байт двоичных данных) ASCII-строку в Base64-кодировке.

Теперь вы знаете, что символы в JavaScript часто требуют более одного байта, и в следующем разделе показано, как обрабатывать этот случай для кодирования и декодирования base64.

btoa() и atob() с Unicode

Как вы уже поняли, ошибка возникает из-за того, что наша строка содержит символы, которые в UTF-16 находятся за пределами одного байта.

К счастью, статья MDN о base64 содержит полезный пример кода для решения этой "проблемы Unicode". Вы можете модифицировать этот код для работы с предыдущим примером:

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
const binString = atob(base64);
return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
const binString = String.fromCodePoint(...bytes);
return btoa(binString);
}

// Образец строки, представляющий собой комбинацию малых, средних и больших кодовых точек.
// Данный образец строки имеет валидный формат UTF-16.
// 'hello' имеет кодовые точки, каждая из которых меньше 128.
// '⛳' - это одна 16-разрядная кодовая единица.
// '❤️' - это две 16-битные кодовые единицы, U+2764 и U+FE0F (сердце и вариант).
// '🧀' - это 32-битная кодовая точка (U+1F9C0), которая также может быть представлена как суррогатная пара двух 16-битных кодовых единиц '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';

// Это будет работать. Она напечатает:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);

// Это будет работать. Она напечатает:
// Decoded string: [hello⛳❤️🧀]
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`Decoded string: [${validUTF16StringDecoded}]`);

Следующие шаги объясняют, что делает этот код для кодирования строки:

  1. С помощью интерфейса TextEncoder можно получить строку JavaScript в кодировке UTF-16 и преобразовать её в поток байтов в кодировке UTF-8 с помощью функции TextEncoder.encode().
  2. В результате возвращается массив Uint8Array, который является менее распространённым типом данных в JavaScript и представляет собой подкласс TypedArray.
  3. Возьмём этот массив Uint8Array и передадим его функции bytesToBase64(), использующей функцию String.fromCodePoint() для обработки каждого байта в массиве Uint8Array как кодовой точки и создания из него строки, в результате чего получится строка кодовых точек, которые можно представить как один байт.
  4. Возьмём эту строку и с помощью функции btoa() закодируем её в base64.

Процесс декодирования — это то же самое, но в обратном порядке.

Это работает потому, что шаг между Uint8Array и строкой гарантирует, что, хотя строка в JavaScript представлена в двухбайтовой кодировке UTF-16, кодовая точка, которую представляют каждые два байта, всегда меньше 128.

Этот код хорошо работает в большинстве случаев, но в остальных случаях он будет тихо сбоить.

Случай тихого сбоя

Используйте тот же код, но с другой строкой:

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
const binString = atob(base64);
return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
const binString = String.fromCodePoint(...bytes);
return btoa(binString);
}

// Образец строки, представляющий собой комбинацию малых, средних и больших кодовых точек.
// Данный образец строки имеет валидный формат UTF-16.
// 'hello' имеет кодовые точки, каждая из которых меньше 128.
// '⛳' - это одна 16-разрядная кодовая единица.
// '❤️' - это две 16-битные кодовые единицы, U+2764 и U+FE0F (сердце и вариант).
// '🧀' - это 32-битная кодовая точка (U+1F9C0), которая также может быть представлена как суррогатная пара двух 16-битных кодовых единиц '\ud83e\uddc0'.
// '\uDE75' - кодовая единица, являющаяся половиной суррогатной пары.
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

// Это будет работать. Она напечатает:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA77+9]
const partiallyInvalidUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(partiallyInvalidUTF16String));
console.log(`Encoded string: [${partiallyInvalidUTF16StringEncoded}]`);

// Это будет работать. Она напечатает:
// Decoded string: [hello⛳❤️🧀�]
const partiallyInvalidUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(partiallyInvalidUTF16StringEncoded));
console.log(`Decoded string: [${partiallyInvalidUTF16StringDecoded}]`);

Если взять последний символ после декодирования () и проверить его шестнадцатеричное значение, то окажется, что это \uFFFD, а не исходное \uDE75. При этом не происходит сбоя или ошибки, но входные и выходные данные молча изменились. Почему?

Строки зависят от JavaScript API

Как было описано ранее, JavaScript обрабатывает строки в формате UTF-16. Но строки UTF-16 обладают уникальным свойством.

В качестве примера можно привести эмодзи "сыр". Этот эмодзи (🧀) имеет кодовую точку Unicode 129472. К сожалению, максимальное значение 16-битного числа равно 65535! Как же UTF-16 представит это гораздо большее число?

В UTF-16 существует понятие, называемое суррогатными парами. Её можно представить себе следующим образом:

Как вы можете себе представить, иногда может возникнуть проблема, когда номер обозначает только книгу, но не саму запись в этой книге. В UTF-16 это называется одиночным суррогатом.

Это особенно сложно в JavaScript, поскольку некоторые API работают, несмотря на наличие одиноких суррогатов, а другие — нет.

В данном случае при обратном декодировании из base64 используется TextDecoder. В частности, в настройках по умолчанию для TextDecoder указано следующее:

По умолчанию он имеет значение false, что означает, что декодер заменяет неправильно сформированные данные символом замены.

Замеченный ранее символ �, который в шестнадцатеричном виде представляется как \uFFFD, и есть тот самый символ замены. В UTF-16 строки с одинокими суррогатами считаются "неверно сформированными"/"malformed" или "неправильно сформированными"/"not well formed".

Существуют различные веб-стандарты (примеры 1, 2, 3, 4), которые точно определяют, когда неправильно сформированная строка влияет на поведение API, но, в частности, TextDecoder является одним из таких API. Перед обработкой текста рекомендуется убедиться в том, что строки правильно сформированы.

Проверка правильности формирования строк

В последних версиях браузеров для этой цели предусмотрена функция isWellFormed().

Аналогичного результата можно добиться с помощью функции encodeURIComponent(), которая выбрасывает ошибку URIError, если строка содержит одинокий суррогат.

Следующая функция использует isWellFormed(), если она доступна, и encodeURIComponent(), если её нет. Аналогичный код может быть использован для создания полифилла для функции isWellFormed().

// Быстрый полифилл, поскольку старые браузеры не поддерживают функцию isWellFormed().
// encodeURIComponent() выдаёт ошибку для одиноких суррогатов, что, по сути, одно и то же.
function isWellFormed(str) {
if (typeof(str.isWellFormed)!="undefined") {
// Используем новую функцию isWellFormed().
return str.isWellFormed();
} else {
// Используем старую функцию encodeURIComponent().
try {
encodeURIComponent(str);
return true;
} catch (error) {
return false;
}
}
}

Соединяем все вместе

Теперь, когда вы знаете, как работать с обоими Unicode и одиночными суррогатами, вы можете собрать все воедино и создать код, который работает со всеми случаями и делает это без тихой замены текста.

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
const binString = atob(base64);
return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
const binString = String.fromCodePoint(...bytes);
return btoa(binString);
}

// Быстрый полифилл, поскольку старые браузеры не поддерживают функцию isWellFormed().
// encodeURIComponent() выдаёт ошибку для одиноких суррогатов, что, по сути, одно и то же.
function isWellFormed(str) {
if (typeof(str.isWellFormed)!="undefined") {
// Используем новую функцию isWellFormed().
return str.isWellFormed();
} else {
// Используем старую функцию encodeURIComponent().
try {
encodeURIComponent(str);
return true;
} catch (error) {
return false;
}
}
}

const validUTF16String = 'hello⛳❤️🧀';
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

if (isWellFormed(validUTF16String)) {
// Это будет работать. Она напечатает:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);

// Это будет работать. Она напечатает:
// Decoded string: [hello⛳❤️🧀]
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`Decoded string: [${validUTF16StringDecoded}]`);
} else {
// В данном примере не достигается.
}

if (isWellFormed(partiallyInvalidUTF16String)) {
// В данном примере не достигается.
} else {
// Это не корректно сформированная строка, поэтому мы обрабатываем этот случай.
console.log(`Cannot process a string with lone surrogates: [${partiallyInvalidUTF16String}]`);
}

В этот код можно внести множество оптимизаций, таких как обобщение в полифилл, изменение параметров TextDecoder на выбрасывание ошибки вместо молчаливой замены одиноких суррогатов и т.д.

С помощью этих знаний и кода можно также принимать явные решения о том, как обрабатывать неправильно сформированные строки, например, отвергать данные или явно разрешать замену данных, или, возможно, выбрасывать ошибку для последующего анализа.

Помимо того, что эта заметка является ценным примером кодирования и декодирования base64, она показывает, почему тщательная обработка текста особенно важна, особенно если текстовые данные поступают из пользовательских или внешних источников.

Дополнительные материалы

Предыдущая Статья

Новое в Symfony 6.4: Маршруты на основе FQCN

Следующая Статья

Новое в Symfony 6.4: Утилиты имперсонации