Files
portal/app/tests/Unit/Autopodbor/Fetch/DverRenderaTest.php
T
Дмитрий 9247281688 fix поиск клиентов: дверь к рендер-виртуалке — разрешённые машины и обязательное шифрование, Р126
Тайный ключ SELF_RENDER_KEY уходил заголовком X-Render-Key на любой адрес, какой окажется
в настройке. Проверялось ровно одно: адрес не пуст и ключ не пуст. Отсюда две молчаливые беды:
опечатка в SELF_RENDER_ENDPOINT увела бы ключ чужому человеку навсегда и без признаков,
а http:// увёз бы его открытым текстом.

Дверь повторяет проверенное устройство Р123 от голосового робота: своя причина на пустой
адрес и на пустой ключ, список разрешённых машин с пустым списком как «закрыто всё»,
обязательное https кроме своей машины, а «своя машина» — два независимых ответа: окружение
из положительного списка И двоичная проверка петли 127.0.0.0/8, ::1, localhost по RFC 6761.
Закрытая дверь = мягкая деградация: наружу не ходим, отдаём пусто, причина в журнал уровнем
warning, потому что на боевом всё ниже не доезжает.

Тумблера «можно без шифра» нет намеренно: он и был бы той самой опечаткой.

Дыр было ДВЕ, а не одна. Тот же ключ и тот же SELF_RENDER_ENDPOINT уходят из
RenderServiceYandexListSource — лента Яндекс.Карт. Починка только названного файла оставила
бы дыру открытой, поэтому дверь общая на обе.

Боевой поиск не сломается: умолчание списка НЕ пустое и содержит замеренный боевой адрес
виртуалки 51-250-1-97.sslip.io — пустое умолчание остановило бы поиск в момент выката.
Набор тот же, что владелец уже принял для OBZVON_ALLOWED_HOSTS: это одна и та же машина.
Прогнаны все формы адреса, какие могут стоять в боевой настройке, в окружении production.

Проверок 37, сторожа показаны красными пятью отдельными сломами, возврат доказан слепками.
Larastan 0, Pint чисто.

Не закрыто и ждёт слова владельца: ProxyMarketProbe шлёт тот же ключ на другую настройку
без двери; боевое значение SELF_RENDER_ENDPOINT не замерено, на боевой не ходил.

Отчёт: docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-r126-dver-rendera-2026-08-07.md
2026-08-07 08:04:35 +03:00

