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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user