9ba2a6a49b
Дверь проверяет адрес из настройки. Если разрешённая машина отвечает «иди на другой адрес», перевозчик по умолчанию туда идёт и везёт наш заголовок дальше: при уходе на чужой адрес 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>
240 lines
14 KiB
PHP
240 lines
14 KiB
PHP
<?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`, строки 199–202). Наши `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('');
|
||
});
|