262 lines
15 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\Providers\AutopodborServiceProvider;
use App\Services\Autopodbor\Agent\Fetch\DverRendera;
use App\Services\Autopodbor\Agent\Fetch\RenderServiceYandexListSource;
use App\Services\Autopodbor\Agent\Fetch\SelfRenderClient;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
// Р126. Дверь к рендер-виртуалке: тайный ключ SELF_RENDER_KEY едет ЗАГОЛОВКОМ X-Render-Key в
// каждом запросе. Опечатка в адресе увела бы его чужому молча и навсегда; `http://` увёз бы его
// открытым текстом. Устройство повторяет проверенную дверь к голосовому роботу (Р123).
/** Хосты, куда разрешено в этих проверках. Боевой список — в config/services.php. */
const HOSTY = ['render.local', '51-250-1-97.sslip.io'];
function dver(string $endpoint, string $key = 'secret', ?array $hosty = null): DverRendera
{
return new DverRendera($endpoint, $key, $hosty ?? HOSTY);
}
// ─────────────────────────────────────────────────────────────────────────────
// Две причины «нет настройки» — каждая СВОИМИ словами, а не одним «не готово»
// ─────────────────────────────────────────────────────────────────────────────
it('пустой адрес — закрыта, и сказано именно про адрес', function () {
expect(dver('')->pochemuZakryta())->toContain('не задан адрес');
});
it('пустой ключ — закрыта, и сказано именно про ключ', function () {
expect(dver('https://render.local/render', '')->pochemuZakryta())->toContain('не задан тайный ключ');
});
it('пробелы вокруг пустого адреса не считаются адресом', function () {
expect(dver(' ')->pochemuZakryta())->toContain('не задан адрес');
});
// ─────────────────────────────────────────────────────────────────────────────
// Разрешённые машины. Пустой список = закрыто ВСЁ
// ─────────────────────────────────────────────────────────────────────────────
it('пустой список разрешённых машин закрывает даже правильный адрес', function () {
expect(dver('https://render.local/render', 'secret', [])->pochemuZakryta())
->toContain('неразрешённый хост');
});
it('чужой хост закрыт, и хост назван вслух', function () {
expect(dver('https://render.local.zloumyshlennik.ru/render')->pochemuZakryta())
->toContain('render.local.zloumyshlennik.ru');
});
it('хост сверяется без оглядки на регистр букв', function () {
expect(dver('https://RENDER.LOCAL/render')->pochemuZakryta())->toBe('');
});
it('хост из списка, записанного заглавными, тоже узнаётся', function () {
expect(dver('https://render.local/render', 'secret', ['RENDER.LOCAL'])->pochemuZakryta())->toBe('');
});
// ─────────────────────────────────────────────────────────────────────────────
// 🔴 ВОСЕМЬ ФОРМ АДРЕСА — та же приёмка, что прошла дверь голосового робота
// ─────────────────────────────────────────────────────────────────────────────
it('восемь форм адреса разбираются ровно так, как решил владелец', function (string $adres, bool $otkryta) {
expect(dver($adres)->pochemuZakryta() === '')->toBe($otkryta);
})->with([
'https обычный' => ['https://render.local/render', true],
'HTTPS большими буквами' => ['HTTPS://render.local/render', true],
'https с портом' => ['https://render.local:8790/render', true],
'https с пробелами по краям' => [' https://render.local/render ', true],
'http — ключ уехал бы открытым' => ['http://render.local/render', false],
'httpsX — не https' => ['httpsX://render.local/render', false],
'http, а https внутри пути' => ['http://render.local/https://render', false],
'вовсе без схемы' => ['render.local/render', false],
]);
it('боевой адрес виртуалки дверь ПРОПУСКАЕТ', function () {
expect(dver('https://51-250-1-97.sslip.io/render')->pochemuZakryta())->toBe('');
});
it('про http сказано именно «без шифрования», а не «чужой хост»', function () {
expect(dver('http://render.local/render')->pochemuZakryta())
->toContain('без шифрования')
->toContain('открытым текстом');
});
// ─────────────────────────────────────────────────────────────────────────────
// Своя машина — ДВА независимых ответа, и оба обязаны совпасть
// ─────────────────────────────────────────────────────────────────────────────
// 🪤 IPv6 в списке разрешённых пишется В СКОБКАХ — ровно так его отдаёт разбор адреса.
// Замерено: без скобок хост не узнаётся. Так же устроена и проверенная дверь голосового
// робота; расхождение между двумя дверями было бы хуже этой шероховатости.
it('на своей машине http на петлю разрешён', function (string $adres) {
expect(dver($adres, 'secret', ['127.0.0.1', 'localhost', '127.0.1.1', '[::1]', '[::ffff:127.0.0.1]', 'moy.localhost'])
->pochemuZakryta())->toBe('');
})->with([
'http://127.0.0.1:8790/render',
'http://127.0.1.1:8790/render',
'http://localhost:8790/render',
'http://moy.localhost:8790/render',
'http://[::1]:8790/render',
'http://[::ffff:127.0.0.1]:8790/render',
]);
it('localhost внутри чужого имени петлёй НЕ считается', function () {
expect(dver('http://localhost.chuzhoy.ru/render', 'secret', ['localhost.chuzhoy.ru'])->pochemuZakryta())
->toContain('без шифрования');
});
it('на боевом окружении петля НЕ спасает — http закрыт', function () {
app()->detectEnvironment(fn () => 'production');
expect(dver('http://127.0.0.1:8790/render', 'secret', ['127.0.0.1'])->pochemuZakryta())
->toContain('без шифрования');
});
it('на боевом окружении https на разрешённый хост по-прежнему открыт', function () {
app()->detectEnvironment(fn () => 'production');
expect(dver('https://51-250-1-97.sslip.io/render')->pochemuZakryta())->toBe('');
});
// ─────────────────────────────────────────────────────────────────────────────
// Мягкая деградация клиента: наружу НЕ ходим, отдаём пусто, причина в журнал
// ─────────────────────────────────────────────────────────────────────────────
function klientSDveryu(string $endpoint, array $hosty = HOSTY): SelfRenderClient
{
return new SelfRenderClient(
endpoint: $endpoint,
key: 'secret',
concurrency: 4,
retries: 2,
razreshyonnyeHosty: $hosty,
);
}
it('закрытая дверь: html не ходит наружу и отдаёт пусто', function () {
Http::fake();
expect(klientSDveryu('http://render.local/render')->html('https://2gis.ru/x'))->toBe('');
Http::assertNothingSent();
});
it('закрытая дверь: htmlBatch не ходит наружу и отдаёт пусто на каждый адрес', function () {
Http::fake();
$out = klientSDveryu('https://chuzhoy.ru/render')->htmlBatch(['https://2gis.ru/a', 'https://2gis.ru/b']);
expect($out)->toBe(['https://2gis.ru/a' => '', 'https://2gis.ru/b' => '']);
Http::assertNothingSent();
});
it('закрытая дверь пишет причину в журнал уровнем не ниже предупреждения', function () {
Http::fake();
Log::shouldReceive('warning')->once()->withArgs(
fn (string $soobshchenie) => str_contains($soobshchenie, 'без шифрования')
);
klientSDveryu('http://render.local/render')->html('https://2gis.ru/x');
});
it('в журнал не попадает тайный ключ', function () {
Http::fake();
Log::shouldReceive('warning')->once()->withArgs(
fn (string $soobshchenie, array $context = []) => ! str_contains($soobshchenie.json_encode($context), 'secret')
);
klientSDveryu('http://render.local/render')->html('https://2gis.ru/x');
});
it('открытая дверь по-прежнему ходит наружу — сегодняшняя работа не изменилась', function () {
Http::fake(['https://render.local/render' => Http::response(['data' => '{"state":1}'], 200)]);
expect(klientSDveryu('https://render.local/render')->html('https://2gis.ru/x'))->toBe('{"state":1}');
Http::assertSent(fn ($r) => $r->hasHeader('X-Render-Key', 'secret'));
});
// ─────────────────────────────────────────────────────────────────────────────
// Вторая дверь на том же ключе — лента Яндекс.Карт
// ─────────────────────────────────────────────────────────────────────────────
it('лента Яндекса: закрытая дверь не ходит наружу и отдаёт пусто', function () {
Http::fake();
$istochnik = new RenderServiceYandexListSource(
endpoint: 'http://render.local/render',
key: 'secret',
httpTimeout: 5,
razreshyonnyeHosty: HOSTY,
);
expect($istochnik->fetch('https://yandex.ru/maps/x', 2))->toBe([]);
Http::assertNothingSent();
});
// ─────────────────────────────────────────────────────────────────────────────
// 🔴🔴 ГЛАВНОЕ: сегодняшний БОЕВОЙ поиск обязан продолжать работать
//
// Эти проверки не выдумывают список хостов, а берут НАСТОЯЩЕЕ умолчание из
// config/services.php — то самое, что поедет на боевой. Если кто-нибудь опустошит
// умолчание или уберёт передачу списка из провайдера, поиск клиентов на боевом встанет
// в момент выката. Здесь это станет красным ДО выката, а не после.
// ─────────────────────────────────────────────────────────────────────────────
/** Боевой адрес виртуалки-рендера. Замер: ПИЛОТ.md 06.07.2026, «прод её достаёт». */
const BOEVOY = 'https://51-250-1-97.sslip.io/render';
it('умолчание разрешённых машин НЕ пусто — иначе боевой поиск встал бы в момент выката', function () {
expect((array) config('services.self_render.allowed_hosts'))->not->toBeEmpty();
});
it('боевой адрес виртуалки проходит дверь на НАСТОЯЩЕМ умолчании настроек', function () {
expect(
(new DverRendera(BOEVOY, 'secret', (array) config('services.self_render.allowed_hosts')))
->pochemuZakryta()
)->toBe('');
});
it('боевой адрес открыт и тогда, когда приложение считает себя боевым', function () {
app()->detectEnvironment(fn () => 'production');
expect(
(new DverRendera(BOEVOY, 'secret', (array) config('services.self_render.allowed_hosts')))
->pochemuZakryta()
)->toBe('');
});
it('провайдер ДЕЙСТВИТЕЛЬНО отдаёт список разрешённых машин ленте Яндекса', function () {
config()->set('autopodbor.self_render', true);
config()->set('services.self_render.endpoint', BOEVOY);
config()->set('services.self_render.key', 'secret');
app()->detectEnvironment(fn () => 'production');
Http::fake([BOEVOY => Http::response(['data' => '{"orgs":[]}'], 200)]);
(new AutopodborServiceProvider(app()))->yandexListSource()
->fetch('https://yandex.ru/maps/?text=ломбард', 1);
// Запрос УШЁЛ — значит провайдер передал список и дверь открылась на боевом адресе.
Http::assertSent(fn ($r) => $r->url() === BOEVOY && $r->hasHeader('X-Render-Key', 'secret'));
});
it('лента Яндекса: открытая дверь по-прежнему ходит наружу', function () {
Http::fake(['https://render.local/render' => Http::response(['data' => '{"orgs":[{"name":"А"}]}'], 200)]);
$istochnik = new RenderServiceYandexListSource(
endpoint: 'https://render.local/render',
key: 'secret',
httpTimeout: 5,
razreshyonnyeHosty: HOSTY,
);
expect($istochnik->fetch('https://yandex.ru/maps/x', 2))->toBe([['name' => 'А']]);
Http::assertSent(fn ($r) => $r->hasHeader('X-Render-Key', 'secret'));
});