Чтобы описать суть проблемы, мне нужно рассказать, как вообще устроен HTML. Вы наверняка в общих чертах представляли себе, но я все равно коротко пробегусь по основным моментам, которые понадобятся для понимания. Если кому-то не терпится, сразу переходите к сути.


HTML — это язык гипертекстовой разметки. Чтобы говорить на этом языке, нужно соблюдать его формат, иначе тот, кто читает написанное, не сможет вас понять. Например, в HTML у тегов есть атрибуты:


<p name="value">

Тут [name] — это имя атрибута, а [value] — это его значение. В статье я буду использовать квадратные скобки вокруг кода, чтобы было понятно, где он начинается и заканчивается. После имени стои?т знак равенства, а после него — значение, заключенное в кавычки. Значение атрибута начинается сразу после первого символа кавычки и заканчивается сразу перед следующим символом кавычки, где бы он не находился. Это значит, что если вместо [value] вы запишете [OOO "Рога и копыта".], то значение атрибута name будет [OOO ], а еще у вашего элемента будет три других атрибута с именами: [рога], [и] и [копыта"."], но без значений.


<p name="OOO "Рога и копыта"."></p>

Если это не то, чего вы ожидали, вам нужно как-то изменить значение атрибута, чтобы в нем не встречалась кавычка. Самое простое, что можно придумать — просто вырезать кавычки.


<p name="OOO Рога и копыта."></p>

Тогда парсер HTML верно прочтет значение, но беда в том, что это будет другое значение. Вы хотели [OOO "Рога и копыта"], а получили [OOO Рога и копыта.]. В каких-то случаях такое различие может быть критичным.


Чтобы вы могли указать в качестве значения любую строку, формат языка HTML предлагает возможность экранировать значения атрибутов. Вместо кавычки в строке значения вы можете записать последовательность символов [&quot;] и парсер поймет, что в этом месте в исходной строке, которую вы хотите использовать в качестве значения атрибута, была кавычка. Такие последовательности называются HTML entities.


<p name="OOO &quot;Рога и копыта&quot;."></p>

При этом, если в вашей исходной строке действительно была последовательность символов [&quot;], у вас все еще есть возможность записать её так, чтобы парсер не превратил её в кавычку — для этого надо заменить знак [&] на последовательность символов [&amp;], то есть вместо [&quot;] вам нужно будет записать в сыром тексте [&amp;quot;].


Получается, что преобразование из исходной строки в ту, которую мы запишем между двумя символами кавычек, является однозначным и обратимым. Благодаря этим преобразованиям можно записать и прочитать любую строку в качестве атрибута HTML-тега, не вдаваясь в суть её содержимого. Вы просто соблюдаете формат, и все работает.


Собственно, так работает большинство форматов, с которыми мы сталкиваемся: есть синтаксис, есть способ экранирования контента от этого синтаксиса и способ экранирования символов экранирования, если вдруг такая последовательность встречается в исходной строке. Большинство, но не…


Тег <script>


Тег <script> служит для встраивания в HTML фрагментов, написанных на других языках. На сегодняшний день в 99% случаев это Javascript. Скрипт начинается сразу после открывающего тега <script> и заканчивается сразу перед закрывающим тегом </script>. Парсер HTML внутрь тега не заглядывает, для него это просто какой-то текст, который он потом отдает в парсер Javascript.


В свою очередь, Javascript — это самостоятельный язык с собственным синтаксисом, он, вообще говоря, никаким специальным образом не рассчитан на то, что будет встроен в HTML. В нем, как в любом другом языке, есть строковые литералы, в которых может быть что угодно. И, как вы уже должны были догадаться, может встретиться последовательность символов, означающая закрывающий тег </script>.


<script>
  var s = "surprise!</script><script>alert('whoops!')</script>";
</script>

Что тут должно происходить: переменной s должна присваиваться безобидная строка.


