Frontender Magazine

Подводные камни при передаче JSON в JavaScript

Часто при генерации страницы нужно передать какие-либо данные окружению JavaScript в браузере пользователя.

Скажем, на странице нужна информация об авторизованном пользователе:

{
    "id": 42,
    "username": "vovan2007",
    "name": "Вовочка"
}

Асинхронная загрузка

Можно пойти по пути наименьшего сопротивления и достать эти данные аяксом:

$.getJSON('/ajax/loggeduser').then(function(info){
    // Делаем что-то полезное
});

Или так, если вы впереди планеты всей:

window.fetch('/ajax/loggeduser').then(function(info){
    // Делаем что-то полезное
});

Всё. Нужны данные — грузим их. Не нужны — экономим пользователю трафик. Все счастливы.

Или не все? Внезапно может оказаться, что данные профиля на странице нужны всегда, и на каждый обычный запрос страницы требуется ещё один аякс-запрос.

Сервер не рад, потому что мы только что удвоили количество запросов.

Клиент не рад, потому что пока запрос не выполнится, он не сможет полноценно воспользоваться страницей. Да и накладных расходов (HTTP-заголовки тоже занимают место!) больше, чем самих данных.

Похоже, пришло время внедрить профиль пользователя прямо в код.

Внедрённые данные

В этом случае мы просто будем вставлять на страницу что-то вроде такой конструкции:

<script>
    var userInfo = {{ user.info | json }};
</script>

Это синтаксис JINJA, но поверьте, неважно, какой шаблонизатор вы используете.

Что получится на выходе:

<script>
    var userInfo = {
        "id": 42,
        "username": "vovan2007",
        "name": "Вовочка"
    };
</script>

Всё круто, мы сэкономили один запрос, и в глобальной переменной userInfo лежит профиль Вовочки. Но это ещё не всё.

Подводные камни

Камень первый: HTML-парсер

Что будет, если вместо Вовочки нам попадётся злоумышленник, который воспользуется тем, что в поле name можно писать что угодно?

Тогда при попытке заинлайнить

{
    "id": 99,
    "username": "Haxx0r",
    "name": "Вася</script><script>alert('Pwnd!')//"
}

мы получим:

<script>
    var userInfo = {
        "id": 99,
        "username": "Haxx0r",
        "name": "Вася
</script>
<script>
    alert('Pwnd!')//"};
</script>

В самом деле, HTML-парсер может узнать о том, что блок JS-кода закончился только по строке </script>. И его не сильно волнует, что она находится прямо посреди внедрённого JSON.

Возможным решением может быть замена </script на <\/script при выводе. JavaScript проигнорирует экранирование слеша, а вот парсер HTML уже не будет давать ложных срабатываний. И хакер Вася останется с носом:

<script>
    var userInfo = {
        "id": 99,
        "username": "Haxx0r",
        "name": "Вася<\/script><script>alert('Pwnd!')//"
    };
</script>

Камень второй: Юникод

Само название JSON гласит, «Запись объекта как в JavaScript» (JavaScript Object Notation). И казалось бы, из любого JSON мы всегда получим валидный JavaScript.

Но это не так! Дело в том, что символы U+2028 (разделитель строк) и U+2029 (разделитель абзацев) в JS считаются переносами строк, в JSON — нет.

Иными словами, U+2028 и U+2029 не создадут проблем в JSON:

{
    "id": 115,
    "username": "unicode",
    "name": "Юни•код"
};

А вот в JS они вызовут ошибку «незавершённая строка»:

<script>
    var userInfo = {
        "id": 115,
        "username": "unicode",
        "name": "Юни
код"
    }
</script>

К счастью, таким образом можно только обрушить страницу, а вот запустить зловредный код — нельзя. Или можно? Напишите, пожалуйста, в комментариях!

Как решить эту проблему? А точно так же: перед выводом на страницу искать в строке символы с кодами 0x2028 и 0x2029 и заменять их на \u2028 и \u2029 соответственно.

Заключение

Разумеется, превращение JSON в JavaScript — это не единственный способ передать данные на страницу.

Можно, например, поступить как Twitter, и вставлять JSON в атрибут какого-нибудь элемента:

<input type="hidden" id="init-data" class="json-data"
value="{&quot;permalinkOverlayEnabled&quot;:false, . . .">

Однако, вставка JSON прямо в тег <script> не такой уж плохой способ. Если, конечно, вы помните о двух важных нюансах:

Если вы заметили ошибку, вы всегда можете отредактировать статью, создать issue или просто написать об этом Антону Немцеву в skype ravencry.

Антон Хлыновский
Автор:
Антон Хлыновский
GitHub:
subzey
Twitter:
@subzey

Комментарии (5 комментариев, если быть точным)

Автар пользователя
mistakster

А если все значения экранировать через \uXXXX? Возможно, будет немного избыточно, но зато надёжно. А?

Автар пользователя
zerkms

@mistakster уже давно не нужно об этом заботиться - энкодеры во всех языках все нюансы учитывают

Автар пользователя
subzey

@mistakster, да кто ж вам запретит? :) Только, скажем, буквы кириллицы будут представляться 6 байтами вместо 2. Это втрое больше!

@zerkms, не всё так радужно! - PHP по умолчанию экранирует / и весь юникод (включая \u2028 и \u2029); - Python экранирует юникод (включая \u2028 и \u2029), но пропускает </script>; - Питоновский модуль ujson экранирует также /; - Ruby не экранирует ни /, ни юникод (Привет </script>, \u2028 и \u2029); - JS точно так же не экранирует ни /, ни юникод.

Автар пользователя
zba

base64 в помощь.

Автар пользователя
FinesseRus

Это всё ответственность компонента, который кодирует данные в JSON, то есть backend'а.