9247281688
Тайный ключ 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
262 lines
15 KiB
PHP
262 lines
15 KiB
PHP
<?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'));
|
||
});
|