fix(http): IP allowlist fail-closed в production env (Plan 2.6 #ii)

Закрывает CV.11 audit WARN #5 (пустой supplier_ip_allowlist '[]' = fail-open
на production — любой IP пропускается).

Изменение: SupplierWebhookController::verifyIpAllowlist — пустой allowlist
возвращает true только если env != production. На production пустой allowlist
блокирует (404). На dev/testing fail-open сохраняется (для localhost development).

TDD: +2 теста (production env empty → 404; testing env empty → 202).
Inline-warning header обновлён.

phpstan-baseline: count: 6 → 8 (postJson() Pest TestCall PhpDoc-quirk).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-10 23:08:16 +03:00
parent e71a02e498
commit f78a85595c
3 changed files with 35 additions and 6 deletions
@@ -31,10 +31,11 @@ use Symfony\Component\HttpFoundation\IpUtils;
* Backward-compat: legacy /api/webhook/{token} (per-tenant) живёт параллельно
* на WebhookReceiveController не пересекается.
*
* ⚠️ PRODUCTION: пустой `supplier_ip_allowlist = '[]'` (или невалидный JSON,
* который `?: []` молча проглатывает) делает endpoint достижимым с любого IP
* только secret защищает. На prod admin ОБЯЗАН заполнить allowlist реальными
* IP/CIDR поставщика. Дополнительно: hash_equals на уровне `verifySecret`
* Plan 2.6 fix #ii (10.05.2026): пустой `supplier_ip_allowlist = '[]'` на
* production env теперь fail-closed (`verifyIpAllowlist` возвращает false если
* env=production AND allowlist пустой). На dev/testing fail-open для localhost
* development. Admin ОБЯЗАН заполнить allowlist реальными IP/CIDR поставщика
* перед production deploy. Дополнительно: hash_equals на уровне `verifySecret`
* timing-safe только при равной длине; rate-limit per-IP добавится в Plan 3+.
*/
class SupplierWebhookController extends Controller
@@ -111,7 +112,11 @@ class SupplierWebhookController extends Controller
}
$list = json_decode((string) $row->value, true) ?: [];
if ($list === []) {
return true;
// Plan 2.6 fix #ii: production env — пустой allowlist fail-closed (защита
// от забытого override schema seed); dev/testing — fail-open для localhost
// development. CV.11 audit WARN #5: inline-warning lines 34-39 признавал
// проблему, теперь enforced на уровне env.
return ! app()->environment('production');
}
return IpUtils::checkIp($clientIp, $list);
+1 -1
View File
@@ -597,7 +597,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 6
count: 8
path: tests/Feature/Http/Webhook/SupplierWebhookTest.php
-
@@ -98,3 +98,27 @@ it('rejects invalid project format (no B[123]_ prefix) with 422', function () {
]);
$response->assertStatus(422);
});
it('blocks empty IP allowlist в production env (Plan 2.6 fix #ii)', function () {
// beforeEach уже выставил secret valid + allowlist '[]'.
// На production env пустой allowlist должен fail-closed → 404.
app()->detectEnvironment(fn () => 'production');
Bus::fake();
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
'vid' => 1, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(),
]);
$response->assertStatus(404);
});
it('allows empty IP allowlist в testing env (Plan 2.6 fix #ii — fail-open для dev)', function () {
// beforeEach уже выставил allowlist '[]'. Testing env (default) — пропускает.
Bus::fake();
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
'vid' => 999, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(),
]);
$response->assertStatus(202);
});