From 90a218d548d4a46a96429dc9f27ae76c3ab68bb9 Mon Sep 17 00:00:00 2001 From: hightemp Date: Thu, 22 Oct 2026 20:00:40 +0300 Subject: [PATCH 1/3] docs: plan HTML entity decoding (TASKS 1.5) Co-Authored-By: Claude Opus 5.5 --- PRD.md | 25 +++++++++++++++++++++++++ PRD.ru.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/PRD.md b/PRD.md index a12222c..faf8aa3 100644 --- a/PRD.md +++ b/PRD.md @@ -639,3 +639,28 @@ optional Redis/Memcached. - NUL raw and `%00`; `%%0041`; - `rounds = 0`; the output is never longer; NULL arguments. - Fuzz target `fuzz_normalize`: output ≤ input and NUL-free, in-place = separate buffer, a round never grows the value. + +### 1.5 HTML entity decoding for XSS +- **Context:** + - Browsers decode character references in attribute values before parsing a URL, so `href="javascript:alert(1)"` and `java script:alert(1)` run, while `ig_detect_xss` misses them in the raw value (note to 1.2). + - Pipeline order (wired in phases 2/4): URL normalization (1.4) → HTML decoding → `ig_detect_xss` on the decoded copy. This also catches `%26%23106%3B` → `j` → `j`. +- **Decision** (`src/core/ig_normalize.{h,c}`): `size_t ig_html_decode(const char *in, size_t len, char *out)`, one pass, HTML5 character reference rules. + - **Numeric:** `&#` + decimal digits or `&#x`/`&#X` + hex digits; `;` optional; any number of leading zeros. + - The code point is written as UTF-8 (1–4 bytes). + - `0`, surrogates and values above `10FFFF` (including overflow) become U+FFFD, as in browsers, so no NUL appears. + - No digits (`&#;`, `&#x;`) → copied as is. The windows-1252 mapping of `€`–`Ÿ` is out of scope. + - **Named:** a subset of the 2231 HTML5 names that yields attack-relevant characters. + - Legacy names also accepted without `;`: `amp`, `lt`, `gt`, `quot` (and `AMP`, `LT`, `GT`, `QUOT`), `nbsp`. + - `;` required: `apos`, `colon`, `lpar`, `rpar`, `Tab`, `NewLine`, `sol`, `bsol`, `grave`, `equals`, `semi`, `comma`, `period`, `excl`, `quest`, `num`, `percnt`, `plus`, `ast`, `commat`, `lowbar`, `verbar`, `vert`, `lsqb`, `lbrack`, `rsqb`, `rbrack`, `lcub`, `lbrace`, `rcub`, `rbrace`, `dollar`, `Hat`. + - Case-sensitive as in HTML5; unknown names are copied as is. + - The attribute rule (a legacy name without `;` before an alphanumeric or `=` is not decoded) is not applied: the context is unknown and the detector should see more. The false-positive cost is measured by the corpus in 1.8. + - **The output is never longer than the input:** a numeric reference yields no more bytes than it occupies (U+FFFD is 3 bytes from at least `�`), and each named value is shorter than its name (checked by a test). An `out` of `len` bytes is enough; in-place decoding is allowed. + - **One pass.** Browsers expand `&#106;` once, and so do we; a second pass would turn safe text into markup. + - No allocation, no state, ZTS-safe. +- **Tests first** (`tests/unit/test_html_decode.c`): + - numeric: decimal and hex, both `x` cases, with and without `;`, leading zeros, multi-byte UTF-8; + - U+FFFD for `�`, a surrogate, `�` and overflow; no digits → kept; + - named: legacy with and without `;`, `;`-only names not decoded without `;`, case, unknown name, trailing `&`, `&<`; + - in place, empty input, NULL arguments; table: every value shorter than its name; + - with `ig_detect_xss`: `javascript:alert(1)`, `java script:alert(1)`, `<script>…` are ATTACK after decoding; `Tom & Jerry` is clean. + - Fuzz target `fuzz_html_decode`: output ≤ input, in place = separate buffer, no new NUL. diff --git a/PRD.ru.md b/PRD.ru.md index 3361ee9..f4a05d0 100644 --- a/PRD.ru.md +++ b/PRD.ru.md @@ -1173,3 +1173,32 @@ SEVERITY = "info" | "low" | "medium" | "high" | "critical" ; - результат ≤ входа и без NUL; - на месте = в отдельный буфер; - один раунд никогда не увеличивает длину. + +### 1.5 Декодирование HTML-сущностей для XSS +**Контекст:** +- Браузер раскодирует сущности в значениях атрибутов до разбора URL. Поэтому `href="javascript:alert(1)"` и `java script:alert(1)` исполняются, а `ig_detect_xss` по сырому значению их не видит (заметка к 1.2). +- Порядок в конвейере проверки (подключение — фазы 2/4): URL-нормализация (1.4) → HTML-декодирование → `ig_detect_xss` по раскодированной копии. Так ловится и `%26%23106%3B` → `j` → `j`. + +**Решение (`src/core/ig_normalize.{h,c}`):** +- `size_t ig_html_decode(const char *in, size_t len, char *out)` — один проход, по правилам HTML5 для ссылок на символы. +- **Числовые ссылки:** `&#` + десятичные цифры или `&#x`/`&#X` + hex-цифры, `;` необязательна, ведущие нули допустимы в любом количестве. + - Результат — кодовая точка в UTF-8 (1–4 байта). + - `0`, суррогаты `D800–DFFF` и значения > `10FFFF` (в том числе переполнение длинной записью) → U+FFFD, как в браузерах. NUL при этом не появляется. + - Без цифр (`&#;`, `&#x;`, `&#xg`) — копируется как есть. + - Таблица windows-1252 для `€`–`Ÿ` вне задачи: эти символы не участвуют в атаках. +- **Именованные сущности** — не вся таблица HTML5 (2231 имя), а подмножество, дающее символы, значимые для атак: + - legacy-имена, которые браузер принимает и без `;`: `amp`, `lt`, `gt`, `quot` (и `AMP`, `LT`, `GT`, `QUOT`), `nbsp`; + - только с `;`: `apos`, `colon`, `lpar`, `rpar`, `Tab`, `NewLine`, `sol`, `bsol`, `grave`, `equals`, `semi`, `comma`, `period`, `excl`, `quest`, `num`, `percnt`, `plus`, `ast`, `commat`, `lowbar`, `verbar`, `vert`, `lsqb`, `lbrack`, `rsqb`, `rbrack`, `lcub`, `lbrace`, `rcub`, `rbrace`, `dollar`, `Hat`. + - Имена чувствительны к регистру, как в HTML5. Неизвестное имя копируется как есть. + - Правило атрибутов (legacy-имя без `;` перед буквой, цифрой или `=` не раскодируется) не применяется: контекст неизвестен, детектор должен видеть больше. Цена в ложных срабатываниях измеряется корпусом в 1.8. +- **Результат никогда не длиннее входа:** числовая ссылка даёт не больше байт, чем занимает (U+FFFD — 3 байта из минимум 3 символов `�`), значение каждой именованной сущности короче её записи (проверяется тестом). Буфер `out` размером `len` достаточен, на месте допустимо. +- **Один проход.** Двойное кодирование (`&#106;`) браузер раскрывает один раз, и мы тоже. Повторное декодирование не делается: приложение, раскодирующее дважды, — редкость, а лишний проход превращает безопасный текст в разметку. +- Без выделения памяти и состояния, безопасно для ZTS. + +**Тесты (red первыми), `tests/unit/test_html_decode.c`:** +- числовые: десятичные и hex, оба регистра `x`, с `;` и без, ведущие нули, многобайтовый UTF-8 (`А`, `€`, `😀`); +- U+FFFD для `�`, суррогата, `�` и переполнения; без цифр — как есть; +- именованные: legacy с `;` и без, только-с-`;` без `;` не раскодируется, регистр, неизвестное имя, `&` в конце, `&<`; +- на месте, пустой вход, NULL-аргументы; таблица: значение короче записи; +- связка с `ig_detect_xss`: `javascript:alert(1)`, `java script:alert(1)`, `<script>alert(1)</script>` после декодирования — ATTACK; `Tom & Jerry` — чисто. +- Fuzz-таргет `fuzz_html_decode`: результат ≤ входа, на месте = в отдельный буфер, NUL не появляется. From 0e4b3784a0c9539832446b033623d0295dbcf4ff Mon Sep 17 00:00:00 2001 From: hightemp Date: Thu, 22 Oct 2026 20:01:26 +0300 Subject: [PATCH 2/3] test: HTML entity decoding (red) Co-Authored-By: Claude Opus 5.5 --- tests/fuzz/corpus/fuzz_html_decode/edge | 1 + tests/fuzz/corpus/fuzz_html_decode/legacy | 1 + .../corpus/fuzz_html_decode/numeric_named | 1 + tests/fuzz/fuzz_html_decode.c | 44 +++++ tests/unit/test_html_decode.c | 181 ++++++++++++++++++ 5 files changed, 228 insertions(+) create mode 100644 tests/fuzz/corpus/fuzz_html_decode/edge create mode 100644 tests/fuzz/corpus/fuzz_html_decode/legacy create mode 100644 tests/fuzz/corpus/fuzz_html_decode/numeric_named create mode 100644 tests/fuzz/fuzz_html_decode.c create mode 100644 tests/unit/test_html_decode.c diff --git a/tests/fuzz/corpus/fuzz_html_decode/edge b/tests/fuzz/corpus/fuzz_html_decode/edge new file mode 100644 index 0000000..e381b4c --- /dev/null +++ b/tests/fuzz/corpus/fuzz_html_decode/edge @@ -0,0 +1 @@ +􏿿���&#x;&# \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_html_decode/legacy b/tests/fuzz/corpus/fuzz_html_decode/legacy new file mode 100644 index 0000000..15094be --- /dev/null +++ b/tests/fuzz/corpus/fuzz_html_decode/legacy @@ -0,0 +1 @@ +<script><b>&#x6a; \ No newline at end of file diff --git a/tests/fuzz/corpus/fuzz_html_decode/numeric_named b/tests/fuzz/corpus/fuzz_html_decode/numeric_named new file mode 100644 index 0000000..21d2920 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_html_decode/numeric_named @@ -0,0 +1 @@ +javascript:alert(1) \ No newline at end of file diff --git a/tests/fuzz/fuzz_html_decode.c b/tests/fuzz/fuzz_html_decode.c new file mode 100644 index 0000000..b46db10 --- /dev/null +++ b/tests/fuzz/fuzz_html_decode.c @@ -0,0 +1,44 @@ +/* SPDX-License-Identifier: MIT */ +/* Fuzz target: HTML decoding never grows a value, introduces no NUL and gives + * the same result in place and into a separate buffer. */ + +#include "core/ig_normalize.h" + +#include +#include +#include + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size); + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + char *out; + char *inplace; + size_t n; + size_t m; + + out = malloc(size + 1); + inplace = malloc(size + 1); + if (out == NULL || inplace == NULL) { + abort(); + } + + n = ig_html_decode((const char *)data, size, out); + if (n > size) { + abort(); + } + if (memchr(data, '\0', size) == NULL && memchr(out, '\0', n) != NULL) { + abort(); + } + if (size > 0) { + memcpy(inplace, data, size); + } + m = ig_html_decode(inplace, size, inplace); + if (m != n || memcmp(out, inplace, n) != 0) { + abort(); + } + + free(out); + free(inplace); + return 0; +} diff --git a/tests/unit/test_html_decode.c b/tests/unit/test_html_decode.c new file mode 100644 index 0000000..94e816e --- /dev/null +++ b/tests/unit/test_html_decode.c @@ -0,0 +1,181 @@ +/* SPDX-License-Identifier: MIT */ +/* HTML character reference decoding for XSS detection (FR-1.3). */ + +#include "ig_test.h" +#include "core/ig_detect.h" +#include "core/ig_normalize.h" + +#include + +/* html : one pass into a separate buffer */ +static int html(const char *in, const char *want) +{ + char out[128]; + size_t n = ig_html_decode(in, strlen(in), out); + + if (n != strlen(want) || memcmp(out, want, n) != 0) { + ig_test_fail(__FILE__, __LINE__, "html \"%s\": got \"%.*s\" (%zu), expected \"%s\"", in, + (int)(n < sizeof(out) ? n : 0), out, n, want); + return 0; + } + return 1; +} + +/* xss_after_decode : ig_detect_xss on the decoded copy */ +static ig_verdict_t xss_after_decode(const char *in) +{ + char out[128]; + ig_result_t res; + size_t n = ig_html_decode(in, strlen(in), out); + + return ig_detect_xss(out, n, &res); +} + +IG_TEST(html_decode_decimal_references) +{ + if (!html("javascript:", "javascript:")) { + return; + } + if (!html("<>", "<>")) { + return; + } + if (!html("j'", "j'")) { + return; + } + html("А€", "\xd0\x90\xe2\x82\xac"); /* "А€" */ +} + +IG_TEST(html_decode_hex_references) +{ + if (!html("jjj", "jjj")) { + return; + } + if (!html("<", "<")) { + return; + } + if (!html("€", "\xe2\x82\xac")) { + return; + } + html("😀", "\xf0\x9f\x98\x80"); +} + +IG_TEST(html_decode_invalid_code_points_become_replacement) +{ + if (!html("�", "\xef\xbf\xbd")) { + return; + } + if (!html("�", "\xef\xbf\xbd")) { + return; + } + if (!html("�", "\xef\xbf\xbd")) { + return; + } + if (!html("�", "\xef\xbf\xbd")) { + return; + } + html("􏿿", "\xf4\x8f\xbf\xbf"); +} + +IG_TEST(html_decode_keeps_references_without_digits) +{ + if (!html("&#;&#x;&#xg;&#a", "&#;&#x;&#xg;&#a")) { + return; + } + html("&#", "&#"); +} + +IG_TEST(html_decode_legacy_names_with_and_without_semicolon) +{ + if (!html("<script>", "