Files
portal/app/tests/Feature/Security/KlyuchNeEdetPoPereadresaciiTest.php
T
Дмитрий 9ba2a6a49b fix(безопасность): за переадресацией не ходим — тайный ключ больше не уезжает чужому
Дверь проверяет адрес из настройки. Если разрешённая машина отвечает «иди на
другой адрес», перевозчик по умолчанию туда идёт и везёт наш заголовок дальше:
при уходе на чужой адрес Guzzle снимает только Authorization и Cookie, а
X-Render-Key и X-Obzvon-Key не из них.

Замерено до правки на подставных машинах: ключ рендера доехал до неразрешённой
машины из ленты Яндекса, из поиска клиентов одиночным запросом и пачкой, из
плитки прокси — и плитка при этом горела зелёным, показывая ЧУЖОЙ IP как свой.
Ключ голосового робота уезжал вместе с заданием.

Поставлен withoutRedirecting в пяти строениях запроса четырёх файлов. У
SelfRenderClient их два: пачка строит запрос сама, и запрет из одиночного пути
туда не доезжает — проверено вырезанием, снятие запрета только с пачки роняет
отдельного сторожа и даёт по утечке на каждый адрес порции.

Новых настроек не заводил: тумблер «сюда переадресовывать можно» и был бы той
дырой, ради которой запрет ставится.

Сторожа: app/tests/Feature/Security/KlyuchNeEdetPoPereadresaciiTest.php, 8
проверок, смотрят на запись переговоров, а не на текст исходника. Каждый показан
красным снятием своего запрета по одному. Отдельная проверка «датчик сам жив»
падает, если подставная сеть перестанет ходить за переадресацией, — иначе
остальные сторожа молча стали бы пустыми.

Отчёт смены: docs/superpowers/priyomka/stroyka-7/otchyot-pomoshchnika-zapret-pereadresacii-2026-08-07.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:37:22 +03:00

