From 25246ad0c49cebbfe5c6f7b2fe335d2fe8decf3d Mon Sep 17 00:00:00 2001 From: Nikolay Unuchek Date: Sat, 17 Oct 2026 16:17:24 +0300 Subject: [PATCH] =?UTF-8?q?fix(api):=20=D0=B4=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20X-Real-IP=20=D1=81=D0=BE=20=D1=88=D0=BB?= =?UTF-8?q?=D1=8E=D0=B7=D0=B0=20docker-=D1=81=D0=B5=D1=82=D0=B8=20=D0=B2?= =?UTF-8?q?=20IPv4-mapped=20=D0=B2=D0=B8=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node отдаёт адрес сокета как ::ffff:172.31.120.1, а TRUSTED_PROXIES записан без префикса — все посетители попадали в один rate-limit. --- apps/api/src/rateLimit.test.ts | 6 ++++++ apps/api/src/rateLimit.ts | 10 ++++++++-- docs/ai-log.md | 1 + 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/api/src/rateLimit.test.ts b/apps/api/src/rateLimit.test.ts index ddf2452..281545e 100644 --- a/apps/api/src/rateLimit.test.ts +++ b/apps/api/src/rateLimit.test.ts @@ -39,6 +39,12 @@ describe('clientIp', () => { expect(clientIp('198.51.100.1', '203.0.113.7', trusted)).toBe('198.51.100.1'); }); + it('шлюз docker-сети в IPv4-mapped виде совпадает с записью без префикса', () => { + const withGateway = new Set([...LOOPBACK, '172.31.120.1']); + expect(clientIp('::ffff:172.31.120.1', '203.0.113.7', withGateway)).toBe('203.0.113.7'); + expect(clientIp('::ffff:198.51.100.1', '203.0.113.7', withGateway)).toBe('198.51.100.1'); + }); + it('без заголовка — адрес сокета', () => { expect(clientIp('127.0.0.1', undefined, trusted)).toBe('127.0.0.1'); expect(clientIp(undefined, '203.0.113.7', trusted)).toBe('unknown'); diff --git a/apps/api/src/rateLimit.ts b/apps/api/src/rateLimit.ts index a324893..ec53fc5 100644 --- a/apps/api/src/rateLimit.ts +++ b/apps/api/src/rateLimit.ts @@ -46,7 +46,13 @@ export function clientIp( realIpHeader: string | undefined, trustedProxies: ReadonlySet, ): string { + const socket = socketAddress && unmapIpv4(socketAddress); const realIp = realIpHeader?.trim(); - if (socketAddress && trustedProxies.has(socketAddress) && realIp) return realIp; - return socketAddress ?? 'unknown'; + if (socket && trustedProxies.has(socket) && realIp) return unmapIpv4(realIp); + return socket || 'unknown'; +} + +// Node на dual-stack сокете отдаёт IPv4 как «::ffff:172.31.120.1» — в TRUSTED_PROXIES пишут без префикса. +function unmapIpv4(address: string): string { + return address.startsWith('::ffff:') && address.includes('.') ? address.slice(7) : address; } diff --git a/docs/ai-log.md b/docs/ai-log.md index 3ee69c7..0d2ebae 100644 --- a/docs/ai-log.md +++ b/docs/ai-log.md @@ -20,3 +20,4 @@ | 14 | Шаг 10, бэкапы | Копия из `db.backup()` унаследовала режим WAL: в архив попадала WAL-база, рядом с временными копиями оставались `-wal`/`-shm` | Тест `backup.test.ts` на чистоту временного каталога | Временная копия переводится в `journal_mode = DELETE` перед gzip | | 15 | Шаг 9, тесты | Тест «без CHAT_ID» передавал `undefined`, и деструктуризация подставляла значение по умолчанию — тест проверял не то | Сам тест повёл себя не так, как ожидалось | `null` вместо `undefined` | | 16 | План, восстановление | В плане пауза приёма включалась после снимка pre-restore — заявка, пришедшая во время gzip снимка, пропала бы | Агент api при реализации | Пауза до снимка, ожидание опустошения очереди Telegram | +| 17 | Шаг 5, прод | `TRUSTED_PROXIES=172.31.120.1` не срабатывал: Node отдаёт адрес сокета как `::ffff:172.31.120.1`, и все посетители снова делили один лимит. Тесты этого не ловили — в них адреса без префикса | Проверка на проде: 6 запросов с X-Real-IP «A», затем «B» получил 429 | Нормализация IPv4-mapped адресов + тест; на сервере проверено пробным контейнером |