fix обзвон: к роботу только по шифрованной дороге, кроме своей машины — Р123
Дверь пускала http к разрешённому хосту, и тайный ключ уезжал заголовком открытым текстом — молча, без единого признака. Одной опечатки в OBZVON_ENDPOINT хватало. Теперь адрес без шифрования портал не принимает: дверь закрыта, звонка нет, причина сказана словами и лежит в журнале уровнем предупреждения. Исключение ровно одно — разработчик на своём компьютере — и устроено оно ДВУМЯ независимыми ответами, чтобы одной перепутанной настройки на боевом для утечки не хватило: 1. приложение само говорит, где живёт: спрашиваем Laravel, а не список слов. Список окружений положительный, а не «всё кроме боевого»: любое неназванное окружение обязано шифрование. 2. робот стоит на ЭТОЙ ЖЕ машине: адрес ведёт на петлю — вся сеть 127.0.0.0/8, ::1, ::ffff:127.x.x.x, имена localhost и *.localhost по RFC 6761. Определяется двоичным сравнением адреса, а не совпадением слов. Резолвер имён не зовём намеренно: он вносит в проверку безопасности неопределённость и щель между «проверили» и «пошли». Именно второй ответ и закрывает то, что владелец назвал незакрытым: одной ошибки в APP_ENV на боевом теперь мало — боевой робот петлёй не станет. На это стоит именной сторож. Тумблера «можно без шифра» нет намеренно: он и был бы той опечаткой, ради которой Р123 принято. 12 новых сторожей: четыре случая ножа приёмки ж, з, и, к; сторож на само исключение; сторож против совпадения слов вместо петли; сторож на подложное шифрование, когда проверку чужого удостоверения снимают. Каждый показан красным — починка ронялась четырьмя разными способами, и каждый раз краснело именно то, что этот способ ломает. Возврат доказан слепком со снятием невидимых знаков конца строки. Моя ошибка первой: первый сторож про журнал зеленел не по той причине — любая строка предупреждения его устраивала. Нашёлся только арифметикой прогона, потому что упал в поле ошибок, а не провалов. Переписан. Найдено и НЕ тронуто второе место того же класса: services.self_render шлёт секрет заголовком, и его дверь не проверяет ни схему, ни хост вовсе. Путь живой, боевой. Вынесено решением владельцу отдельно. Отчёт: docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-r123-shifrovanie-2026-08-07.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -170,6 +170,14 @@ CREATIVE_ROBOT_TOKEN=
|
||||
# Пока адрес или ключ пусты — дверь закрыта: портал наружу не ходит, звонков не будет,
|
||||
# и он говорит об этом вслух. Ошибками не сыплет.
|
||||
# БАЗОВЫЙ адрес приёмника заданий, без хвоста ручки (портал сам добавит /pozvonit и др.).
|
||||
# 🔴 Р123: только https://. Тайный ключ едет заголовком в каждом запросе — по http:// он
|
||||
# поехал бы открытым текстом и молча. Адрес без шифрования портал не принимает: дверь
|
||||
# закрыта, звонков нет. Живой робот 51.250.1.97 слушает 443 уже сегодня.
|
||||
# Единственное исключение — разработчик на СВОЁМ компьютере: при APP_ENV=local (или
|
||||
# testing) И адресе на петлю этой же машины (127.0.0.0/8, ::1, localhost) http:// разрешён,
|
||||
# потому что такой запрос компьютер физически не покидает. Тумблера «можно без шифра»
|
||||
# нет намеренно: он и был бы той опечаткой, ради которой запрет заведён.
|
||||
# Пример для своей машины: OBZVON_ENDPOINT=http://127.0.0.1:8080/lena
|
||||
OBZVON_ENDPOINT=
|
||||
# Тайный ключ портал↔робот. Только здесь, в код и в гит НЕ попадает.
|
||||
OBZVON_KEY=
|
||||
@@ -189,4 +197,6 @@ OBZVON_TIMEOUT=15
|
||||
# 🔴 Куда порталу ВООБЩЕ разрешено ходить с тайным ключом (хосты через запятую).
|
||||
# Опечатка в OBZVON_ENDPOINT увела бы ключ чужому человеку молча. Хост не из списка =
|
||||
# дверь закрыта. Пусто = закрыто всё. Переезд робота — осознанная правка в двух местах.
|
||||
# 🪤 Разрешённый хост — это ещё не безопасная дорога: http:// к разрешённому хосту всё
|
||||
# равно закрыт (Р123). Два запрета независимы, и снимать их надо по отдельности.
|
||||
OBZVON_ALLOWED_HOSTS=51.250.1.97,51-250-1-97.sslip.io,127.0.0.1,localhost
|
||||
|
||||
@@ -71,6 +71,31 @@ use Illuminate\Support\Facades\Log;
|
||||
* ЗАЯВЛЕНА, но не замкнута. Так и записано в отчёте, «сделано» не называется.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 🔴 Р123 — К РОБОТУ ТОЛЬКО ПО ШИФРОВАННОЙ ДОРОГЕ, КРОМЕ СВОЕЙ МАШИНЫ
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Тайный ключ едет ЗАГОЛОВКОМ в каждом запросе. Приёмка 07.08.2026 замерила:
|
||||
* разрешённый хост + `http://` уходил наружу молча — ключ ехал открытым текстом,
|
||||
* и признака этому не было никакого. Одной опечатки в `OBZVON_ENDPOINT` хватало.
|
||||
*
|
||||
* Решение владельца: адрес без шифрования портал просто НЕ ПРИНИМАЕТ — говорит
|
||||
* «дверь закрыта» и звонить не будет. Исключение ровно одно — разработчик на
|
||||
* своём компьютере, и оно устроено ДВУМЯ независимыми ответами
|
||||
* ({@see self::svoyaMashina()}), чтобы одной перепутанной настройки на боевом
|
||||
* для утечки не хватило.
|
||||
*
|
||||
* 🪤 Чего Р123 НЕ закрывает, говорю прямо: шифрование бережёт ключ В ПУТИ, но
|
||||
* НЕ доказывает, что на том конце наш робот, а не чужая машина, занявшая тот же
|
||||
* адрес. Это отдельная задача (закрепить отпечаток или свой корень доверия), и
|
||||
* она не здесь.
|
||||
*
|
||||
* 🪤 И второе: если робот когда-нибудь уедет за посредника, снимающего
|
||||
* шифрование на входе (`http://robot.vnutri:8080`), эта дверь его закроет — и
|
||||
* закроет правильно. Лечится это шифрованием на внутреннем участке либо новым
|
||||
* решением владельца, но НЕ настройкой-обходом: настройка-обход и есть та самая
|
||||
* опечатка, ради которой всё это заведено.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 🔴 ПРЕДЕЛ ОДНОВРЕМЕННЫХ — ЧТО ОН НА САМОМ ДЕЛЕ ОГРАНИЧИВАЕТ
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
@@ -242,7 +267,8 @@ final class ObzvonClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Открыта ли дверь: есть адрес, есть ключ, и адрес ведёт на РАЗРЕШЁННЫЙ хост.
|
||||
* Открыта ли дверь: есть адрес, есть ключ, адрес ведёт на РАЗРЕШЁННЫЙ хост
|
||||
* и по ШИФРОВАННОЙ дороге (решение владельца Р123).
|
||||
*
|
||||
* Пустая строка = открыта. Иначе — человеческая причина, почему закрыта.
|
||||
*/
|
||||
@@ -269,9 +295,97 @@ final class ObzvonClient
|
||||
return 'адрес робота ведёт на неразрешённый хост '.$host;
|
||||
}
|
||||
|
||||
$shema = mb_strtolower((string) (parse_url($this->endpoint, PHP_URL_SCHEME) ?: ''));
|
||||
|
||||
// 🔴 Р123. Разрешённый хост НЕ означает безопасную дорогу: `http://` к тому
|
||||
// же самому роботу везёт тайный ключ открытым текстом. Замерено приёмкой
|
||||
// 07.08.2026 — уходило молча, без единого признака.
|
||||
if ($shema !== 'https' && ! $this->svoyaMashina($host)) {
|
||||
return 'адрес робота без шифрования ('
|
||||
.($shema === '' ? 'схема не указана' : $shema.'://')
|
||||
.') — тайный ключ уехал бы открытым текстом';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 Единственное исключение из Р123: разработчик на СВОЁМ компьютере.
|
||||
*
|
||||
* «Своя машина» определяется не списком разрешённых слов — такой список сам
|
||||
* становится дверью («сегодня localhost и 127.0.0.1, завтра четвёртый»), — а
|
||||
* ДВУМЯ независимыми ответами, и оба обязаны совпасть:
|
||||
*
|
||||
* 1. **Где живёт само приложение.** Спрашиваем не себя, а Laravel: он знает
|
||||
* это из `APP_ENV`. Список положительный, а не «всё кроме боевого»: любое
|
||||
* окружение, которого тут не назвали (боевое, витрина, что заведут завтра),
|
||||
* шифрование обязано. Это та же осторожность, что и у пустого списка
|
||||
* хостов выше — «не сказано, что можно, значит нельзя».
|
||||
* 2. **Где стоит робот.** Адрес обязан вести на петлю ЭТОЙ ЖЕ машины: тогда
|
||||
* запрос физически не покидает компьютер и подслушивать его негде.
|
||||
*
|
||||
* 🔴 Зачем именно два, а не один. Владелец назвал незакрытым: «ошибись
|
||||
* кто-нибудь настройкой на боевом так, чтобы портал счёл себя своей машиной,
|
||||
* — запрет отвалится». С двумя ответами одной такой ошибки НЕ хватает:
|
||||
* боевой робот живёт на чужом железе, петлёй его адрес не станет, и ключ
|
||||
* наружу всё равно не уйдёт. Чтобы утечка состоялась, сломать надо оба.
|
||||
*/
|
||||
private function svoyaMashina(string $host): bool
|
||||
{
|
||||
if (! app()->environment('local', 'testing')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->petlya($host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ведёт ли адрес на петлю этой самой машины — по существу, а не по написанию.
|
||||
*
|
||||
* 🪤 Резолвер имён сюда намеренно не зовём. Он превратил бы проверку
|
||||
* безопасности в неопределённую (чужой медленный DNS вешает обзвон) и открыл
|
||||
* бы щель между «проверили» и «пошли»: имя, ответившее петлёй на проверке,
|
||||
* к моменту запроса может отвечать чужим адресом. Разработчику, у которого
|
||||
* робот назван своим именем в hosts, дверь скажет причину вслух, а лечится
|
||||
* это одной правкой адреса на `127.0.0.1`.
|
||||
*/
|
||||
private function petlya(string $host): bool
|
||||
{
|
||||
// Квадратные скобки — как IPv6 пишется в адресе: http://[::1]:8080/…
|
||||
$host = trim(mb_strtolower(trim($host)), '[]');
|
||||
|
||||
// RFC 6761 §6.3: `localhost` и всё под `.localhost` закреплены за петлёй
|
||||
// этой машины СТАНДАРТОМ. Это не наше усмотрение и не список, который
|
||||
// завтра дополнят строкой. 🪤 `localhost.chuzhoy.ru` сюда не попадает:
|
||||
// сверяется конец имени, а не наличие слова внутри.
|
||||
if ($host === 'localhost' || str_ends_with($host, '.localhost')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dvoichnyy = inet_pton($host);
|
||||
|
||||
if (! is_string($dvoichnyy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ::ffff:127.0.0.1 — та же петля, записанная по-шестому.
|
||||
if (strlen($dvoichnyy) === 16 && str_starts_with($dvoichnyy, str_repeat("\0", 10)."\xff\xff")) {
|
||||
$dvoichnyy = substr($dvoichnyy, 12);
|
||||
}
|
||||
|
||||
// Петля — это ВСЯ сеть 127.0.0.0/8, а не один 127.0.0.1: у разработчика
|
||||
// сервер может стоять на 127.0.1.1 (Debian) или 127.0.0.53 (resolved).
|
||||
if (strlen($dvoichnyy) === 4) {
|
||||
return $dvoichnyy[0] === "\x7f";
|
||||
}
|
||||
|
||||
return $dvoichnyy === str_repeat("\0", 15)."\x01";
|
||||
}
|
||||
|
||||
/**
|
||||
* Одно обращение к роботу: занять место → сходить → отдать место.
|
||||
*
|
||||
|
||||
@@ -456,6 +456,14 @@ return [
|
||||
// БАЗОВЫЙ адрес приёмника заданий на машине робота (без хвоста ручки):
|
||||
// клиент сам добавит /pozvonit, /sostoyanie, /ostanovit. Сам приёмник —
|
||||
// задача З-0.6, его ещё нет; портальная половина от этого не зависит.
|
||||
//
|
||||
// 🔴 Р123: адрес обязан быть `https://`. Тайный ключ едет ЗАГОЛОВКОМ в
|
||||
// каждом запросе — по `http://` он поехал бы открытым текстом и молча.
|
||||
// Адрес без шифрования портал не принимает: дверь закрыта, звонка нет.
|
||||
// Исключение ровно одно — разработчик на своём компьютере, и оно НЕ
|
||||
// настраивается отсюда: тумблера «можно без шифра» тут нет намеренно,
|
||||
// такой тумблер и был бы той самой опечаткой. Как устроено исключение —
|
||||
// см. `ObzvonClient::svoyaMashina()`.
|
||||
'endpoint' => env('OBZVON_ENDPOINT', ''),
|
||||
'key' => env('OBZVON_KEY', ''),
|
||||
|
||||
|
||||
@@ -318,6 +318,194 @@ it('адрес без хоста вовсе — дверь закрыта', func
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 🔴 Р123: к роботу только по шифрованной дороге, кроме своей машины
|
||||
//
|
||||
// Замерено прибором приёмки 07.08.2026: разрешённый хост + `http://` уходил
|
||||
// наружу молча, и тайный ключ ехал в заголовке ОТКРЫТЫМ ТЕКСТОМ. Одна опечатка
|
||||
// в `OBZVON_ENDPOINT` — и ключ у чужого человека, без единого признака.
|
||||
//
|
||||
// 🪤 Каждый случай меряется ОТДЕЛЬНО, своим подставным роботом: на прошлой
|
||||
// приёмке подставной робот протёк из случая в случай и два замера были ложью.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('🔴 ж: разрешённый хост, но http без шифра — ключ наружу НЕ уходит', function () {
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://51.250.1.97/lena',
|
||||
'razreshyonnyeHosty' => ['51.250.1.97'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse()
|
||||
->and($otvet->poyasnenie)->toContain('без шифрования');
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('🔴 з: тот же хост по https — работает и после запрета', function () {
|
||||
// Контроль. Без него запрет мог бы закрыть дверь вообще всем и выглядеть
|
||||
// «зелёным»: живой робот на 51.250.1.97 слушает 443 уже сегодня.
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'rob-7'], 200));
|
||||
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'https://51.250.1.97/lena',
|
||||
'razreshyonnyeHosty' => ['51.250.1.97'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen)
|
||||
->and($otvet->vneshniyIdZvonka)->toBe('rob-7');
|
||||
});
|
||||
|
||||
it('🔴 и: свой компьютер, http на localhost — работает, разработка не встаёт', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'mestnyy'], 200));
|
||||
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://localhost:8080/lena',
|
||||
'razreshyonnyeHosty' => ['localhost'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen);
|
||||
});
|
||||
|
||||
it('🔴 и: свой компьютер, http на 127.0.0.1 — работает', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'mestnyy'], 200));
|
||||
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://127.0.0.1:8080/lena',
|
||||
'razreshyonnyeHosty' => ['127.0.0.1'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen);
|
||||
});
|
||||
|
||||
it('🔴 к: тот же местный адрес НА БОЕВОМ — дверь закрыта', function () {
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://127.0.0.1:8080/lena',
|
||||
'razreshyonnyeHosty' => ['127.0.0.1'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
|
||||
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 🔴 Сторож на само исключение: «своя машина» — ДВА независимых ответа
|
||||
//
|
||||
// Владелец назвал незакрытым: «ошибись кто-нибудь настройкой на боевом так,
|
||||
// чтобы портал счёл себя своей машиной, — запрет отвалится, и сторожа на это
|
||||
// нет». Сторож — здесь. Чтобы ключ уехал открытым, обязаны сломаться ОБА
|
||||
// ответа сразу: и «где я живу», и «робот стоит на этой же машине». Одной
|
||||
// опечатки в APP_ENV на боевом для утечки НЕ хватает.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('🔴 APP_ENV=local на боевой машине НЕ открывает http к удалённому роботу', function () {
|
||||
// Ровно тот случай: настройку окружения перепутали, портал считает себя
|
||||
// своей машиной. Робот при этом на чужом железе — ключ поехал бы в интернет.
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://51.250.1.97/lena',
|
||||
'razreshyonnyeHosty' => ['51.250.1.97'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('🔴 «своя машина» — не список слов: похожее имя хоста своей машиной не считается', function () {
|
||||
// `localhost.zloumyshlennik.ru` кончается на что угодно и начинается со
|
||||
// слова «localhost». Проверкой «есть ли в строке localhost» он бы прошёл.
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://localhost.zloumyshlennik.ru/lena',
|
||||
'razreshyonnyeHosty' => ['localhost.zloumyshlennik.ru'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('🔴 вся петля 127.0.0.0/8 — своя машина, а соседний 127 в другом месте адреса — нет', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
Http::fake(fn () => Http::response(['ok' => true], 200));
|
||||
|
||||
// 127.0.0.1 — не единственный адрес петли; сервер разработчика может жить
|
||||
// на 127.0.0.53 (systemd-resolved) или 127.0.1.1 (Debian).
|
||||
expect(klientObzvona([
|
||||
'endpoint' => 'http://127.0.1.1:8080/lena',
|
||||
'razreshyonnyeHosty' => ['127.0.1.1'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567')->poluchenLi())->toBeTrue();
|
||||
});
|
||||
|
||||
it('🔴 адрес, похожий на петлю, но ею не являющийся — дверь закрыта', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
Http::fake();
|
||||
|
||||
// 127 внутри, но это чужая машина в интернете.
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://10.0.127.1:8080/lena',
|
||||
'razreshyonnyeHosty' => ['10.0.127.1'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
|
||||
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('🔴 запрет http на боевом виден в журнале, и уровень не ниже предупреждения', function () {
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
$zhurnal = Log::spy();
|
||||
Http::fake();
|
||||
|
||||
$otvet = klientObzvona([
|
||||
'endpoint' => 'http://51.250.1.97/lena',
|
||||
'razreshyonnyeHosty' => ['51.250.1.97'],
|
||||
])->pozvonit(znakZadaniya(), '+79991234567');
|
||||
|
||||
// 🪤 Сперва — что дверь закрыта ИМЕННО из-за шифрования, а уж потом про
|
||||
// журнал. Без первой строки сторож зеленел бы от чужого предупреждения в
|
||||
// том же походе: сломай запрет — запрос уйдёт, робот ответит не тем, о чём
|
||||
// договаривались, и строка `warning` появится сама, не о том.
|
||||
expect($otvet->poyasnenie)->toContain('без шифрования');
|
||||
Http::assertNothingSent();
|
||||
|
||||
// На бою LOG_LEVEL=warning: молчаливый отказ звонить был бы вторым сортом
|
||||
// той же беды — обзвон встал бы, и никто бы не узнал почему.
|
||||
$zhurnal->shouldHaveReceived('warning');
|
||||
$zhurnal->shouldNotHaveReceived('info');
|
||||
$zhurnal->shouldNotHaveReceived('notice');
|
||||
$zhurnal->shouldNotHaveReceived('debug');
|
||||
});
|
||||
|
||||
it('🔴 умолчание настроек не открывает http: в .env.example адрес робота шифрованный', function () {
|
||||
// Сторож против тихого возврата дыры через образец настроек: скопировал
|
||||
// .env.example — получил открытую дорогу.
|
||||
$obrazec = (string) file_get_contents(__DIR__.'/../../../.env.example');
|
||||
|
||||
expect($obrazec)->not->toMatch('/^OBZVON_ENDPOINT=http:\/\//m');
|
||||
});
|
||||
|
||||
it('🔴 проверка чужого удостоверения не отключена — иначе https был бы одной видимостью', function () {
|
||||
// Та же беда с другого конца: `https://` в адресе, а проверку удостоверения
|
||||
// машины кто-то снял, чтобы «заработало с самодельным сертификатом». Тогда
|
||||
// шифрование есть, а подставиться на дороге может кто угодно — и ключ
|
||||
// уедет ему. Laravel проверяет удостоверение по умолчанию; сторож стоит на
|
||||
// том, чтобы эту проверку тут никогда не сняли.
|
||||
$ishodnik = (string) file_get_contents(__DIR__.'/../../../app/Services/Obzvon/ObzvonClient.php');
|
||||
|
||||
expect($ishodnik)->not->toContain('withoutVerifying')
|
||||
->and($ishodnik)->not->toContain("'verify' => false");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Проверка 4 плана: предел одновременных заданий
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
# Отчёт помощника — Р123: к роботу только по шифрованной дороге
|
||||
|
||||
**Дата:** 07.08.2026
|
||||
**Ветка:** `feat/prospects-manual-testing-kp`
|
||||
**Задача:** исполнить решение владельца Р123 в двери `ObzvonClient::pochemuDverZakryta()`
|
||||
и завести сторожа на случай, когда исключение «своя машина» отваливается не там, где должно.
|
||||
|
||||
## 0. Моя ошибка — называю первой
|
||||
|
||||
Первый вариант сторожа про журнал **зеленел не по той причине**. Он проверял «была ли
|
||||
вообще строка уровня предупреждения». Со сломанной починкой запрос уходил наружу, робот
|
||||
отвечал не тем, о чём договаривались, строка `warning` появлялась сама — и сторож был
|
||||
зелёный при живой дыре. Увидел это только на прогоне со сломанной починкой: сторож
|
||||
провалился **не в поле «провалов», а в поле «ошибок»** (`40 зелёных + 5 провалов + 1
|
||||
ошибка = 46`), то есть по коду возврата и по слову «провалов: 5» я бы его не нашёл.
|
||||
Переписал: сторож сперва требует, чтобы дверь закрылась **именно из-за шифрования**
|
||||
(`poyasnenie` содержит «без шифрования»), и только потом смотрит на журнал.
|
||||
|
||||
Вторая, мелкая: добавил `use Mockery;` — PHP на это ругается («имя без составных частей»),
|
||||
и предупреждение печаталось перед разбором вывода. Убрал вместе с самим `Mockery`.
|
||||
|
||||
Третья: правил файл сторожей, когда полный прогон уже шёл. Пришлось прогон убить и
|
||||
запустить заново на окончательных файлах — числа в §4 сняты с последнего запуска.
|
||||
|
||||
Четвёртая, из того же теста: ждал конца прогона по признаку «файл вывода перестал быть
|
||||
пустым». Признак негодный — в файл по ходу падают посторонние строки замеров, и ожидание
|
||||
кончилось на середине прогона. Чуть не снял числа с недобежавшего прогона. Переделал на
|
||||
ожидание самой итоговой строки.
|
||||
|
||||
🔴 Пятая, и самая дорогая: пока шёл полный прогон, я запустил **на той же базе** ещё
|
||||
один, короткий. Две пробы полезли в одну базу — и полный прогон вернул 52 небылицы
|
||||
подряд: 47 обрывов внутри подключения к базе и 5 в стороже схемы, который смотрит на
|
||||
живую базу глазами. Ни одна из них не была настоящей. Правило простое: **своя база — это
|
||||
одна проба за раз**, а не «своя от соседей». Прогон переделан начисто.
|
||||
|
||||
## 1. Что было замерено ДО правки
|
||||
|
||||
Прогон только по файлу сторожей обзвона, починка ещё не написана:
|
||||
|
||||
```text
|
||||
всего 46, зелёных 41, красных 5
|
||||
```
|
||||
|
||||
Все пять красных — это случаи «дверь обязана быть закрыта», и все пятеро вернули
|
||||
`Neizvestno` вместо `DverZakryta`. `Neizvestno` тут значит ровно одно: **запрос ушёл
|
||||
наружу**, тайный ключ уехал в заголовке. Дыра из приёмки воспроизведена.
|
||||
|
||||
## 2. Что построено
|
||||
|
||||
### 2.1. Запрет в двери
|
||||
|
||||
В `ObzvonClient::pochemuDverZakryta()` после проверки разрешённого хоста добавлена
|
||||
проверка схемы адреса:
|
||||
|
||||
- `https://` — проходит;
|
||||
- любая другая схема (и её отсутствие) — **дверь закрыта**, наружу не ходим, звонка
|
||||
не было, причина сказана словами и ложится в журнал уровнем `warning`.
|
||||
|
||||
Единственное исключение — `svoyaMashina()`.
|
||||
|
||||
### 2.2. «Своя машина» — ДВА независимых ответа, а не список слов
|
||||
|
||||
Оба обязаны совпасть:
|
||||
|
||||
1. **Где живёт приложение.** Спрашиваем Laravel: `app()->environment('local', 'testing')`.
|
||||
Список **положительный**, а не «всё кроме боевого»: любое окружение, которого тут не
|
||||
назвали (боевое, витрина, что заведут завтра), обязано шифрование. Это та же
|
||||
осторожность, что у пустого списка хостов рядом — «не сказано, что можно, значит нельзя».
|
||||
2. **Где стоит робот.** Адрес обязан вести на петлю **этой же** машины: тогда запрос
|
||||
физически не покидает компьютер и подслушивать его негде. Петля определяется по
|
||||
существу — двоичным сравнением адреса: вся сеть `127.0.0.0/8` (а не один `127.0.0.1`:
|
||||
у разработчика сервер может стоять на `127.0.1.1` или `127.0.0.53`), `::1`, а также
|
||||
`::ffff:127.x.x.x`. Имена — только `localhost` и `*.localhost`, и это не наш список
|
||||
разрешений, а RFC 6761 §6.3, где эти имена закреплены за петлёй стандартом.
|
||||
|
||||
🪤 Резолвер имён намеренно не зовём. Он превратил бы проверку безопасности в
|
||||
неопределённую (чужой медленный DNS вешает обзвон) и открыл бы щель между «проверили» и
|
||||
«пошли». Разработчику, назвавшему робота своим именем в `hosts`, дверь скажет причину
|
||||
вслух, а лечится это правкой адреса на `127.0.0.1`.
|
||||
|
||||
### 2.3. Почему ответов два — это и есть сторож на «незакрытое» владельца
|
||||
|
||||
Владелец назвал незакрытым: «ошибись кто-нибудь настройкой на боевом так, чтобы портал
|
||||
счёл себя своей машиной, — запрет отвалится, и сторожа на это нет».
|
||||
|
||||
С двумя независимыми ответами **одной такой ошибки не хватает**: боевой робот живёт на
|
||||
чужом железе, петлёй его адрес не станет, и ключ наружу всё равно не уйдёт. Чтобы утечка
|
||||
состоялась, сломать надо оба ответа сразу. На это стоит именной сторож
|
||||
«APP_ENV=local на боевой машине НЕ открывает http к удалённому роботу».
|
||||
|
||||
### 2.4. Тумблера-обхода нет намеренно
|
||||
|
||||
Настройки `OBZVON_ALLOW_HTTP` или подобной я **не заводил**. Такой тумблер и есть та самая
|
||||
одна опечатка, ради которой запрет принят: он снимается так же молча, как ставится.
|
||||
Это записано и в `config/services.php`, и в `.env.example`, чтобы следующая смена не
|
||||
завела его «для удобства».
|
||||
|
||||
### 2.5. Файлы
|
||||
|
||||
| Файл | Что |
|
||||
|---|---|
|
||||
| `app/app/Services/Obzvon/ObzvonClient.php` | запрет схемы, `svoyaMashina()`, `petlya()`, раздел Р123 в шапке |
|
||||
| `app/config/services.php` | только комментарий у `obzvon.endpoint` (общий файл — дописаны 8 строк, чужого не тронуто) |
|
||||
| `app/.env.example` | почему только `https`, как устроено исключение, пример для своей машины |
|
||||
| `app/tests/Feature/Obzvon/ObzvonClientShovTest.php` | 12 новых сторожей |
|
||||
|
||||
## 3. Чем ронял сторожей и что они сказали дословно
|
||||
|
||||
Ронял четырьмя разными способами — каждый раз проверял, что краснеет **именно то**, что
|
||||
этот способ ломает, а не «вообще что-нибудь».
|
||||
|
||||
### Слом А — снял запрет схемы целиком
|
||||
|
||||
`if (false && $shema !== 'https' && …)`
|
||||
|
||||
```text
|
||||
всего 46, зелёных 40, красных 6
|
||||
```
|
||||
|
||||
```text
|
||||
* ж: разрешённый хост, но http без шифра — ключ наружу НЕ уходит
|
||||
Failed asserting that two variables reference the same object.
|
||||
-App\Services\Obzvon\IskhodObrashcheniya Enum (DverZakryta, 'dver_zakryta')
|
||||
+App\Services\Obzvon\IskhodObrashcheniya Enum (Neizvestno, 'neizvestno')
|
||||
* к: тот же местный адрес НА БОЕВОМ — дверь закрыта (то же)
|
||||
* APP_ENV=local на боевой машине НЕ открывает http к удалённому роботу (то же)
|
||||
* «своя машина» — не список слов: похожее имя хоста своей машиной не считается (то же)
|
||||
* адрес, похожий на петлю, но ею не являющийся — дверь закрыта (то же)
|
||||
* запрет http на боевом виден в журнале, и уровень не ниже предупреждения
|
||||
Failed asserting that 'ответ без условленного слова ok' contains "без шифрования".
|
||||
```
|
||||
|
||||
🔴 Последняя строка — сама по себе доказательство дыры: при снятом запрете портал **сходил
|
||||
к роботу** и получил от него ответ. Ключ уехал.
|
||||
|
||||
### Слом Б — убрал только первый ответ (где живёт приложение)
|
||||
|
||||
`svoyaMashina()` перестал спрашивать `app()->environment()`.
|
||||
|
||||
```text
|
||||
всего 46, зелёных 45, красных 1
|
||||
* к: тот же местный адрес НА БОЕВОМ — дверь закрыта
|
||||
Failed asserting that two variables reference the same object.
|
||||
```
|
||||
|
||||
Ровно один красный — тот, что охраняет именно эту половину. Остальные не шелохнулись:
|
||||
удалённый робот петлёй не стал.
|
||||
|
||||
### Слом В — заменил «петлю по существу» на совпадение слов
|
||||
|
||||
`str_contains($host, 'localhost') || str_contains($host, '127')`
|
||||
|
||||
```text
|
||||
всего 46, зелёных 44, красных 2
|
||||
* «своя машина» — не список слов: похожее имя хоста своей машиной не считается
|
||||
* адрес, похожий на петлю, но ею не являющийся — дверь закрыта
|
||||
```
|
||||
|
||||
`localhost.zloumyshlennik.ru` и `10.0.127.1` при таком «сторожении» становятся своей
|
||||
машиной — и оба сторожа это поймали.
|
||||
|
||||
### Слом Г — потребовал шифрование ВСЕГДА, без исключения
|
||||
|
||||
Это отрезанная владельцем ветка. Проверял, что запрет не закрыл дверь заодно и разработке.
|
||||
|
||||
```text
|
||||
всего 46, зелёных 43, красных 3
|
||||
* и: свой компьютер, http на localhost — работает, разработка не встаёт
|
||||
* и: свой компьютер, http на 127.0.0.1 — работает
|
||||
* вся петля 127.0.0.0/8 — своя машина, а соседний 127 в другом месте адреса — нет
|
||||
Failed asserting that false is true.
|
||||
```
|
||||
|
||||
### Возврат починки — доказан слепком со снятием невидимых знаков
|
||||
|
||||
```text
|
||||
tr -d '\r' < app/app/Services/Obzvon/ObzvonClient.php | git hash-object --stdin
|
||||
до слома: 487b60c24b101cc9a258f94045eb653674aa9b5f
|
||||
после: 487b60c24b101cc9a258f94045eb653674aa9b5f
|
||||
```
|
||||
|
||||
### Зелёный прогон файла сторожей после починки
|
||||
|
||||
```text
|
||||
всего 47, зелёных 47, красных 0, ошибок 0
|
||||
```
|
||||
|
||||
(47, а не 46 — добавлен ещё один сторож, см. §8 про подложное шифрование.)
|
||||
|
||||
## 4. Полный прогон
|
||||
|
||||
БД своя, `liderra_testing_r123`, создана и промигрирована отдельно.
|
||||
|
||||
### Прогон первый
|
||||
|
||||
```text
|
||||
всего 5243, зелёных 5238, провалов 0, ошибок 1, пропущено 4
|
||||
арифметика: 5238 + 0 + 1 + 4 = 5243 — сходится
|
||||
```
|
||||
|
||||
🔴 Единственная ошибка — **не моя и уже не существует**:
|
||||
|
||||
```text
|
||||
Tests\Feature\ClientSms\VychitanieVremennyyTest
|
||||
«ВРЕМЕННО: новое правило без второго окна отвечает ровно то же, что старое»
|
||||
Class "Tests\Temp\SmsQuietHoursEtalon" not found
|
||||
```
|
||||
|
||||
Это соседняя смена по СМС: временный сравнительный сторож с временным же
|
||||
образцовым классом. Пока мой прогон шёл, соседи файл убрали — к концу прогона ни
|
||||
`app/tests/Feature/ClientSms/VychitanieVremennyyTest.php`, ни папки `app/tests/Temp`
|
||||
в дереве уже не было. К обзвону отношения не имеет; моих файлов не касается.
|
||||
|
||||
### Прогон второй — отброшен, испорчен мной
|
||||
|
||||
```text
|
||||
всего 5242, зелёных 5186, провалов 5, ошибок 47, пропущено 4
|
||||
арифметика: 5186 + 5 + 47 + 4 = 5242 — сходится
|
||||
```
|
||||
|
||||
Числами не считаю: все 52 небылицы — моя вина, см. §0 ошибку пятую. 47 обрывов лежат
|
||||
внутри подключения к базе, ещё 5 — в стороже, который смотрит на живую базу глазами
|
||||
(`ObzvonCallsSchemaTest`, «канон и база разошлись по столбцам»). Ни одна к обзвону
|
||||
отношения не имеет; их создала вторая проба, которую я запустил на той же базе.
|
||||
|
||||
### Прогон третий — начисто, база ничем больше не занята
|
||||
|
||||
```text
|
||||
всего 5242, зелёных 5238, провалов 0, ошибок 0, пропущено 4
|
||||
проверок сделано 21 718, время 18 мин 45 с
|
||||
арифметика: 5238 + 0 + 0 + 4 = 5242 — сходится
|
||||
```
|
||||
|
||||
Это и есть число смены. Поля «ошибок» и «провалов» смотрел отдельно — оба ноль, а не
|
||||
только код возврата. 21 718 проверок против 21 355 у испорченного прогона — ещё один
|
||||
признак, что второй прогон недосчитался, а не «нашёл настоящее».
|
||||
|
||||
Мои 47 сторожей обзвона внутри: отдельным запуском файла — `всего 47, зелёных 47`.
|
||||
|
||||
Прочее: `composer stan` — 0 ошибок; `vendor/bin/pint --test` по моим файлам — чисто;
|
||||
`gitleaks` по каждому моему файлу — чисто. 🪤 `gitleaks` находит одну помету в
|
||||
`app/.env.example` на строке 139 — это `SMS_MTS_TOKEN` соседней смены, ложное срабатывание,
|
||||
оно есть и на текущей главной без моих правок; мои строки лежат ниже и чисты.
|
||||
|
||||
## 5. Соседи
|
||||
|
||||
- моего имени нет ни в одном выводе ни одного сторожа
|
||||
- чужие файлы в дереве не тронуты
|
||||
|
||||
Проверено `git status`: из отслеживаемых мною изменены ровно четыре — `app/.env.example`,
|
||||
`app/app/Services/Obzvon/ObzvonClient.php`, `app/config/services.php`,
|
||||
`app/tests/Feature/Obzvon/ObzvonClientShovTest.php`. Всё прочее изменённое в дереве
|
||||
(`app/app/Services/ClientSms/…`, `bots/lena-golos/…`, `docs/lending-sborka/…`,
|
||||
`docs/observer/STATUS.md`, `docs/superpowers/plans/…`, `docs/superpowers/priyomka/stroyka-5/…`,
|
||||
`package.json`, `package-lock.json`) — работа соседних смен, идущая прямо сейчас: список
|
||||
менялся у меня на глазах трижды за смену. Я к ним не прикасался. Общий файл `config/services.php`
|
||||
перечитан построчным сравнением: в моём изменении только 8 добавленных строк
|
||||
комментария, ни одной чужой.
|
||||
|
||||
## 6. Зовущие поимённо
|
||||
|
||||
Смотрел глазами по всему `app/app` и `app/tests`, а затем поиском по всему хранилищу:
|
||||
вне кеша статанализа ни одного упоминания сверх перечисленного.
|
||||
|
||||
| Кто зовёт | Что именно |
|
||||
|---|---|
|
||||
| `ObzvonClient::obratitsya()` — сам класс, строка 396 | `pochemuDverZakryta()` перед каждым походом |
|
||||
| `app/tests/Feature/Obzvon/ObzvonClientShovTest.php` | весь класс целиком |
|
||||
| `app/app/Services/Obzvon/ObzvonOtvet.php` | только упоминание в поясняющем тексте, вызова нет |
|
||||
|
||||
**Больше не зовёт никто.** Зовущей половины (кто именно решает позвонить и когда) ещё
|
||||
нет — она отдельная задача. Значит, мой запрет сегодня не может сломать ничего живого:
|
||||
он охраняет дверь, в которую пока никто не ходит, кроме сторожей.
|
||||
|
||||
## 7. Мой разбор задания
|
||||
|
||||
### 7.1. Где задание право
|
||||
|
||||
Дыра настоящая и опасная ровно так, как описана: `http://` к **разрешённому** хосту
|
||||
уходил молча, ключ ехал открытым текстом. Воспроизвёл красным до правки, посмотрел на
|
||||
это своими глазами.
|
||||
|
||||
### 7.2. 🔴 Где подсказка задания привела бы к негодной починке
|
||||
|
||||
Задание говорит: «спроси у самого приложения, где оно живёт: у Laravel есть на это прямой
|
||||
ответ». Прямой ответ — `app()->environment()`. **Если бы я исполнил только это, починка
|
||||
была бы негодной, а сторожа — зелёными.**
|
||||
|
||||
Причина: случаи «ж» и «и» из вашего же ножа отличаются **только хостом**, а живут в одном
|
||||
и том же окружении. Сторожа гоняются при `APP_ENV=testing`, то есть «не боевое». Проверка
|
||||
«не боевое → http можно» пропустила бы случай «ж» — `http://51.250.1.97` — наружу, и
|
||||
именно та дыра, ради которой всё затеяно, осталась бы жить при зелёном прогоне.
|
||||
|
||||
Поэтому исключение сделано **двумя независимыми ответами**: окружение И петля этой машины.
|
||||
Ваш собственный нож этого требовал — подсказка про Laravel одна его не удовлетворяет.
|
||||
Говорю прямо, потому что следующая смена, читая только подсказку, повторит эту ошибку.
|
||||
|
||||
Побочная и приятная сторона: именно второй ответ и закрывает то, что владелец назвал
|
||||
незакрытым (§2.3). Без него сторожить было бы нечего — одна перепутанная настройка
|
||||
`APP_ENV` открывала бы всё.
|
||||
|
||||
### 7.3. Проверил, о чём просили: посредник, снимающий шифрование на входе
|
||||
|
||||
**Сегодня не ломает ничего.** Замерил:
|
||||
|
||||
- `OBZVON_ENDPOINT` **не задан** ни в местном `.env`, ни в `.env.example` — в эту дверь
|
||||
ещё никто не ходит. 🪤 Боевой я не смотрел: туда без разрешения владельца не хожу;
|
||||
- разрешённые хосты по умолчанию — сама машина робота (`51.250.1.97`,
|
||||
`51-250-1-97.sslip.io`) и петля. Посредника в этой дороге нет;
|
||||
- разведка приёмки: `51.250.1.97` слушает шифрованный 443 уже сегодня.
|
||||
|
||||
**Но если робот когда-нибудь уедет за внутренний посредник**, снимающий шифрование
|
||||
(`http://robot.vnutri:8080`), эта дверь его закроет — и закроет правильно, потому что на
|
||||
внутреннем участке ключ поедет открытым. Как быть тогда: либо поднять шифрование на
|
||||
внутреннем участке, либо новое решение владельца. **Настройкой-обходом — нельзя**, и
|
||||
тумблера для этого я не завёл: тумблер и есть та опечатка, против которой принято Р123.
|
||||
Решение Р123 не отменяю и не смягчаю — это не моё право.
|
||||
|
||||
### 7.4. 🔴 Второе место того же класса — нашёл, НЕ трогал
|
||||
|
||||
`services.self_render` + `app/app/Services/Autopodbor/Agent/Fetch/SelfRenderClient.php`
|
||||
— тот самый шов, который был образцом для обзвона. Он шлёт секрет
|
||||
`SELF_RENDER_KEY` заголовком `X-Render-Key` на ту же виртуалку. Его дверь
|
||||
(`SelfRenderClient`, строка 121) проверяет ровно два условия:
|
||||
|
||||
```php
|
||||
return $this->endpoint !== '' && $this->key !== '';
|
||||
```
|
||||
|
||||
**Ни списка разрешённых хостов, ни проверки схемы там нет вовсе.** То есть у соседа дыра
|
||||
не одна, а полторы: `http://` уведёт секрет открытым текстом, а опечатка в имени хоста
|
||||
уведёт его вообще чужому человеку — и это на живом пути «Поиска клиентов», который
|
||||
сегодня работает на бою.
|
||||
|
||||
Не чинил: файл не мой, путь боевой, а поспешная правка там роняет работающий поиск.
|
||||
Выношу решением владельцу отдельно.
|
||||
|
||||
## 8. Что осталось незакрытым
|
||||
|
||||
1. **Шифрование не проверяет, ЧЕЙ робот отвечает.** Названо владельцем, не закрыто и
|
||||
мной. Ключ защищён в пути, но машина, занявшая тот же адрес, получит его так же.
|
||||
Лечится закреплением отпечатка машины или своим корнем доверия — отдельная работа.
|
||||
2. **Остаток дыры «своя машина».** Сужен, но не убит: если на боевом сервере одновременно
|
||||
перепутать `APP_ENV` **и** поставить робота на петлю самого боевого сервера — запрет
|
||||
отвалится. Изнутри портала этого не видно в принципе. Настоящее лекарство — проверка
|
||||
снаружи: сторож выката обязан требовать `APP_ENV=production` на боевом. Этого я не
|
||||
делал (файлы не мои).
|
||||
3. **Заведён сторож на подложное шифрование.** `https://` перестаёт быть защитой, если
|
||||
кто-то снимет проверку удостоверения машины («чтобы заработало с самодельным
|
||||
сертификатом»). Laravel проверяет её по умолчанию, и в нашем клиенте она не снята;
|
||||
новый сторож краснеет, если в файле появится `withoutVerifying` или `'verify' => false`.
|
||||
🪤 Он смотрит **текст файла**, а не поведение — от снятия проверки где-нибудь глубже
|
||||
(общая настройка перевозчика) он не защищает.
|
||||
4. **Приёмник заданий на стороне робота (З-0.6) — не мой.** Слушает ли он 443 своей
|
||||
ручкой `/lena/pozvonit`, проверено не мной. Если приёмник поднимут только на 80,
|
||||
портал честно скажет «дверь закрыта» и звонить не станет.
|
||||
5. **`services.self_render`** — см. §7.4, ждёт решения владельца.
|
||||
Reference in New Issue
Block a user