Skip to content

Latest commit

 

History

History
240 lines (165 loc) · 16.3 KB

File metadata and controls

240 lines (165 loc) · 16.3 KB

Кодирование под контекст: HTML / JS / URL

Шпаргалка. Сначала ответ, потом объяснение — читать подряд не обязательно.

Всё, что здесь написано, проверено эмпирически: System.Web.HttpUtility 10.0.0 (та же сборка, что в сервере), @angular/router@21.2.16, Node. Не по памяти.


1. Таблица стоков

Это то, ради чего файл. Остальное — объяснение, почему таблица именно такая.

Куда кладём значение Кто и в каком порядке декодирует Чем кодировать
HTML-текст, обычный атрибут (title=, alt=) HTML HtmlEncode
внутрь <script>, в строковый литерал только JS (HTML не заглядывает в raw text) JsEncode
onclick="f('…')" и прочие инлайн-обработчики HTML → JS JsEncode, снаружи HtmlEncode
JS-строка, которая потом уйдёт в innerHTML JS (при загрузке), позже HTML (в рантайме) HtmlEncode, снаружи JsEncode
href / src HTML → разбор URL UrlEncode значения, снаружи HtmlEncode

Строки 3 и 4 зеркальные. Это и есть главная причина, по которой всё это не запоминается как одно правило: два похожих на вид случая требуют противоположного порядка.


2. Метод вместо зубрёжки

Проследить, какой парсер трогает эти байты первым, и кодировать в обратном порядке.

Порядок декодирования — свойство стока, а не универсальная константа. Поэтому не пытайся вспомнить «как правильно»; посмотри, куда конкретно едет значение, и выведи заново. С якорем из §3 это занимает секунд двадцать.


3. Якорь: <script> — raw text

HTML-парсер внутри <script> не декодирует сущности вообще. Тег обрабатывается в script data state, character references там не потребляются; единственная задача парсера — найти </script. Всё содержимое уходит в JS-движок как есть.

<script>var s = "&quot;";</script>

s — это шесть символов &quot;, а не кавычка.

Контраст, который и создаёт зеркальность строк 3-4 таблицы: значения атрибутов сущности декодируются нормально. Поэтому onclick="…" ведёт себя принципиально иначе, чем <script>, хотя внутри обоих лежит JavaScript.

Практическое следствие: HTML-кодирование внутри <script> не работает ни в какую сторону — оно не декодируется и не защищает. Прилетела сырая " — она закроет литерал, и &quot; этому никак не помешает. Нужен \u0022 или \".


4. Каждая нотация принадлежит одному языку

Общего «базового текстового декодера» не существует. Это ключевая развилка, на которой обычно и ломается интуиция.

Запись Чья нотация Кто её снимает Как её видит чужой парсер
&quot; HTML HTML-парсер JS: шесть обычных букв
\u0022 JS / JSON JS-лексер HTML: шесть обычных букв
литеральная " ничья никто, декодировать нечего

Что значит «декодирует»

В исходнике лежат шесть символов \, u, 0, 0, 2, 6. Лексер при разборе заменяет их на один символ в значении строки — в JS это одна UTF-16 единица 0x0026 в буфере. Преобразование одноразовое, происходит в момент парсинга:

"\u0026".length        // 1
"\u0026".charCodeAt(0) // 38 = 0x26
"\u0026" === "&"       // true

HTML-парсер делает ровно то же со своей нотацией: шесть символов &quot; в разметке → один символ U+0022 в текстовом узле DOM.

Следствие, которое надо унести

После декодирования происхождение стирается. Кавычка, полученная из \u0022, из &quot; или написанная литералом, в памяти неразличима — но пришли они туда разными маршрутами и в разные моменты.

Поэтому «опасно / безопасно» — вообще не про запись. Оно про то, сколько парсеров осталось впереди у уже раскодированного символа:

  • \u0022 → JS отдаёт живую ". Если впереди ещё HTML-парсер (строка поедет в innerHTML), кавычка сработает там как разделитель.
  • &quot; в JS-исходнике → JS не трогает, отдаёт шесть символов. Впереди HTML-парсер, и он превратит это в кавычку-текст, за которой парсеров уже не осталось. Ломать нечего.

Отложенное декодирование = обезвреженное.


5. Зачем второй слой и когда он пустышка

Роли у слоёв разные, и это снимает всю путаницу:

  • HtmlEncode делает значение безвредным для конечного стока. Кавычка становится сущностью, которая отрисуется текстом.
  • JsEncode поверх защиты не добавляет вообще. Его единственная работа — довезти эту запись до конечного стока нетронутой, мимо парсера, который стоит раньше и норовит её съесть.

Это не «два слоя защиты». Это защита плюс транспорт.

Нет промежуточного парсера — нет второго слоя.

Вырожденный случай: внутри <script> второй слой бесполезен

"\u0026quot;" === "&quot;"   // true

JS не собирался трогать &quot;, прятать не от кого. \u0026 вырождается в шум.

Несущий случай: onclick

<a onclick="myFunction('<%= JsEncode(userValue) %>')">

Здесь HTML-декодер атрибута стоит перед JS. Пользователь подсовывает &quot;:

  • JsEncode его не трогает — это не кавычка, это пять букв и точка с запятой;
  • HTML-парсер декодирует &quot;" ровно перед тем, как за дело возьмётся JS-лексер;
  • литерал закрыт, дальше произвольный код.

Первый декодировщик изготовил синтаксис для второго. Спасает экранирование амперсанда: \u0026quot; не содержит символа & вообще, HTML-парсеру не за что зацепиться, запись доезжает целой и JS раскрывает её в безобидную строку &quot;.