Что тут происходит на самом деле: Скрипт, в котором объявляется переменная s на самом деле заканчивается так: [var s = "surprise!], что приводит к ошибке синтаксиса. Весь текст после него интерпретируется как чистый HTML и в него может быть внедрена любая разметка. В данном случае открывается новый тег <script> и выполняется зловредный код.


Мы получили тот же эффект, как когда в значении атрибута присутствует кавычка. Но в отличие от значений атрибута, для тега <script> нет никакого способа экранировать исходный контент. HTML entities внутри тега <script> не работают, они будут переданы в парсер Javascript без изменений, то есть либо приведут к ошибке, либо изменят его смысл. Стандарт HTML прямо говорит, что в содержимом тега <script> не может быть последовательности символов </script> ни в каком виде. А стандарт Javascript не запрещает такой последовательности быть где угодно в строковых литералах.


Получается парадоксальная ситуация: после встраивания валидного Javascript в валидный документ HTML абсолютно валидными средствами мы можем получить невалидный результат.


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


Как эксплуатируется уязвимость


Конечно, когда вы просто пишете какой-то код, трудно представить, что вы напишете в строке </script> и не заметите проблем. Как минимум, подсветка синтаксиса даст вам знать, что тег закрылся раньше времени, как максимум, написанный вами код не запустится и вы будете долго искать, что произошло. Но это не является основной проблемой с этой уязвимостью. Проблема возникает там, где вы вставляете какой-то контент в Javascript, когда генерируете HTML. Вот частый кусок кода приложений на реакте с серверным рендерингом:


<script>
window.__INITIAL_STATE__ = <%- JSON.stringify(initialState) %>;
</script>

В initialState </script> может появиться в любом месте, где данные поступают от пользователя или из других систем. JSON.stringify не будет менять такие строки при сериализации, потому что они полностью соответствуют формату JSON и Javascript, поэтому они просто попадут на страницу и позволят злоумышленнику выполнить произвольный Javascript в браузере пользователя.


Другой пример:


<script>
  analytics.identify(
      '<%- user.id.replace(/(\'|\\)/g, "\\$1") %>',
      '<%- request.HTTP_REFERER.replace(/(\'|\\)/g, "\\$1") %>',
      ...
  );
</script>

Тут в строки с соответствующим экранированием записываются id пользователя и referer, который пришел на сервер. И, если в user.id вряд ли будет что-то кроме цифр, то в referer злоумышленник может запихнуть что угодно.


Но на закрывающем теге </script> приколы не заканчиваются. Опасность представляет и открывающий тег <script>, если перед ним в любом месте есть символы [<!--], которые в обычном HTML обозначают начало многострочного комментария. Причем в этом случае вам уже не поможет подсветка синтаксиса большинства редакторов.


<script>
  var a = 'Consider this string: <!--';
  var b = '<script>';
</script>
<p>Any text</p>
<script>
  var s = 'another script';
</script>

Что видит здоровый человек и большинство подсветок синтаксиса в этом коде? Два тега <script>, между которыми находится параграф.


Что видит больной парсер HTML5? Он видит один (!) незакрытый (!) тег <script>, содержащий весь текст со второй строчки до последней.


Я до конца не понимаю, почему это так работает, мне понятно лишь, что встретив где-либо символы [<!--], парсер HTML начинает считать открывающие и закрывающие теги <script> и не считает скрипт законченным, пока не будут закрыты все открытые теги <script>. То есть в большинстве случаев этот скрипт будет идти до конца страницы (если только кто-то не смог внедрить еще один лишний закрывающий тег </script> ниже, хе-хе). Если вы до этого не сталкивались с подобным, то можете подумать, что я сейчас шучу. К сожалению, нет. Вот скриншот DOM-дерева примера выше:



Самое неприятное, что в отличие от закрывающего тега </script>, который в Javascript может встретиться только внутри строковых литералов, последовательности символов <!-- и <script могут встретиться и в самом коде! И будут иметь точно такой же эффект.


<script>
  if (x<!--y) { ... }
  if ( player<script ) { ... }
</script>

А вы точно спецификация?


Спецификация HTML, помимо того, что запрещает использование легальных последовательностей символов внутри тега <script> и не дает никакого способа их экранирования в рамках HTML, также советует следующее:


The easiest and safest way to avoid the rather strange restrictions described in this section is to always escape "<!--" as "<\!--", "<script" as "<\script", and "</script" as "<\/script" when these sequences appear in literals in scripts (e.g. in strings, regular expressions, or comments), and to avoid writing code that uses such constructs in expressions.

Что можно перевести как «Всегда экранируйте последовательности "<!--" как "<\!--", "<script" как "<\script", а "</script" как "<\/script", когда они встречаются в строковых литералах в ваших скриптах и избегайте этих выражений в самом коде». Эта рекомендация меня умиляет. Тут делается сразу несколько наивных предположений:


  1. Во встраиваемом скрипте (а это не обязательно Javascript) перечисленные выше последовательности символов могут быть либо внутри строковых литералов, либо их можно легко избежать в синтаксисе языка.
  2. Во встраиваемом скрипте в строковых литералах можно экранировать не специальные символы и это не приводит к изменению значений литералов.
  3. Тот, кто встраивает скрипт, знает что это за скрипт, глубоко понимает его синтаксис и способен производить изменения в его структуре.

И, если первые два пункта выполняются хотя бы для Javascript, то последний не выполняется даже для него. Не всегда скрипт в HTML вставляет квалифицированный человек, это может быть какой-то генератор HTML. Вот пример того, как с этим не справляется сам браузер:


var script = document.createElement('script')
script.innerText = 'var s = "</script><script>alert(\'whoops!\')</script>"';
console.log(script.outerHTML);

>>> <script>var s = "</script><script>alert('whoops!')</script>"</script>

Как видите, строка с сериализованным элементом не будет распаршена в элемент, аналогичный исходному. Преобразование DOM-дерево > HTML-текст в общем случает не является однозначным и обратимым. Некоторые DOM-деревья просто нельзя представить в виде исходного HTML-текста.


Как избежать проблем?


Как вы уже поняли, способа безопасно вставить Javascript в HTML нет. Но есть способы сделать Javascript безопасным для вставки в HTML (почувствуйте разницу). Правда для этого нужно быть предельно внимательным всё время, пока вы пишете что-то внутри тега <script>, особенно если вы вставляете любые данные с помощью шаблонизатора.


Во-первых, вероятность того, что у вас в исходном тексте (даже после минификации) не в строковых литералах встретятся символы [<!-- <script>] крайне мала. Сами вы вряд ли напишете что-то такое, а если злоумышленник что-то сможет написать прямо в теге <script>, то внедрение этих символов будет беспокоить вас в последнюю очередь.


Остается проблема внедрения символов в строки. В этом случае, как и написано в спецификации, всего-то нужно заменить все "<!--" на "<\!--", "<script" на "<\script", а "</script" на "<\/script". Но беда в том, что если вы выводите какую-то структуру с помощью JSON.stringify(), то вряд ли вы захотите потом её распарсить еще раз, чтобы найти все строковые литералы и заэкранировать в них что-то. Так же не хочется советовать пользоваться другими пакетами для сериализации, где эта проблема уже учтена, потому что ситуации бывают разными, а защититься хочется всегда и решение должно быть универсальным. Поэтому я бы советовал экранировать символы / и ! с помощью обратного слеша уже после сериализации. Эти символы не могут встречаться в JSON нигде кроме как внутри строк, поэтому простая замена будет абсолютно безопасной. Это не изменит последовательность символов "<script", но она и не представляет опасности, если встречается сама по себе.


<script>
window.__INITIAL_STATE__ = <%- JSON.stringify(initialState).replace(/(\/|\!)/g, "\\$1") %>;
</script>

Точно так же можно экранировать и отдельные строки.


Другой совет — не встраивайте в тег <script> ничего вообще. Храните данные в местах, где трансформации для вставки данных однозначны и обратимы. Например, в атрибутах других элементов. Правда смотрится это довольно грязно и работает только со строками, JSON придется парсить отдельно.


<var id="s" data="surprise!</script><script>alert(&quot;whoops!&quot;)</script>"></var>
<script>
  var s = document.getElementById('s').getAttribute('data');
  console.log(s);
</script>

Но, по-хорошему, конечно, если вы хотите нормально разрабатывать приложения, а не аккуратно ходить по минному полю, нужен надежный способ встраивания скриптов в HTML. Поэтому правильным решением считаю вообще отказаться от тега <script>, как от не безопасного.


Тег <safescript>


Если не использовать встраиваемые скрипты, то что тогда? Конечно, подключать все скрипты извне — не вариант, иногда иметь какой-то Javascript с данными внутри HTML-документа очень удобно: нет лишних HTTP-запросов, не нужно делать дополнительных роутов на стороне сервера.


Поэтому я предлагаю ввести новый тег — <safescript>, содержимое которого будет полностью подчинятся обычным правилам HTML — будут работать HTML entities для экранирования контента — и поэтому встраивание в него любого скрипта будет абсолютно безопасным.


<safescript>
  var s = "surprise!&lt;/script&gt;&lt;script&gt;alert('whoops!')&lt;/script&gt;";
</safescript>

<safescript>
  var a = 'Consider this string: &lt;!--';
  var b = '&lt;script&gt;';
</safescript>

При этом нет необходимости дожидаться реализации этого тега в браузерах. Я написал очень простой полифил safescript, который позволяет использовать его прямо сейчас. Вот всё, что для этого нужно:


<script type="text/javascript" src="/static/safescript.js"></script>
<style type="text/css">safescript {display: none !important}</style>

Код внутри <safescript> выглядит ужасно и непривычно. Но это код, который попадет в сам HTML. В шаблонизаторе, который вы используете, можно сделать простой фильтр, который будет вставлять тег и экранировать все его содержимое. Вот так может выглядеть код в шаблонизаторе Django:


{% safescript %}
  var s = "surprise!</script><script>alert('whoops!')</script>";
{% endsafescript %}

{% safescript %}
  var a = 'Consider this string: <!--';
  var b = '<script>';
{% endsafescript %}

Такой подход позволяет забыть об экранировании Javascript и избежать многих уязвимостей. И было бы очень здорово, если бы ребята, разрабатывающие спецификацию HTML, добавили такой скрипт в базовый набор или придумали какой-то другой способ решения проблемы небезопасного встраивания скриптов в HTML.

Комментарии (64)


  1. mobi
    12.02.2018 11:21

    1. Не будет работать document.write.
    2. Не будет работать в комбинации с внешними скриптами вида <script>var params={...};</script><script src="process_params.js"></script>.


    1. homm Автор
      12.02.2018 11:50

      Совершенно верно, <safescript> это аналог <script defer>. Забыл об этом сказать.


  1. vitaliy2
    12.02.2018 12:02
    -2

    Статья не очень.

    1. <?/script> — это спецификация.
    2. При препрецессинге кода проблем не будет, т.?к. там вряд ли будут штуки типа referer, поэтому и вероятность <?/script> мала. При шаблонизации на клиенте проблем тоже не будет, т.?к. .innerText и .innerHTML позволяют вставлять <?/script> сколько угодно. А вот при шаблонизации на сервере да, будут проблемы. Но чтобы сказать об этом, не нужно было писать статью на 15 тыс символов.
    3. Для решения проблемы серверной шаблонизации достаточно просто вставить обратный слэш (\), т.?к. <?/script> может вроде встретиться только в строках, комментах и регулярках, а там вставить слэш безопасно.


  1. mayorovp
    12.02.2018 12:24

    Между тем, библиотека Knockout.MVC содержит эту уязвимость...


  1. vlasenkofedor
    12.02.2018 12:45

    В документации есть типы тега script
    проблема одна кеширование на клиенте


  1. symbix
    12.02.2018 12:49

    Проблема возникает только тогда, когда в тег <script> подставляются автоматически генерируемые данные на основе пользовательского ввода (первичные, вторичные (из базы) или какого угодно порядка — не суть).


    Честно говоря, не припоминаю ни одного случая, когда бы это было необходимо. Кодогенерация на основе пользовательских данных — это зло само по себе, и если уж в каком-то случае это необходимо, там кроме описанной проблемы будут еще сотни других. А данные — это данные, для них достаточно JSON-сериализации, а JSON можно, как справедливо замечено, поместить в атрибуты.


    Да, JSON в атрибутах необходимо парсить. Ну а с вашей директивой safescript необходимо на лету откомпилировать Javascript. Парсинг JSON — очевидно менее ресурсоемкая операция. Тем более, многие фреймворки (angular, vue) умеют парсить JSON в атрибутах "из коробки".


    1. EGDFree
      12.02.2018 15:54

      Кратко:

      не встраивайте в тег <script> ничего вообще


      1. homm Автор
        12.02.2018 18:32
        -3

        Совершенно верно. Вставляйте в safescript :-)


  1. Sayonji
    12.02.2018 15:43
    +1

    Подзравляю, вы открыли, что пользоваться eval с внешними данными не очень хорошая идея.

    По теме статьи, было:
    <script>var x = <?php echo $x; ?></script>
    стало:
    <?php $url = base64_encode("var x = " . $x); ?>
    <script src="data:text/javascript;base64,<?php echo $url; ?>"></script>
    И ваши проблемы решатся, нет?


    1. sebres
      13.02.2018 18:26

      Подзравляю, вы открыли, что пользоваться eval с внешними данными не очень хорошая идея.

      Жирный плюс!


      По теме статьи, было… стало ...

      Всё еще много проще — например было/стало:


      -<script>var x = <%= JSON.stringify(...) %>; </script>
      +<script>var x = <%=html= JSON.stringify(...) %>; </script>

      Т.е. html= это обёртка (функция, генератор, стрим), вызывающая htmlEncode, htmlEscape, и прочие подобные функции, делающие &lt;, &gt;, &quot;, &amp; и прочее, ещё никто не отменял.
      Если foreign-input, embedded и прочее вставляется в html-разметку, т.е. результат сперва разбирается html-парсером, до того как будет собственно "исполнен" тег скрипт, полифилы и прочее ничего с собственно html-стандартом не имееющее.


      И если забыть про правильный эскейп, то скрипт-тег будет далеко не единственной проблемой…
      XSS, XFS, и прочие радости можно словить даже тупо на "сломаной" encoding...


      К homm: это не имеет вообще ничего общего с "фундаментальной уязвимостью". Совсем.


      1. homm Автор
        13.02.2018 18:37

        делающие <, >, ", & и прочее, ещё никто не отменял.

        И в результате у вас внутри строки Javascript будут именно &lt;, &gt;, &quot;, &amp;, а не <, >, ", &, которы были в исходной строке.


        И если забыть про правильный эскейп, то скрипт-тег будет далеко не единственной проблемой…

        Еще раз попробую донести свою мысль: скрипт-тег все еще останется проблемой, даже если не забыть про правильный эскейп. В случае тега script просто не предусмотрено никакого эскейпа для встраиваемого скрипта, на него просто наложены ограничения (причем с <!-- весьма извращенные), которых нет в Javascript.


        1. sebres
          13.02.2018 19:29

          И в результате у вас внутри строки Javascript будут именно &lt;, &gt; ...

          Прошу прощения, и правда попутал тут, — забыл про XHTML vs. CDATA (никогда не вставляю подобное прямо в скрипт).


          скрипт-тег все еще останется проблемой, даже если не забыть про правильный эскейп

          Попробую и я вам донести свою мысль — про правильный эскейп просто НЕЛЬЗЯ забывать, ни в коем случае.
          Просто, если внутри тега ожидается CDATA, то и эскейпить нужно для CDATA.
          Для HTML — HtmlEncode, для JS внутри HTML — HtmlJSEncode (например <a onclick="alert(<%=html-js= something(); %>)">)…
          И так далее.


          А лучше просто класть foreign-inlput в предназначеные для этого теги (типа input, textarea и т.д.), и/или вовсе отдельным майм-стримом (т.е. application/json).


          1. homm Автор
            13.02.2018 20:29

            про правильный эскейп просто НЕЛЬЗЯ забывать

            Давайте еще раз. Скрипт-тег все еще останется проблемой, если вы НЕ ЗАБЫВАЕТЕ про правильный эскейп. Его для тега script просто не предусмотрено.


            Пример который вы показали с onclick — это как раз пример того, где можно экранировать вставляемый скрипт, потому что атрибут onclick парсится точно так же как любые остальные атрибуты HTML и в нем может быть абсолютно любой контент, для его встраивания не нужно знать синтаксис скрипта.


            В отличие от него, тег script парсится по своим, совершенно отдельным правилам. Он не позволяет вставить в себя произвольный Javascript, даже если он на 100% валидный Javascript. Он накладывает дополнительные требования на содержимое встриваемых скриптов, которые в случае Javascript могут быть соблюдены только парсингом и модификацией Javascript, а в случае встраивания скрипта на произвольном языке в общем случае не могут быть соблюдены.


            А лучше просто класть foreign-inlput

            Лучше или хуже это другой вопрос. Я не о лучших практиках говорю, а о проблеме в спецификации. Спецификация допускает встраивание в HTML языков, синтаксис которых позволяет выходить за пределы встраеваемого жлемента и не дает никаких способов этот синтаксис экранировать. Вместо этого она говорит: «там этого контента быть не должно. Как и кто его будет оттуда доставать? Да мне плевать.». Из-за этого часто возникают уязвимости уже в реальных приложениях, когда контент в теге скрипт генерируется на лету.


            1. sebres
              13.02.2018 20:49

              Его для тега script просто не предусмотрено.

              см. ниже (просто попробуйте) ...


              Заглянул в древние исходники своего js_encode (для utf-8 на сях), прекрасно работает много лет...


              Скрытый текст
              while (l--) {
              switch ((c = (unsigned char)*p)) {
                case '\\': 
                  buf = "\\\\";
                  buflen = 2;
                  goto lab_enc;
                case '\'': 
                  buf = "\\'";
                  buflen = 2;
                  goto lab_enc;
                case '"': 
                  buf = "\\\"";
                  buflen = 2;
                  goto lab_enc;
                case '\n': 
                  buf = "\\n";
                  buflen = 2;
                  goto lab_enc;
                case '\r': 
                  buf = "\\r";
                  buflen = 2;
                  goto lab_enc;
                case '<':
                  // wrap <!-- to <\!--, wrap </ to <\/ :
                  if (l && (*(p+1) == '!' || *(p+1) == '/')) {
                    buf = "<\\";
                    buflen = 2;
                    goto lab_enc;
                  }
                break;
                case '>': 
                  // wrap --> to --\> :
                  if (p-1 > st && *(p-1) == '-' && *(p-2) == '-') {
                    buf = "\\>";
                    buflen = 2;
                    goto lab_enc;
                  }
                break;
              }
              ...


        1. sebres
          13.02.2018 19:43

          Да, для скрипт-тега, JS обертка должна, кроме всего прочего (кавычки и т.д.), еще и эскейпить /, т.е. всего лишь:


          <script> alert('<\/script>'); </script>


          1. homm Автор
            14.02.2018 15:57
            -2

            Символ / встречается не только внутри строк, но и в самом скрипте (операция деления) и его экранированое с помощью \ приведет к порче скрипта.


            Соответственно чтобы экранировать все символы / внутри скрипта внутри строк нужно разобрать синтаксис срипта, найти все строки, модифицировать их, собрать обратно. В случае Javascript это несоразмерно сложно для такой простой задачи, для произвольного скрипта это невыполнимая задача, т.к. встроить можно что угодно, а синтаксиси чего угодно вы точно не сможете распарсить.


            1. sebres
              14.02.2018 16:16

              Символ / встречается не только внутри строк, но и в самом скрипте (операция деления)

              Ну да, ииии? Это-то вам зачем экранировать? Или вы операцию деления тоже от пользователя (чужого кода) прямым способом у себя "вставляете"?!


              Т.е. я вас правильно понимаю, что вы экранируете так (я утрирую дальше):


              <%
                MagicEncode(
                  JS_doing_WebServiceCall(
                    URL_Get(something),
                    XML_Get(Params), etc
                  )
                );
              %>

              Т.е. одно магическое маскирование для JS (наружу), XML (веб-сервис), URL (something, опять для WS), и т.д. Всё одним MagicEncode?


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


            1. mayorovp
              14.02.2018 16:18

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


              1. homm Автор
                14.02.2018 16:53

                И нет никакой проблемы правильно экранировать их автоматически.

                Ну вы же сами показали пример, что есть.


                1. mayorovp
                  14.02.2018 17:10

                  Где я показывал такой пример?


                  Если вы про Knockout.MVC — то они просто забыли про экранирование, исправление-то той строчки тривиально (например, подойдет Replace("<", "\\u003c")).


  1. fRoStBiT
    12.02.2018 15:54
    +2

    Если разработчик вставляет в JS-код, встроенный в страницу, неэкранированный текст, он сам виноват.
    Никакой уязвимости нет, всё есть в спецификации. Просто надо экранировать не только кавычки, но и угловые скобки.


    1. homm Автор
      12.02.2018 16:36
      +1

      Если разработчик вставляет в JS-код, встроенный в страницу, неэкранированный текст, он сам виноват.

      Хороший подход. А что если его вставляет не разработчик, а библиотека или сам барузер, как это описано в части А вы точно спецификация??


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

      Это не всегда возможно, об этом я написал в части А вы точно спецификация?


      1. fRoStBiT
        12.02.2018 16:52

        Браузер вставляет ровно то, что ему сказали. Вместо закрывающего тега script мог бы быть любой другой.
        Свойство innerText вообще не стандартизовано и может работать так, как захочется конкретному браузеру.
        В каких случаях экранирование невозможно?


        1. homm Автор
          12.02.2018 17:03

          Экранирование невозможно в случаях, когда:


          1. Вы не знаете на каком языке встраивается скрипт
          2. Синтаксис этого языка не предусматривает экранирования не специальных символов (то есть \/ в строковых литералах будет означать \/, а не \).
          3. У вас нет средств для синтаксического разбора и модификации исходного текста встраиваемого скрипта.


          1. fRoStBiT
            12.02.2018 17:09

            1. Это в каких же случаях язык скрипта неизвестен (бонусный вопрос: когда он отличается от JS)?
            2. См. пункт 1.
            3. Нельзя ожидать безопасности, встаивая на страницу скрипт с неопределённым содержанием. И когда это может понадобиться (именно не строку, а целый скрипт)?


            1. homm Автор
              13.02.2018 20:47

              1. Вот вам известен язык в следующем теге? <script type="application/x-my-templates"> Но вы хоть погуглить можете, а библиотеке, которая собирает из DOM-дерева HTML что делать?
              2. ...
              3. Это необзательно неопределенное содержимое, просто модержмое может быть определено не в том месте, где формируется HTML-документ.


              1. mayorovp
                13.02.2018 22:06

                А зачем библиотеке автоматически формировать <script type="application/x-my-templates">? Обычно наоборот происходит: пишет такие шаблоны программист, а библиотека их парсит.


      1. GerrAlt
        12.02.2018 21:52

        А что если его вставляет не разработчик, а библиотека


        Позвольте вопрос, в целях повышения образованности, а библиотеки кто пишет?


        1. homm Автор
          12.02.2018 22:14

          Попробуйте в целях повышения образованности написать библиотеку, которая бы вставляла произвольный Javascript в тег <script>.


          Задание со звездочкой: вставляла произвольный скрипт (не обязательно Javascript).


          1. GerrAlt
            12.02.2018 23:54

            Зачем мне писать библиотеку? Тем более такую странную.

            Если вам такое надо, попробуйте посмотреть в сторону RequireJS, Rollup или Webpack. Чтобы писать свое когда имеется уже написанное нужно иметь серьезные основания, если в вашем случае основание — это загрудка скрипта на произвольном языке то дерзайте, меня же на данный момент боже упаси от загрузки клиенту в браузер чего-то кроме JavaScript.

            По существу статьи не понял:
            1) как должен работать ваш safescript если я не держу текст скриптов в своей разметке (script src="...")?
            2) валидация получаемой разметки вас не волнует?
            3) у тега script есть еще целый ряд важных аттрибутов, как ваш safescript будет эмулирвать их поведение?


  1. neumond
    12.02.2018 16:31

    Спасибо за статью, привычка полагаться на htmlescape действительно может подвести. Но решение вызывает определённые сомнения: почему бы просто не экранировать сразу в шаблонизаторе как рекомендует стандарт, выдавая нативный тег script? Ограничиваясь по сути имплементацией темплейттега в джанге.


    1. homm Автор
      12.02.2018 16:34

      почему бы просто не экранировать сразу в шаблонизаторе как рекомендует стандарт

      Это не всегда возможно, об этом я написал в части А вы точно спецификация?.


      1. mayorovp
        12.02.2018 16:48

        Да нет, это как раз возможно. Потому что экранировать-то нужно только вставляемые переменные — а там можно хоть бэкслешей навставлять, хоть конкатенацию использовать.


      1. neumond
        12.02.2018 17:08

        Смею предположить, что основной use case для такого экранирования — это всё-таки пользовательские данные, которые в шаблон попадают через json.encode. В json нет арифметических операторов, поэтому всегда <!--, -->, <script, </script> будет внутри строк. Более того, json это не джаваскрипт, и чтобы стало совсем правильно, нужно делать var x = JSON.parse("..."), у которого единственный аргумент сплошная строка. В остальных случаях, когда код пишется непосредственно разработчиком, спецификация вполне резонно предлагает избегать сомнительных конструкций.


  1. Mikluho
    12.02.2018 16:40
    -1

    А я вот всё в толк не возьму, что за проблему предлагается решать столь неоднозначным способом?
    Возможность "вредоносным" пользователем встроить произвольное содержание в ваш документ — это скорее фундаментальный баг разработчика. Оное содержимое может оказаться в любом месте документа, ведь никто не мешает в качестве имени при регистрации указать:


    Вася"><script>alert("Aloha!");</script>

    и продолжить сей фрагмент остатком оригинального тэга. Т.е. это проблема всей разметки, а не исключительно тэга script.
    Точно так же и в safescript можно включить любую ересь.
    Правильно говорят — на странице должен быть только константный или тщательно проверенный и экранированный контент. Остальное данные и работа с DOM. Других способов защиты нет.
    <sarcasm>
    ну и далее нужна картинка про 15-й стандарт :)
    </sarcasm>


    1. mayorovp
      12.02.2018 16:50
      +1

      Я давал выше ссылку на библиотеку Knockout.MVC. Там используется преобразование модели (содержащей пользовательские данные) в JSON поскольку JSON является валидным JS-литералом. Ваш трюк с закрытием кавычки тут ничего не сломает — кавычка будет экранирована в процессе преобразования в JSON.


      А вот закрывающий тэг </script> внутри строкового литерала, увы, сработает.


      1. Mikluho
        12.02.2018 17:15

        Но суть-то не меняется — пока что-то встраивается в код страницы минуя соответствующее экранирование, проблема будет сохраняться.
        И кто защитит safescript от подобных проблем?


        1. homm Автор
          12.02.2018 17:21

          Экранирование <script> сложное, требует понимания синтаксиса встраиваемого скрипта, в общем случае не однозначное и не обратимое. Происходит по своим собственным законам.


          Экранирование <safescript> простое, однозначное, обратимое, не требует знать ничего о встраиваевом скрипте, такое же как во всем остальном HTML.


          1. Mikluho
            12.02.2018 18:37
            +1

            ок, ок… наверно я что-то делаю не так, но мне за последние 15 лет ни разу не пришлось использовать встраиваемые скрипты с небезопасным содержимым…
            И меня смутило долгое вступление про общие проблемы html, требующие аккуратного экранирования пользовательских данных.
            Но если рассматривать safescript исключительно как разрешение конфликта парсеров html и script — то идея может и годная.

            А заголовок всё же слишком громкий :)


          1. aamonster
            12.02.2018 21:13

            Не проще вместо этого просто вынести скрипты в отдельные файлы?


        1. mayorovp
          14.02.2018 16:19

          А я что-то писал про safescript?


  1. Vindicar
    12.02.2018 19:02

    Как уже написали выше — уязвим будет только тот клиент, который генерирует содержимое тега <script> на основе неэкранированных данных.

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


  1. michael_vostrikov
    12.02.2018 19:03

    В PHP слэши автоматически экранируются.


    php -r "echo json_encode('</script>');"
    "<\/script>"


    1. mobi
      12.02.2018 19:45
      +1

      Слэши — да, а комментарии — нет:

      php -r 'echo json_encode("<!--");'
      "<!--"


      1. michael_vostrikov
        12.02.2018 20:51

        Как бы да, но cама по себе она не влияет, а в сочетании с <script> ей можно разве что закомментировать весь документ до конца. Воспользоваться уязвимостью и выполнить код не получится, по крайней мере я не нашел способа. Документ с валидной разметкой парсится корректно.


        <script>
            var a = <?= json_encode([
                's' => "test <!-- <script>alert(1);",
                't' => "--> </script>",
            ]) ?>;
        </script>
        <script>
            console.log(a);
            // Object { s: "test <!-- <script>alert(1);", t: "--> </script>" }
        </script>


        1. VolCh
          13.02.2018 13:51

          Для DoS атаки вполне годная уязвимость.


  1. MGriboedoff
    12.02.2018 20:50

    Если кто-то может встроить JS в код страницы, используя бекенд… затыкать это надо на стороне бекенда, или я туплю..?


  1. Arris
    12.02.2018 21:37

    Эта рекомендация меня умиляет. Тут делается сразу несколько наивных предположений:

    Эти, как вы выразились, «наивные» предположения строятся на том, что разработчики стандарта ожидают, что вы будете следовать стандарту и соблюдать правила техники безопасности.

    Всегда найдется идиот, который сможет выстрелить себе в ногу. Но соблюдение правил ТБ хотя бы уменьшает их количество.

    Полагать, что разработчики стандарта должны были подумать о естественных идиотах и их проблемах со стрельбой в ногу… наивно. По меньшей мере наивно.


  1. justboris
    12.02.2018 22:13

    В этом случае, как и написано в спецификации, всего-то нужно заменить все "<!--" на "<!--", "<script" на "<\script", а "</script" на "<\/script".

    Можно проще, достаточно заэскейпить символ "<". В документации к Redux есть пример:


    <script>
      window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g, '\\u003c')}
    </script>

    Если этого кажется мало, всегда можно взять более продвинутую библиотеку.


  1. SerafimArts
    12.02.2018 23:23

    Я, конечно, могу сказать глупость, но:


    <script>
    //<![CDATA[
        var a = 'Consider this string: <!--';
        var b = '<script>';
    //]]>
    </script>

    Разве нет?


    1. lastmac
      12.02.2018 23:52

      Зачем гадать?! Спецификация чётко оговаривает как оно должно парситься:
      1) Токенизатор script data stage
      2) Переключатель токенизатора (восьмой пункт)

      Когда-то я рисовал картинку для наглядности
      image


  1. saboteur_kiev
    12.02.2018 23:41

    «В свою очередь, Javascript — это самостоятельный язык с собственным синтаксисом, он, вообще говоря, никаким специальным образом не рассчитан на то, что будет встроен в HTML.»


    То есть язык, разработанный в Mosaic специально для браузера и для работы в вебе, никак не рассчитан на работу в связке с HTML?


    1. homm Автор
      13.02.2018 02:09

      Что вы имеете в виду под работой в связке?


    1. homm Автор
      13.02.2018 02:10

      Что вы имеете в виду под работай в связке? Я написал на что конкретно он не расчитан: на встраивание в HTML.


  1. vladbarcelo
    13.02.2018 02:43

    Поздравляю, вы открыли XSS.


    1. homm Автор
      13.02.2018 12:15
      +1

      Нет, я как раз пытаюсь закрыть XSS


  1. vintage
    13.02.2018 08:43

    Эта и многие другие фундаментальные проблемы html уже решены в xhtml. Но индустрия выбрала вариант стандартизировать говнокод.


  1. qrck13
    13.02.2018 10:30
    -1

    > Как вы уже поняли, способа безопасно вставить Javascript в HTML нет.

    <script source="./script.js" />
    


    1. alix_ginger
      13.02.2018 17:27

      Тогда уж

      <script src="https://domain.tld/script.js"></script>


      1. sumanai
        14.02.2018 18:10

        Забыли защиту от подмены.
        <script src="https://example.com/example-framework.js"
        integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
        crossorigin="anonymous">

        Безопаснее некуда.


  1. Interreto
    13.02.2018 11:07

    безобидный тэг который обычно в списке разрешенных тэгов на равные с , и т.д., но в эпоху популярности чатов, можно было похулиганить — отправляешь открытый тэг, а через 10-20 сообщений закрытый и весь чат начинал «плыть».


  1. sebres
    14.02.2018 00:08

    Правда смотрится это довольно грязно

    Кстати, если уж хакиш-вэй так хочется embedded, то чем вот это вот не устраивает?


    <var data="surprise!</script><script>alert(&quot;whoops!&quot;)</script>">
    </var><script>
      var s = (function(){
        var s = document.currentScript;
        if (!s) {s = document.getElementsByTagName('script'); s = s[s.length-1];}
        return s.previousSibling.getAttribute('data');
      })();
      console.log(s);
    </script>

    Естественно обернув полифилом, нормальной функцией в АПИ и т.д.
    Оно не крадет ID, и работает вроде везде...


  1. apapacy
    14.02.2018 19:18

    Спасибо интересные замечания. Скрипт в теле документа я могу представить только для передачи состояния серверного объекта JSON.stringify/JSON.parse. Но в этом случае лучше просто дополнительно ввести операцию кодировния/декодирования некоторых сущностей. Они абсолютно однозначны тогода будут. И синхронны


    1. apapacy
      14.02.2018 19:59

      Даже вот так как оказалось я где-то закопипастил

      window.__PRELOADED_STATE__ = ${JSON.stringify(store.getState()).replace(/</g, '\\u003c')}