Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `&#0`), 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 `j` 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.
29 changes: 29 additions & 0 deletions PRD.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 символов `&#0`), значение каждой именованной сущности короче её записи (проверяется тестом). Буфер `out` размером `len` достаточен, на месте допустимо.
- **Один проход.** Двойное кодирование (`j`) браузер раскрывает один раз, и мы тоже. Повторное декодирование не делается: приложение, раскодирующее дважды, — редкость, а лишний проход превращает безопасный текст в разметку.
- Без выделения памяти и состояния, безопасно для 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 не появляется.
2 changes: 1 addition & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
- [x] **1.2 [TDD]** `ig_detect_xss()` wrapper.
- [x] **1.3 [TDD]** Common detector interface and registry.
- [x] **1.4 [TDD]** Normalizer: repeated URL decoding, NUL removal, `%u` sequences.
- [ ] **1.5 [TDD]** HTML entity decoding for XSS.
- [x] **1.5 [TDD]** HTML entity decoding for XSS.
- [ ] **1.6 [TDD]** Length limit with `truncated` flag.
- [ ] **1.7 [TDD]** Glob matcher for exclusions.
- [ ] **1.8 [TDD]** Corpus runner with accuracy thresholds (NFR-5).
Expand Down
2 changes: 1 addition & 1 deletion TASKS.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
- [x] **1.2 [TDD]** Обёртка `ig_detect_xss(...)`: `<script>`, `onerror=`, `javascript:`, `<svg onload>` vs обычный текст и безопасный HTML (`<b>`). (FR-2.2)
- [x] **1.3 [TDD]** Единый интерфейс детектора `ig_detector_t` (имя, битовая маска, функция) и реестр детекторов. (FR-2.4)
- [x] **1.4 [TDD]** Нормализатор: повторный URL-decode с лимитом раундов, удаление NUL, `%u`-последовательности. Тесты на двойное кодирование и границы буфера. (FR-1.3)
- [ ] **1.5 [TDD]** Нормализатор для XSS: декодирование HTML-сущностей (именованные, `&#x..;`, `&#..;`, без `;`). (FR-1.3)
- [x] **1.5 [TDD]** Нормализатор для XSS: декодирование HTML-сущностей (именованные, `&#x..;`, `&#..;`, без `;`). (FR-1.3)
- [ ] **1.6 [TDD]** Ограничение длины: значения > `max_len` проверяются по префиксу, флаг `truncated` в результате. (FR-1.4)
- [ ] **1.7 [TDD]** Glob-матчер для исключений (`*`, экранирование, регистр). (FR-5.4)
- [ ] **1.8 [TDD]** Корпус-раннер: `tests/corpus/attacks/{sqli,xss}.txt`, `tests/corpus/benign/*.txt` (имена, адреса, тексты на русском/английском, JSON, SQL-подобный легитимный текст, HTML из WYSIWYG). Тест падает, если метрики ниже NFR-5.
Expand Down
186 changes: 181 additions & 5 deletions src/core/ig_normalize.c
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ static size_t ig_escape_at(const char *in, size_t len, size_t i, long *cp)
return 0;
}

/* Writes code point cp (< 0x10000) as UTF-8; returns the byte count. */
/* Writes code point cp (<= 0x10FFFF) as UTF-8; returns the byte count. */
static size_t ig_put_utf8(char *out, long cp)
{
if (cp < 0x80) {
Expand All @@ -70,10 +70,17 @@ static size_t ig_put_utf8(char *out, long cp)
out[1] = (char)(0x80 | (cp & 0x3F));
return 2;
}
out[0] = (char)(0xE0 | (cp >> 12));
out[1] = (char)(0x80 | ((cp >> 6) & 0x3F));
out[2] = (char)(0x80 | (cp & 0x3F));
return 3;
if (cp < 0x10000) {
out[0] = (char)(0xE0 | (cp >> 12));
out[1] = (char)(0x80 | ((cp >> 6) & 0x3F));
out[2] = (char)(0x80 | (cp & 0x3F));
return 3;
}
out[0] = (char)(0xF0 | (cp >> 18));
out[1] = (char)(0x80 | ((cp >> 12) & 0x3F));
out[2] = (char)(0x80 | ((cp >> 6) & 0x3F));
out[3] = (char)(0x80 | (cp & 0x3F));
return 4;
}

size_t ig_url_decode(const char *in, size_t len, char *out, unsigned int flags)
Expand Down Expand Up @@ -165,3 +172,172 @@ size_t ig_normalize(const char *in, size_t len, unsigned int rounds, unsigned in
}
return n;
}

static const ig_html_entity_t ig_entities[] = {
{"amp", "&", 1},
{"AMP", "&", 1},
{"lt", "<", 1},
{"LT", "<", 1},
{"gt", ">", 1},
{"GT", ">", 1},
{"quot", "\"", 1},
{"QUOT", "\"", 1},
{"nbsp", "\xc2\xa0", 1},
{"apos", "'", 0},
{"colon", ":", 0},
{"lpar", "(", 0},
{"rpar", ")", 0},
{"Tab", "\t", 0},
{"NewLine", "\n", 0},
{"sol", "/", 0},
{"bsol", "\\", 0},
{"grave", "`", 0},
{"equals", "=", 0},
{"semi", ";", 0},
{"comma", ",", 0},
{"period", ".", 0},
{"excl", "!", 0},
{"quest", "?", 0},
{"num", "#", 0},
{"percnt", "%", 0},
{"plus", "+", 0},
{"ast", "*", 0},
{"commat", "@", 0},
{"lowbar", "_", 0},
{"verbar", "|", 0},
{"vert", "|", 0},
{"lsqb", "[", 0},
{"lbrack", "[", 0},
{"rsqb", "]", 0},
{"rbrack", "]", 0},
{"lcub", "{", 0},
{"lbrace", "{", 0},
{"rcub", "}", 0},
{"rbrace", "}", 0},
{"dollar", "$", 0},
{"Hat", "^", 0},
};