Отсюда вывод, который стоит запомнить отдельно: JavaScriptEncoder.Default экранирует & не «с запасом», а именно чтобы его вывод переживал предшествующее HTML-декодирование.

Сноска

Внутри <script> JsEncode всё же не полностью бесполезен: переводы строк и U+2028/U+2029 рвут строковый литерал, а HtmlEncode их не трогает. Но к истории с &quot; это отношения не имеет — отдельная работа.


6. Проверенные факты по этому репозиторию

HttpUtility.HtmlEncode кодирует ровно пять символов:

'"' -> &quot;    '&' -> &amp;    ''' -> &#39;    '<' -> &lt;    '>' -> &gt;

Больше ничего, включая не-ASCII. Это отличие от .NET Framework, где диапазон 160-255 уходил в числовые сущности. ООО «Ромашка» остаётся ООО &#171;Ромашка&#187; — кириллица не тронута.

Base64Url с этим набором не пересекается. Алфавит A-Za-z0-9-_ (плюс = при паддинге) проходит через HtmlEncode без единого изменения, как и URL целиком: ?, =, /, :, . в список не входят. Токены в ссылках из писем безопасны by construction.

Но в путь такой токен не помещается. Сегмент пути ограничен драйвером http.sys в 260 символов (UrlSegmentMaxLength). Превышение даёт HTTP 400 Bad Request - Invalid URL от ядра — до IIS и до Kestrel, поэтому в логах приложения будет пусто, а ответ придёт в разметке времён HTML 4.01.

Токен подтверждения email занимает 352 символа: Identity выдаёт DataProtector-строку в 264 символа (префикс CfDJ8), а код кодирует её в base64 повторно — это ×4/3.

Вывод: длинные токены живут в query, не в пути. Там действуют MaxFieldLength и MaxRequestBytes по 16384 байта — запас в сорок раз. Поднимать лимит через реестр (HKLM\SYSTEM\CurrentControlSet\Services\HTTP\Parameters\UrlSegmentMaxLength) — плохая идея: настройка машинная, её придётся ставить на каждом сервере, и она не спасёт, если перед приложением встанет прокси со своими лимитами.

Если query-параметр остаётся ровно один, & в ссылке нет, и проблема из §7 не возникает — то есть «токен в query» и «защита от почтовых клиентов» не противоречат друг другу.

Двойное кодирование токена наивно не убирать. DataProtector-строка содержит +, /, = (видно в декодированном виде: CfDJ8APvjdH+YtpPq72hy/v5…). Без Base64UrlEncode символ + в query декодируется как пробел и токен разваливается. Корректный способ сократить — Convert.FromBase64StringBase64UrlEncode(bytes), это даст 264 символа вместо 352. Для пути всё равно много, для query не нужно. Поэтому проще не трогать.

Не менять HttpUtility.HtmlEncode на HtmlEncoder.Default из System.Text.Encodings.Web. На ссылки результат идентичен, выигрыша нет, а кириллицу он энтитит целиком:

HttpUtility     : ООО &#171;Ромашка&#187;
HtmlEncoder.Def : &#x41E;&#x41E;&#x41E; &#xAB;&#x420;&#x43E;&#x43C;&#x430;&#x448;&#x43A;&#x430;&#xBB;

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

Двойного кодирования в письмах нет. UserEmailTemplatesSettingHelper (TehnoInnovaLk.Shared/DbContext/Enums/) подставляет каждый %placeholder% через HttpUtility.HtmlEncode ровно один раз — так же, как hardcoded-ветка в CustomMessageEmailSender. Проверено, когда искали причину поломки из §7.

Инлайновые обработчики — единственная причина складывать два кодировщика в одну строку. data--атрибут + addEventListener убирает необходимость целиком: декодировщик остаётся один.


7. Случай с письмами (2026-07, чтобы узнать симптом в следующий раз)

Симптом. Часть почтовых клиентов не деэкранирует &amp; в <a href> и отправляет его на сервер как есть. Второй и последующие query-параметры приезжают с именем amp;userName вместо userName, компонент видит их как отсутствующие и уходит на главную.

Диагностический признак. Первый параметр всегда выживает — перед ним нет &. Поэтому поломка выглядит капризной: /confirmEmail терял code, но не userId.

Чего это НЕ было. Не двойное кодирование на нашей стороне (см. §6) и не «что-то из base64 проскочило через HtmlEncode» — алфавит с набором HtmlEncode не пересекается. Целиком поведение клиента.

Решение. Разбирать сырой URI отдельным экземпляром AmpTolerantUrlSerializer по месту, без глобальной подмены UrlSerializer, снимая префикс amp; с имён параметров.

Ловушка, которую надо помнить при правках этого класса: UrlTree._queryParamMap мемоизируется лениво (_queryParamMap ??= convertToParamMap(this.queryParams)), поэтому переприсваивать tree.queryParams можно только до первого обращения к tree.queryParamMap. Отладочный console.log(tree.queryParamMap) выше по коду тихо заморозит кеш на неисправленных ключах, и фикс перестанет работать, продолжая компилироваться.

Альтернатива, если тема всплывёт снова: упаковать все параметры в один (?t=<base64url(json)>). Тогда & в ссылке нет вообще, и никакое количество проходов кодирования её не испортит. Надёжность by construction, а не по совпадению алфавитов.


8. Чем HtmlEncode не является

Он не защищает от схемы javascript: в href. Кодирование тут не помогает в принципе — нужна валидация схемы. Не считай HtmlEncode защитой от того, чем он не занимается.