fix(api): доверять X-Real-IP со шлюза docker-сети в IPv4-mapped виде

Node отдаёт адрес сокета как ::ffff:172.31.120.1, а TRUSTED_PROXIES
записан без префикса — все посетители попадали в один rate-limit.
This commit is contained in:
2026-10-17 16:17:24 +03:00
parent 5f31b03203
commit 25246ad0c4
3 changed files with 15 additions and 2 deletions
+6
View File
@@ -39,6 +39,12 @@ describe('clientIp', () => {
expect(clientIp('198.51.100.1', '203.0.113.7', trusted)).toBe('198.51.100.1'); expect(clientIp('198.51.100.1', '203.0.113.7', trusted)).toBe('198.51.100.1');
}); });
it('шлюз docker-сети в IPv4-mapped виде совпадает с записью без префикса', () => {
const withGateway = new Set<string>([...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('без заголовка — адрес сокета', () => { it('без заголовка — адрес сокета', () => {
expect(clientIp('127.0.0.1', undefined, trusted)).toBe('127.0.0.1'); expect(clientIp('127.0.0.1', undefined, trusted)).toBe('127.0.0.1');
expect(clientIp(undefined, '203.0.113.7', trusted)).toBe('unknown'); expect(clientIp(undefined, '203.0.113.7', trusted)).toBe('unknown');
+8 -2
View File
@@ -46,7 +46,13 @@ export function clientIp(
realIpHeader: string | undefined, realIpHeader: string | undefined,
trustedProxies: ReadonlySet<string>, trustedProxies: ReadonlySet<string>,
): string { ): string {
const socket = socketAddress && unmapIpv4(socketAddress);
const realIp = realIpHeader?.trim(); const realIp = realIpHeader?.trim();
if (socketAddress && trustedProxies.has(socketAddress) && realIp) return realIp; if (socket && trustedProxies.has(socket) && realIp) return unmapIpv4(realIp);
return socketAddress ?? 'unknown'; 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;
} }
+1
View File
@@ -20,3 +20,4 @@
| 14 | Шаг 10, бэкапы | Копия из `db.backup()` унаследовала режим WAL: в архив попадала WAL-база, рядом с временными копиями оставались `-wal`/`-shm` | Тест `backup.test.ts` на чистоту временного каталога | Временная копия переводится в `journal_mode = DELETE` перед gzip | | 14 | Шаг 10, бэкапы | Копия из `db.backup()` унаследовала режим WAL: в архив попадала WAL-база, рядом с временными копиями оставались `-wal`/`-shm` | Тест `backup.test.ts` на чистоту временного каталога | Временная копия переводится в `journal_mode = DELETE` перед gzip |
| 15 | Шаг 9, тесты | Тест «без CHAT_ID» передавал `undefined`, и деструктуризация подставляла значение по умолчанию — тест проверял не то | Сам тест повёл себя не так, как ожидалось | `null` вместо `undefined` | | 15 | Шаг 9, тесты | Тест «без CHAT_ID» передавал `undefined`, и деструктуризация подставляла значение по умолчанию — тест проверял не то | Сам тест повёл себя не так, как ожидалось | `null` вместо `undefined` |
| 16 | План, восстановление | В плане пауза приёма включалась после снимка pre-restore — заявка, пришедшая во время gzip снимка, пропала бы | Агент api при реализации | Пауза до снимка, ожидание опустошения очереди Telegram | | 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 адресов + тест; на сервере проверено пробным контейнером |