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
30 changes: 30 additions & 0 deletions PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -609,3 +609,33 @@ optional Redis/Memcached.
- lookup by bit: found, 0, several bits, unknown bit;
- `ig_detector_at` out of range;
- registry `detect` gives the same verdicts as the direct functions.

### 1.4 Normalizer: URL decoding, `%u`, NUL
- **Context:**
- PHP has already URL-decoded GET/POST/COOKIE once before the input filter, so `decode_rounds` (default 2) are **extra** rounds that catch double and triple encoding.
- `REQUEST_URI` is raw, so the same rounds give it its first decode.
- A copy is normalized; the original value is not touched.
- **Decision** (`src/core/ig_normalize.{h,c}`):
- **One round:** `ig_url_decode(in, len, out, flags)`.
- `%XX` → byte.
- `%uXXXX` (IIS, a common WAF bypass) → UTF-8, 1–3 bytes; surrogates are not decoded.
- `+` → space only with `IG_DECODE_PLUS` (query/body; it is literal in a URI path).
- Invalid or truncated sequences are copied as is, like PHP does.
- The output is never longer than the input, so an `out` of `len` bytes is enough and in-place decoding is allowed.
- **Normalization:** `ig_normalize(in, len, rounds, flags, out, &stats)`.
- Each round decodes, then removes NUL bytes. NUL is removed in every round because `%%0041` becomes `%41` only after the NUL goes.
- It stops when a round changes nothing or the rounds run out.
- NUL removal also happens with `rounds = 0`.
- **Stats:** `rounds` (rounds that changed the value), `nul_removed`, and `limit_hit` (the value would still decode). The anomaly event for `limit_hit` comes with class 3 (v1.1).
- **No allocation** in the core: the caller provides the buffer (`emalloc` in the Zend layer, phase 4). Stateless and ZTS-safe.
- **Out of scope:** overlong UTF-8 and invalid encodings (class 3), HTML entities (1.5), length limits (1.6).
- **Tests first** (`tests/unit/test_normalize.c`):
- one round:
- hex in both cases; invalid and truncated sequences kept;
- `%u0041`, `%u0410`, `%u20AC`; a surrogate and an invalid `%u` kept;
- `+` with and without the flag; in-place decoding; empty input.
- normalization:
- double encoding; triple encoding with 2 rounds (→ `limit_hit`); early stop;
- 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.
45 changes: 45 additions & 0 deletions PRD.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -1128,3 +1128,48 @@ SEVERITY = "info" | "low" | "medium" | "high" | "critical" ;
- поиск по биту: найден, 0, несколько битов, неизвестный бит;
- `ig_detector_at` вне диапазона;
- `detect` из реестра — та же функция, что `ig_detect_sqli`/`ig_detect_xss`: тот же вердикт на атаке.

### 1.4 Нормализатор: URL-decode, `%u`, NUL
**Контекст:**
- GET, POST и COOKIE PHP уже один раз раскодировал (`urldecode`) до input filter. Поэтому `decode_rounds` (по умолчанию 2) — это **дополнительные** раунды: они ловят двойное и тройное кодирование (`%2527` → `%27` → `'`).
- URI (`REQUEST_URI`) приходит сырым, для него те же раунды дают первое раскодирование.
- Нормализуется копия: исходное значение не меняется (FR-1.3).

