Шпаргалка. Сначала ответ, потом объяснение — читать подряд не обязательно.
Всё, что здесь написано, проверено эмпирически: System.Web.HttpUtility 10.0.0 (та же сборка,
что в сервере), @angular/router@21.2.16, Node. Не по памяти.
Это то, ради чего файл. Остальное — объяснение, почему таблица именно такая.
| Куда кладём значение | Кто и в каком порядке декодирует | Чем кодировать |
|---|---|---|
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 зеркальные. Это и есть главная причина, по которой всё это не запоминается как одно правило: два похожих на вид случая требуют противоположного порядка.
Проследить, какой парсер трогает эти байты первым, и кодировать в обратном порядке.
Порядок декодирования — свойство стока, а не универсальная константа. Поэтому не пытайся вспомнить «как правильно»; посмотри, куда конкретно едет значение, и выведи заново. С якорем из §3 это занимает секунд двадцать.
HTML-парсер внутри <script> не декодирует сущности вообще. Тег обрабатывается в script
data state, character references там не потребляются; единственная задача парсера — найти
</script. Всё содержимое уходит в JS-движок как есть.
<script>var s = """;</script>s — это шесть символов ", а не кавычка.
Контраст, который и создаёт зеркальность строк 3-4 таблицы: значения атрибутов сущности
декодируются нормально. Поэтому onclick="…" ведёт себя принципиально иначе, чем <script>,
хотя внутри обоих лежит JavaScript.
Практическое следствие: HTML-кодирование внутри <script> не работает ни в какую сторону —
оно не декодируется и не защищает. Прилетела сырая " — она закроет литерал, и " этому
никак не помешает. Нужен \u0022 или \".
Общего «базового текстового декодера» не существует. Это ключевая развилка, на которой обычно и ломается интуиция.
| Запись | Чья нотация | Кто её снимает | Как её видит чужой парсер |
|---|---|---|---|
" |
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" === "&" // trueHTML-парсер делает ровно то же со своей нотацией: шесть символов " в разметке → один
символ U+0022 в текстовом узле DOM.
После декодирования происхождение стирается. Кавычка, полученная из \u0022, из
" или написанная литералом, в памяти неразличима — но пришли они туда разными
маршрутами и в разные моменты.
Поэтому «опасно / безопасно» — вообще не про запись. Оно про то, сколько парсеров осталось впереди у уже раскодированного символа:
\u0022→ JS отдаёт живую". Если впереди ещё HTML-парсер (строка поедет вinnerHTML), кавычка сработает там как разделитель."в JS-исходнике → JS не трогает, отдаёт шесть символов. Впереди HTML-парсер, и он превратит это в кавычку-текст, за которой парсеров уже не осталось. Ломать нечего.
Отложенное декодирование = обезвреженное.
Роли у слоёв разные, и это снимает всю путаницу:
HtmlEncodeделает значение безвредным для конечного стока. Кавычка становится сущностью, которая отрисуется текстом.JsEncodeповерх защиты не добавляет вообще. Его единственная работа — довезти эту запись до конечного стока нетронутой, мимо парсера, который стоит раньше и норовит её съесть.
Это не «два слоя защиты». Это защита плюс транспорт.
Нет промежуточного парсера — нет второго слоя.
"\u0026quot;" === """ // trueJS не собирался трогать ", прятать не от кого. \u0026 вырождается в шум.
<a onclick="myFunction('<%= JsEncode(userValue) %>')">Здесь HTML-декодер атрибута стоит перед JS. Пользователь подсовывает ":
JsEncodeего не трогает — это не кавычка, это пять букв и точка с запятой;- HTML-парсер декодирует
"→"ровно перед тем, как за дело возьмётся JS-лексер; - литерал закрыт, дальше произвольный код.
Первый декодировщик изготовил синтаксис для второго. Спасает экранирование амперсанда:
\u0026quot; не содержит символа & вообще, HTML-парсеру не за что зацепиться, запись
доезжает целой и JS раскрывает её в безобидную строку ".
Отсюда вывод, который стоит запомнить отдельно: JavaScriptEncoder.Default экранирует &
не «с запасом», а именно чтобы его вывод переживал предшествующее HTML-декодирование.
Внутри <script> JsEncode всё же не полностью бесполезен: переводы строк и U+2028/U+2029
рвут строковый литерал, а HtmlEncode их не трогает. Но к истории с " это отношения не
имеет — отдельная работа.
HttpUtility.HtmlEncode кодирует ровно пять символов:
'"' -> " '&' -> & ''' -> ' '<' -> < '>' -> >
Больше ничего, включая не-ASCII. Это отличие от .NET Framework, где диапазон 160-255 уходил в
числовые сущности. ООО «Ромашка» остаётся ООО «Ромашка» — кириллица не тронута.
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.FromBase64String → Base64UrlEncode(bytes), это даст 264 символа вместо 352. Для
пути всё равно много, для query не нужно. Поэтому проще не трогать.
Не менять HttpUtility.HtmlEncode на HtmlEncoder.Default из System.Text.Encodings.Web.
На ссылки результат идентичен, выигрыша нет, а кириллицу он энтитит целиком:
HttpUtility : ООО «Ромашка»
HtmlEncoder.Def : ООО «Ромашка»
Отрендерится одинаково, но тело письма распухает и становится нечитаемым в логах.
Двойного кодирования в письмах нет. UserEmailTemplatesSettingHelper
(TehnoInnovaLk.Shared/DbContext/Enums/) подставляет каждый %placeholder% через
HttpUtility.HtmlEncode ровно один раз — так же, как hardcoded-ветка в
CustomMessageEmailSender. Проверено, когда искали причину поломки из §7.
Инлайновые обработчики — единственная причина складывать два кодировщика в одну строку.
data--атрибут + addEventListener убирает необходимость целиком: декодировщик остаётся один.
Симптом. Часть почтовых клиентов не деэкранирует & в <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, а не по совпадению алфавитов.
Он не защищает от схемы javascript: в href. Кодирование тут не помогает в принципе —
нужна валидация схемы. Не считай HtmlEncode защитой от того, чем он не занимается.