240 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Services\Autopodbor\Agent\Fetch\RenderServiceYandexListSource;
use App\Services\Autopodbor\Agent\Fetch\SelfRenderClient;
use App\Services\External\ProxyMarketProbe;
use App\Services\Obzvon\ObzvonClient;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
/**
* 🔴 Сторожа одного класса беды: РАЗРЕШЁННАЯ машина отвечает «иди на другой адрес», и наш
* клиент послушно едет туда — вместе с тайным ключом в заголовке.
*
* Дверь (разрешённый хост + шифрованная дорога) проверяет адрес ИЗ НАСТРОЙКИ. Про второй
* адрес, который прилетел ответом, она ничего не знает и знать не может. А перевозчик Guzzle
* при уходе на чужой адрес снимает ровно два заголовка — `Authorization` и `Cookie`
* (`vendor/guzzlehttp/guzzle/src/RedirectMiddleware.php`, строки 199202). Наши `X-Render-Key`
* и `X-Obzvon-Key` не из них и едут дальше.
*
* 🪤 Чего эти сторожа НАМЕРЕННО не делают: не ищут слов в тексте исходника. Сторож, читающий
* файл, находит нужное слово в собственном пояснении и зеленеет при сломанном коде — этим в
* проекте уже платили. Здесь смотрят на РАБОТУ: куда физически ушёл запрос и что он вёз.
*
* 🪤 Второе: сторож обязан УМЕТЬ покраснеть. Ниже есть отдельная проверка `датчик сам жив`,
* которая доказывает, что подставная сеть за переадресацией ИДЁТ. Погасни она — все остальные
* сторожа этого файла станут пустыми, и она скажет об этом вслух, а не молча.
*/
/** Разрешённая машина: она в списке, дорога шифрованная — дверь её пропускает. */
const SVOYA = 'svoya-vm.test';
/** Чужая машина: в списке её НЕТ. Ключ не имеет права появиться здесь ни разу. */
const CHUZHAYA = 'chuzhaya-vm.test';
const KLYUCH_RENDERA = 'tainyy-klyuch-renderа-777';
const KLYUCH_OBZVONA = 'tainyy-klyuch-robota-777';
beforeEach(function () {
// Ни один сторож этого файла не имеет права уйти в настоящую сеть.
Http::preventStrayRequests();
});
/**
* Подставная сеть: разрешённая машина отвечает «иди на чужую», чужая отвечает успехом.
*
* Чужая отвечает именно УСПЕХОМ намеренно: если запрет не встал, поход закончится хорошо и
* зелёный результат сам по себе беду не покажет. Показывает её только запись переговоров.
*/
function setPereadresuyushchaya(): void
{
Http::fake([
SVOYA.'/*' => Http::response('', 302, ['Location' => 'http://'.CHUZHAYA.'/ukradi']),
CHUZHAYA.'/*' => Http::response(['data' => 'ukradeno', 'ok' => true, 'ip' => '1.2.3.4'], 200),
]);
}
/**
* Записи всех обращений, дошедших до ЧУЖОЙ машины, — с указанием, вёз ли запрос тайный ключ.
*
* @return list<array{url:string,klyuch:string}>
*/
function dokudaDoehalKlyuch(string $imyaZagolovka): array
{
return Http::recorded()
->map(fn (array $para): Request => $para[0])
->filter(fn (Request $z): bool => str_contains((string) parse_url($z->url(), PHP_URL_HOST), CHUZHAYA))
->map(fn (Request $z): array => [
'url' => $z->url(),
'klyuch' => $z->hasHeader($imyaZagolovka) ? $z->header($imyaZagolovka)[0] : '',
])
->values()
->all();
}
// ─────────────────────────────────────────────────────────────────────────────
// 0. 🔴 ДАТЧИК САМ ЖИВ. Без этой проверки все остальные сторожа файла могли бы
// зеленеть просто потому, что подставная сеть переадресацию не отрабатывает.
// ─────────────────────────────────────────────────────────────────────────────
it('датчик сам жив: подставная сеть ИДЁТ за переадресацией и везёт заголовок дальше', function () {
setPereadresuyushchaya();
// Это НЕ наш код — это голый перевозчик. Проверяем сам измерительный прибор.
Http::withHeaders(['X-Render-Key' => KLYUCH_RENDERA])->get('https://'.SVOYA.'/render');
expect(dokudaDoehalKlyuch('X-Render-Key'))->toBe([
['url' => 'http://'.CHUZHAYA.'/ukradi', 'klyuch' => KLYUCH_RENDERA],
]);
});
// ─────────────────────────────────────────────────────────────────────────────
// 1. Поиск клиентов, одиночный запрос (SelfRenderClient::html)
// ─────────────────────────────────────────────────────────────────────────────
it('поиск клиентов, одиночный запрос: за переадресацией не идём, ключ остаётся дома', function () {
setPereadresuyushchaya();
$klient = new SelfRenderClient(
endpoint: 'https://'.SVOYA.'/render',
key: KLYUCH_RENDERA,
retries: 1,
razreshyonnyeHosty: [SVOYA],
);
$stranica = $klient->html('https://2gis.ru/x');
// 🔴 Главная проверка — первой: ключ не имеет права оказаться на чужой машине.
expect(dokudaDoehalKlyuch('X-Render-Key'))->toBe([]);
// И чужой ответ не выдан за нашу страницу.
expect($stranica)->toBe('');
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. 🔴 Поиск клиентов, отправка ПАЧКОЙ (Http::pool). У пачки своё построение
// запроса — «поставил в одном месте, встало и в другом» тут не работает.
// ─────────────────────────────────────────────────────────────────────────────
it('поиск клиентов, отправка пачкой: за переадресацией не идём, ключ остаётся дома', function () {
setPereadresuyushchaya();
$klient = new SelfRenderClient(
endpoint: 'https://'.SVOYA.'/render',
key: KLYUCH_RENDERA,
concurrency: 4,
retries: 1,
razreshyonnyeHosty: [SVOYA],
);
$out = $klient->htmlBatch(['https://2gis.ru/a', 'https://2gis.ru/b']);
expect(dokudaDoehalKlyuch('X-Render-Key'))->toBe([]);
expect($out)->toBe(['https://2gis.ru/a' => '', 'https://2gis.ru/b' => '']);
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Лента Яндекс.Карт (RenderServiceYandexListSource) — тот же ключ, та же дверь
// ─────────────────────────────────────────────────────────────────────────────
it('лента Яндекса: за переадресацией не идём, ключ остаётся дома', function () {
setPereadresuyushchaya();
$istochnik = new RenderServiceYandexListSource(
endpoint: 'https://'.SVOYA.'/render',
key: KLYUCH_RENDERA,
razreshyonnyeHosty: [SVOYA],
);
$lenta = $istochnik->fetch('https://yandex.ru/maps/org/1', 3);
expect(dokudaDoehalKlyuch('X-Render-Key'))->toBe([]);
expect($lenta)->toBe([]);
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. Плитка живости прокси (ProxyMarketProbe) — третья дверь на том же ключе
// ─────────────────────────────────────────────────────────────────────────────
it('плитка прокси: за переадресацией не идём, ключ остаётся дома', function () {
setPereadresuyushchaya();
config()->set('services.proxy_market.check_url', 'https://'.SVOYA.'/proxy-check');
config()->set('services.proxy_market.expires_at', now()->addDays(30)->format('Y-m-d'));
config()->set('services.self_render.key', KLYUCH_RENDERA);
config()->set('services.self_render.allowed_hosts', [SVOYA]);
$chtenie = (new ProxyMarketProbe)->check();
expect(dokudaDoehalKlyuch('X-Render-Key'))->toBe([]);
// 🔴 И плитка не врёт зелёным: 302 — это не «ок».
expect($chtenie->light)->not->toBe('green');
});
it('плитка прокси НЕ выдаёт чужой ответ за свой — зелёного по переадресации не бывает', function () {
setPereadresuyushchaya();
config()->set('services.proxy_market.check_url', 'https://'.SVOYA.'/proxy-check');
config()->set('services.proxy_market.expires_at', now()->addDays(30)->format('Y-m-d'));
config()->set('services.self_render.key', KLYUCH_RENDERA);
config()->set('services.self_render.allowed_hosts', [SVOYA]);
$chtenie = (new ProxyMarketProbe)->check();
// Чужая машина отдаёт `{"ok":true,"ip":"1.2.3.4"}` — ровно тот ответ, от которого
// плитка загорелась бы зелёным и показала владельцу ЧУЖОЙ IP как свой.
expect($chtenie->detail)->not->toContain('1.2.3.4');
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. Дверь к голосовому роботу (ObzvonClient) — другой ключ, та же беда
// ─────────────────────────────────────────────────────────────────────────────
it('голосовой робот: за переадресацией не идём, ключ остаётся дома', function () {
setPereadresuyushchaya();
config()->set('services.obzvon.allowed_hosts', [SVOYA]);
$klient = new ObzvonClient(
endpoint: 'https://'.SVOYA.'/obzvon',
key: KLYUCH_OBZVONA,
concurrency: 4,
waitSeconds: 0,
connectTimeout: 5,
timeout: 15,
razreshyonnyeHosty: [SVOYA],
);
$klient->pozvonit(tenantId: 1, campaignId: 7, telefon: '+7 999 123-45-67', popytkaNo: 1);
expect(dokudaDoehalKlyuch('X-Obzvon-Key'))->toBe([]);
});
// ─────────────────────────────────────────────────────────────────────────────
// 6. 🔴 Переадресация с шифрованной дороги на НЕшифрованную — отдельная беда:
// ключ уехал бы открытым текстом даже без смены машины.
// ─────────────────────────────────────────────────────────────────────────────
it('переадресация с https на http к той же машине не увозит ключ открытым текстом', function () {
Http::fake([
'https://'.SVOYA.'/*' => Http::response('', 302, ['Location' => 'http://'.SVOYA.'/render']),
'http://'.SVOYA.'/*' => Http::response(['data' => 'otkrytym-tekstom'], 200),
]);
$klient = new SelfRenderClient(
endpoint: 'https://'.SVOYA.'/render',
key: KLYUCH_RENDERA,
retries: 1,
razreshyonnyeHosty: [SVOYA],
);
$stranica = $klient->html('https://2gis.ru/x');
$pohttp = Http::recorded()
->map(fn (array $para): Request => $para[0])
->filter(fn (Request $z): bool => str_starts_with($z->url(), 'http://'))
->count();
expect($pohttp)->toBe(0);
expect($stranica)->toBe('');
});