**Решение (`src/core/ig_normalize.{h,c}`):**
- **Один раунд:** `size_t ig_url_decode(const char *in, size_t len, char *out, unsigned flags)`.
- `%XX` (hex в любом регистре) → байт.
- `%uXXXX` (IIS/старые IE, частый приём обхода WAF) → кодовая точка в UTF-8, 1–3 байта; суррогаты `D800–DFFF` не раскодируются.
- `+` → пробел только с флагом `IG_DECODE_PLUS`: как `urldecode()` для query и тела. Для пути URI `+` — литерал.
- Невалидные и обрезанные последовательности (`%zz`, `%4` в конце, `%u12`) копируются как есть, ровно как в PHP.
- Результат никогда не длиннее входа: `%XX` даёт 1 байт из 3, `%uXXXX` — не более 3 из 6. Буфер `out` размером `len` достаточен, `out == in` (на месте) допустим.
- **Нормализация:** `ig_normalize(in, len, rounds, flags, out, &stats)` → длина результата.
- Раунд = URL-decode, затем удаление NUL-байтов.
- NUL удаляется в каждом раунде, а не только в конце: иначе `%%0041` → `%`+NUL+`41` → после удаления `%41` — новая последовательность, которую должен увидеть следующий раунд.
- Остановка — когда раунд ничего не изменил или раунды кончились.
- NUL удаляется и при `rounds = 0`.
- **Статистика `ig_norm_stats_t`:**
- `rounds` — сколько раундов изменили значение;
- `nul_removed` — сколько удалено NUL;
- `limit_hit` — после последнего разрешённого раунда значение всё ещё раскодируется.

Глубокое кодирование само по себе подозрительно; событие аномалии будет в классе 3 (v1.1), сейчас флаг только возвращается.
- **Без выделения памяти** в ядре: буфер даёт вызывающий (Zend-слой — `emalloc`, фаза 4). Состояния нет, функции безопасны для ZTS.
- **Вне задачи:**
- overlong UTF-8 (`%c0%af`) и невалидные кодировки — класс 3 (аномалии ввода);
- HTML-сущности — 1.5;
- ограничение длины — 1.6.