#define IG_ENTITY_COUNT (sizeof(ig_entities) / sizeof(ig_entities[0]))

const ig_html_entity_t *ig_html_entities(size_t *count)
{
if (count != NULL) {
*count = IG_ENTITY_COUNT;
}
return ig_entities;
}

static int ig_is_alnum(unsigned char c)
{
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
}

/* Numeric reference at in[i] ("&#..."): length consumed and code point in
* *cp; 0 when there are no digits. */
static size_t ig_numeric_ref(const char *in, size_t len, size_t i, long *cp)
{
size_t j = i + 2;
size_t digits = 0;
int base = 10;
long v = 0;

if (j < len && (in[j] == 'x' || in[j] == 'X')) {
base = 16;
j++;
}
for (; j < len; j++, digits++) {
int d = ig_hex((unsigned char)in[j]);

if (base == 10 && d > 9) {
d = -1;
}
if (d < 0) {
break;
}
/* saturate: any value above 10FFFF is invalid anyway */
v = v > 0x10FFFF ? v : v * base + d;
}
if (digits == 0) {
return 0;
}
if (j < len && in[j] == ';') {
j++;
}
/* HTML5: NUL, surrogates and out-of-range values -> U+FFFD */
if (v == 0 || v > 0x10FFFF || (v >= 0xD800 && v <= 0xDFFF)) {
v = 0xFFFD;
}
*cp = v;
return j - i;
}

/* Named reference at in[i] ("&name"): length consumed and the entity in
* *ent; 0 when the name is not known. */
static size_t ig_named_ref(const char *in, size_t len, size_t i, const ig_html_entity_t **ent)
{
size_t run = 0;
size_t k;
size_t best = 0;

while (i + 1 + run < len && ig_is_alnum((unsigned char)in[i + 1 + run])) {
run++;
}
for (k = 0; k < IG_ENTITY_COUNT; k++) {
size_t nl = strlen(ig_entities[k].name);

if (nl > run || memcmp(in + i + 1, ig_entities[k].name, nl) != 0) {
continue;
}
if (nl == run && i + 1 + nl < len && in[i + 1 + nl] == ';') {
*ent = &ig_entities[k];
return nl + 2;
}
/* legacy names also end without ';': the longest prefix wins */
if (ig_entities[k].legacy && nl + 1 > best) {
*ent = &ig_entities[k];
best = nl + 1;
}
}
return best;
}

size_t ig_html_decode(const char *in, size_t len, char *out)
{
size_t i = 0;
size_t o = 0;

if (len > 0 && (in == NULL || out == NULL)) {
return IG_NORM_ERROR;
}
/* every reference is at least as long as its value, so o <= i */
while (i < len) {
size_t ref = 0;

if (in[i] == '&' && i + 1 < len) {
long cp = 0;
const ig_html_entity_t *ent = NULL;

if (in[i + 1] == '#') {
ref = ig_numeric_ref(in, len, i, &cp);
if (ref > 0) {
o += ig_put_utf8(out + o, cp);
}
} else {
ref = ig_named_ref(in, len, i, &ent);
if (ref > 0) {
size_t vl = strlen(ent->value);

memmove(out + o, ent->value, vl);
o += vl;
}
}
}
if (ref == 0) {
out[o++] = in[i];
ref = 1;
}
i += ref;
}
return o;
}
17 changes: 17 additions & 0 deletions src/core/ig_normalize.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,21 @@ size_t ig_url_decode(const char *in, size_t len, char *out, unsigned int flags);
size_t ig_normalize(const char *in, size_t len, unsigned int rounds, unsigned int flags, char *out,
ig_norm_stats_t *stats);

/* A named character reference: `name` without '&' and ';'. Legacy names
* are also recognised without the ';'. */
typedef struct ig_html_entity {
const char *name;
const char *value; /* UTF-8 */
int legacy;
} ig_html_entity_t;

/* The named references ig_html_decode() knows: the attack-relevant subset of
* HTML5. Sets *count. */
const ig_html_entity_t *ig_html_entities(size_t *count);

/* One pass of HTML character reference decoding: &#N, &#xH (';' optional,
* invalid code points -> U+FFFD) and the names of ig_html_entities().
* Anything else is copied as is. Same buffer rules as ig_url_decode(). */
size_t ig_html_decode(const char *in, size_t len, char *out);

#endif /* IG_NORMALIZE_H */
1 change: 1 addition & 0 deletions tests/fuzz/corpus/fuzz_html_decode/edge
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
&#x10FFFF;&#0;&#xD800;&#99999999999;&#x;&#
1 change: 1 addition & 0 deletions tests/fuzz/corpus/fuzz_html_decode/legacy
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
&lt;script&gt;&ltb&gt&amp;#x6a;
1 change: 1 addition & 0 deletions tests/fuzz/corpus/fuzz_html_decode/numeric_named
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
&#106;avascript&colon;alert&lpar;1&rpar;
44 changes: 44 additions & 0 deletions tests/fuzz/fuzz_html_decode.c
Original file line number Diff line number Diff line change
@@ -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 <stdint.h>
#include <stdlib.h>
#include <string.h>

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;
}
Loading