From dd3b6fb4b544a338935c2b95a25848c8a2a26461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 7 Aug 2026 06:31:35 +0300 Subject: [PATCH] =?UTF-8?q?fix=20=D0=BE=D0=B1=D0=B7=D0=B2=D0=BE=D0=BD:=20?= =?UTF-8?q?=D0=BA=20=D1=80=D0=BE=D0=B1=D0=BE=D1=82=D1=83=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D0=BE=20=D1=88=D0=B8=D1=84?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=B5,=20=D0=BA=D1=80=D0=BE=D0=BC=D0=B5=20?= =?UTF-8?q?=D1=81=D0=B2=D0=BE=D0=B5=D0=B9=20=D0=BC=D0=B0=D1=88=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=20=E2=80=94=20=D0=A0123?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дверь пускала 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 --- app/.env.example | 10 + app/app/Services/Obzvon/ObzvonClient.php | 116 +++++- app/config/services.php | 8 + .../Feature/Obzvon/ObzvonClientShovTest.php | 188 +++++++++ ...omoshchnika-r123-shifrovanie-2026-08-07.md | 357 ++++++++++++++++++ 5 files changed, 678 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-r123-shifrovanie-2026-08-07.md diff --git a/app/.env.example b/app/.env.example index 4084f545..6e73fe70 100644 --- a/app/.env.example +++ b/app/.env.example @@ -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 diff --git a/app/app/Services/Obzvon/ObzvonClient.php b/app/app/Services/Obzvon/ObzvonClient.php index 71832408..487b60c2 100644 --- a/app/app/Services/Obzvon/ObzvonClient.php +++ b/app/app/Services/Obzvon/ObzvonClient.php @@ -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"; + } + /** * Одно обращение к роботу: занять место → сходить → отдать место. * diff --git a/app/config/services.php b/app/config/services.php index 9aaf8a4b..1d3b7598 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -456,6 +456,14 @@ return [ // БАЗОВЫЙ адрес приёмника заданий на машине робота (без хвоста ручки): // клиент сам добавит /pozvonit, /sostoyanie, /ostanovit. Сам приёмник — // задача З-0.6, его ещё нет; портальная половина от этого не зависит. + // + // 🔴 Р123: адрес обязан быть `https://`. Тайный ключ едет ЗАГОЛОВКОМ в + // каждом запросе — по `http://` он поехал бы открытым текстом и молча. + // Адрес без шифрования портал не принимает: дверь закрыта, звонка нет. + // Исключение ровно одно — разработчик на своём компьютере, и оно НЕ + // настраивается отсюда: тумблера «можно без шифра» тут нет намеренно, + // такой тумблер и был бы той самой опечаткой. Как устроено исключение — + // см. `ObzvonClient::svoyaMashina()`. 'endpoint' => env('OBZVON_ENDPOINT', ''), 'key' => env('OBZVON_KEY', ''), diff --git a/app/tests/Feature/Obzvon/ObzvonClientShovTest.php b/app/tests/Feature/Obzvon/ObzvonClientShovTest.php index 63ba1d4d..81b7efce 100644 --- a/app/tests/Feature/Obzvon/ObzvonClientShovTest.php +++ b/app/tests/Feature/Obzvon/ObzvonClientShovTest.php @@ -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 плана: предел одновременных заданий // ───────────────────────────────────────────────────────────────────────────── diff --git a/docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-r123-shifrovanie-2026-08-07.md b/docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-r123-shifrovanie-2026-08-07.md new file mode 100644 index 00000000..f552855b --- /dev/null +++ b/docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-r123-shifrovanie-2026-08-07.md @@ -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, ждёт решения владельца.