**Тесты (red первыми), `tests/unit/test_normalize.c`:**
- Один раунд:
- `%XX` в верхнем и нижнем регистре; невалидные и обрезанные последовательности сохраняются;
- `%u0041` → `A`, `%u0410` → `А` (UTF-8), `%u20AC` → `€`; суррогат и невалидный `%u` сохраняются;
- `+` с флагом и без; раскодирование на месте; пустой вход.
- Нормализация:
- `%2527` при 2 раундах → `'`; тройное кодирование при 2 раундах → `%27` и `limit_hit`;
- ранняя остановка (`rounds` в статистике);
- удаление NUL: сырой байт и `%00`; случай `%%0041`;
- `rounds = 0`; результат не длиннее входа; NULL-аргументы.
- Fuzz-таргет `fuzz_normalize`:
- результат ≤ входа и без NUL;
- на месте = в отдельный буфер;
- один раунд никогда не увеличивает длину.
2 changes: 1 addition & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
- [x] **1.1 [TDD]** `ig_detect_sqli()` wrapper over libinjection, fingerprint in result.
- [x] **1.2 [TDD]** `ig_detect_xss()` wrapper.
- [x] **1.3 [TDD]** Common detector interface and registry.
- [ ] **1.4 [TDD]** Normalizer: repeated URL decoding, NUL removal, `%u` sequences.
- [x] **1.4 [TDD]** Normalizer: repeated URL decoding, NUL removal, `%u` sequences.
- [ ] **1.5 [TDD]** HTML entity decoding for XSS.
- [ ] **1.6 [TDD]** Length limit with `truncated` flag.
- [ ] **1.7 [TDD]** Glob matcher for exclusions.
Expand Down
2 changes: 1 addition & 1 deletion TASKS.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
- [x] **1.1 [TDD]** Обёртка `ig_detect_sqli(const char*, size_t, ig_result_t*)` над libinjection: тесты на классические payload'ы (`' OR 1=1--`, `UNION SELECT`, `; DROP TABLE`) и на безопасные строки. Fingerprint в результате. (FR-2.1)
- [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)
- [ ] **1.4 [TDD]** Нормализатор: повторный URL-decode с лимитом раундов, удаление NUL, `%u`-последовательности. Тесты на двойное кодирование и границы буфера. (FR-1.3)
- [x] **1.4 [TDD]** Нормализатор: повторный URL-decode с лимитом раундов, удаление NUL, `%u`-последовательности. Тесты на двойное кодирование и границы буфера. (FR-1.3)
- [ ] **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)
Expand Down
2 changes: 1 addition & 1 deletion config.m4
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ PHP_ARG_ENABLE([inletguard-dev],
[no], [no])

if test "$PHP_INLETGUARD" != "no"; then
IG_SOURCES="inletguard.c src/core/ig_version.c src/core/ig_detect.c src/core/ig_libinjection.c src/core/ig_detector.c"
IG_SOURCES="inletguard.c src/core/ig_version.c src/core/ig_detect.c src/core/ig_libinjection.c src/core/ig_detector.c src/core/ig_normalize.c"

IG_CFLAGS="-DZEND_ENABLE_STATIC_TSRMLS_CACHE=1 -std=gnu99"
if test "$PHP_INLETGUARD_DEV" != "no"; then
Expand Down
167 changes: 167 additions & 0 deletions src/core/ig_normalize.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/* SPDX-License-Identifier: MIT */

#include "core/ig_normalize.h"

#include <string.h>

static int ig_hex(unsigned char c)
{
if (c >= '0' && c <= '9') {
return c - '0';
}
if (c >= 'a' && c <= 'f') {
return c - 'a' + 10;
}
if (c >= 'A' && c <= 'F') {
return c - 'A' + 10;
}
return -1;
}

/* Value of n hex digits at s, or -1. */
static long ig_hex_n(const char *s, size_t n)
{
long v = 0;
size_t i;

for (i = 0; i < n; i++) {
int d = ig_hex((unsigned char)s[i]);

if (d < 0) {
return -1;
}
v = v * 16 + d;
}
return v;
}

/* Length of the escape at in[i] ('%XX' = 3, '%uXXXX' = 6) and its value in
* *cp; 0 when there is no valid escape. */
static size_t ig_escape_at(const char *in, size_t len, size_t i, long *cp)
{
if (in[i] != '%') {
return 0;
}
if (i + 5 < len && (in[i + 1] == 'u' || in[i + 1] == 'U')) {
*cp = ig_hex_n(in + i + 2, 4);
/* surrogates are not characters */
if (*cp >= 0 && (*cp < 0xD800 || *cp > 0xDFFF)) {
return 6;
}
}
if (i + 2 < len) {
*cp = ig_hex_n(in + i + 1, 2);
if (*cp >= 0) {
return 3;
}
}
return 0;
}

/* Writes code point cp (< 0x10000) as UTF-8; returns the byte count. */
static size_t ig_put_utf8(char *out, long cp)
{
if (cp < 0x80) {
out[0] = (char)cp;
return 1;
}
if (cp < 0x800) {
out[0] = (char)(0xC0 | (cp >> 6));
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;
}

size_t ig_url_decode(const char *in, size_t len, char *out, unsigned int flags)
{
size_t i = 0;
size_t o = 0;

if (len > 0 && (in == NULL || out == NULL)) {
return IG_NORM_ERROR;
}
/* o <= i at every step, so decoding in place is safe */
while (i < len) {
long cp = 0;
size_t esc = ig_escape_at(in, len, i, &cp);

if (esc == 3) {
out[o++] = (char)cp;
} else if (esc == 6) {
o += ig_put_utf8(out + o, cp);
} else if (in[i] == '+' && (flags & IG_DECODE_PLUS)) {
out[o++] = ' ';
esc = 1;
} else {
out[o++] = in[i];
esc = 1;
}
i += esc;
}
return o;
}

/* Would another decoding round change s? */
static int ig_url_decodable(const char *s, size_t len, unsigned int flags)
{
size_t i;

for (i = 0; i < len; i++) {
long cp;

if (ig_escape_at(s, len, i, &cp) != 0 || (s[i] == '+' && (flags & IG_DECODE_PLUS))) {
return 1;
}
}
return 0;
}

static size_t ig_remove_nul(char *s, size_t len, size_t *removed)
{
size_t i;
size_t o = 0;

for (i = 0; i < len; i++) {
if (s[i] != '\0') {
s[o++] = s[i];
}
}
*removed += len - o;
return o;
}

size_t ig_normalize(const char *in, size_t len, unsigned int rounds, unsigned int flags, char *out,
ig_norm_stats_t *stats)
{
ig_norm_stats_t st = {0, 0, 0};
size_t n;
unsigned int r;

if (len > 0 && (in == NULL || out == NULL)) {
if (stats != NULL) {
*stats = st;
}
return IG_NORM_ERROR;
}
if (len > 0 && out != in) {
memmove(out, in, len);
}
n = ig_remove_nul(out, len, &st.nul_removed);

/* A decodable value always changes in a round (an escape shrinks it,
* '+' becomes ' '), so the check also detects a stable value. */
for (r = 0; r < rounds && ig_url_decodable(out, n, flags); r++) {
n = ig_url_decode(out, n, out, flags);
n = ig_remove_nul(out, n, &st.nul_removed);
st.rounds++;
}
st.limit_hit = ig_url_decodable(out, n, flags);
if (stats != NULL) {
*stats = st;
}
return n;
}
33 changes: 33 additions & 0 deletions src/core/ig_normalize.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/* SPDX-License-Identifier: MIT */
/* Normalization of a copy of an input value before detection (FR-1.3).
* No allocation: the caller provides the output buffer. */

#ifndef IG_NORMALIZE_H
#define IG_NORMALIZE_H

#include <stddef.h>

/* '+' decodes to a space (query string and form body, like urldecode()). */
#define IG_DECODE_PLUS (1u << 0)

/* Returned instead of a length on invalid arguments. */
#define IG_NORM_ERROR ((size_t) - 1)

typedef struct ig_norm_stats {
unsigned int rounds; /* rounds that changed the value */
size_t nul_removed; /* NUL bytes removed */
int limit_hit; /* the value would still decode after the last round */
} ig_norm_stats_t;

/* One round of URL decoding: %XX, %uXXXX (to UTF-8) and, with
* IG_DECODE_PLUS, '+'. Invalid sequences are copied as is. The result is
* never longer than len, so out needs len bytes; out may be in. */
size_t ig_url_decode(const char *in, size_t len, char *out, unsigned int flags);

/* Up to `rounds` rounds of URL decoding, each followed by NUL removal; stops
* early when a round changes nothing. NUL is removed even with rounds == 0.
* Same buffer rules as ig_url_decode(); stats may be NULL. */
size_t ig_normalize(const char *in, size_t len, unsigned int rounds, unsigned int flags, char *out,
ig_norm_stats_t *stats);

#endif /* IG_NORMALIZE_H */
1 change: 1 addition & 0 deletions tests/fuzz/corpus/fuzz_normalize/double
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
%2527%2520OR%25201%253D1
1 change: 1 addition & 0 deletions tests/fuzz/corpus/fuzz_normalize/nul
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
%%0041+x
Binary file added tests/fuzz/corpus/fuzz_normalize/percent_u
Binary file not shown.
54 changes: 54 additions & 0 deletions tests/fuzz/fuzz_normalize.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/* SPDX-License-Identifier: MIT */
/* Fuzz target: the normalizer never grows a value, leaves 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;
unsigned int flags;
ig_norm_stats_t st;

if (size == 0) {
return 0;
}
/* the first byte selects the options */
flags = (data[0] & 1u) ? IG_DECODE_PLUS : 0;
data++;
size--;

out = malloc(size + 1);
inplace = malloc(size + 1);
if (out == NULL || inplace == NULL) {
abort();
}

n = ig_url_decode((const char *)data, size, out, flags);
if (n > size) {
abort();
}

n = ig_normalize((const char *)data, size, 3, flags, out, &st);
if (n > size || memchr(out, '\0', n) != NULL || st.rounds > 3) {
abort();
}
memcpy(inplace, data, size);
m = ig_normalize(inplace, size, 3, flags, inplace, NULL);
if (m != n || memcmp(out, inplace, n) != 0) {
abort();
}

free(out);
free(inplace);
return 0;
}
Loading