210 lines
8.6 KiB
PHP
210 lines
8.6 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Models\BannedIp;
|
|
use App\Models\Setting;
|
|
use App\Models\User;
|
|
use App\Services\BruteforceGuard;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Tests\TestCase;
|
|
|
|
class HoneypotTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
/** A non-exempt (public) client IP — loopback/private ranges are exempt from banNow. */
|
|
private const ATTACKER_IP = '203.0.113.99';
|
|
|
|
/** Per-install canary values registered for the tests (config default is now empty). */
|
|
private const CANARY_APP = 'base64:T3sTC4n4ryAppK3yAAAAAAAAAAAAAAAAAAAAAAAAA0=';
|
|
|
|
private const CANARY_DB = 'T3sT-Pr0d-DB-Pass-9f2a7c11';
|
|
|
|
private const CANARY_API = 'a7f3c9e1d5b04826bf1a3c7e9d2f6b84';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Cache::flush();
|
|
|
|
// Register per-install canaries for detection (config ships EMPTY so nothing guessable leaks).
|
|
config([
|
|
'clusev.honeypot.canaries' => [
|
|
'app_key' => self::CANARY_APP,
|
|
'db_password' => self::CANARY_DB,
|
|
'api_key' => self::CANARY_API,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/** Simulate a request from a specific client IP via REMOTE_ADDR (trustProxies is prod-gated). */
|
|
private function fromIp(string $method, string $uri, string $ip, array $data = [])
|
|
{
|
|
return $this->call($method, $uri, $data, [], [], ['REMOTE_ADDR' => $ip]);
|
|
}
|
|
|
|
public function test_wp_login_decoy_serves_fake_wordpress_without_banning_on_view(): void
|
|
{
|
|
// Merely VIEWING a decoy (GET) is logged but never bans — an operator testing the honeypot,
|
|
// or a stray click, must not lock themselves out of the real panel. The ban fires only on a
|
|
// POST login attempt (see HoneypotSubmitTest).
|
|
$response = $this->fromIp('GET', '/wp-login.php', self::ATTACKER_IP);
|
|
|
|
$response->assertStatus(200);
|
|
$response->assertSee('wp-submit', false);
|
|
$response->assertSee('WordPress', false);
|
|
|
|
$this->assertDatabaseCount('banned_ips', 0);
|
|
$this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists());
|
|
}
|
|
|
|
public function test_env_decoy_leaks_canary_and_bans(): void
|
|
{
|
|
$canary = config('clusev.honeypot.canaries.app_key');
|
|
|
|
$response = $this->fromIp('GET', '/.env', self::ATTACKER_IP);
|
|
|
|
$response->assertStatus(200);
|
|
$response->assertSee($canary, false);
|
|
|
|
// GET = view only → leaked + logged, but not banned (ban is reserved for a POST attempt).
|
|
$this->assertDatabaseCount('banned_ips', 0);
|
|
$this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists());
|
|
}
|
|
|
|
public function test_git_config_decoy_serves_fake_git_config_without_banning_on_view(): void
|
|
{
|
|
$response = $this->fromIp('GET', '/.git/config', self::ATTACKER_IP);
|
|
|
|
$response->assertStatus(200);
|
|
$response->assertHeader('Content-Type', 'text/plain; charset=UTF-8');
|
|
$response->assertSee('[remote "origin"]', false);
|
|
$response->assertSee('[core]', false);
|
|
|
|
$this->assertDatabaseCount('banned_ips', 0);
|
|
$this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists());
|
|
}
|
|
|
|
public function test_honeytoken_replay_in_post_body_api_key_is_blocked_banned_and_audited(): void
|
|
{
|
|
$canary = config('clusev.honeypot.canaries.api_key');
|
|
|
|
// An unauthenticated POST web route carrying the leaked canary in the api_key BODY field trips
|
|
// the middleware (broadcasting/auth is in the web group, so the appended DetectHoneytoken runs).
|
|
// The 403 fires before the route's own handler, proving the tripwire — not the endpoint — responded.
|
|
$response = $this->fromIp('POST', '/broadcasting/auth', self::ATTACKER_IP, ['api_key' => $canary]);
|
|
|
|
$response->assertStatus(403);
|
|
|
|
$this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists());
|
|
$this->assertTrue(
|
|
AuditEvent::where('action', 'security.honeytoken_used')
|
|
->where('target', 'api_key')
|
|
->where('ip', self::ATTACKER_IP)
|
|
->exists()
|
|
);
|
|
}
|
|
|
|
public function test_honeytoken_trip_still_bans_and_403s_when_the_audit_write_fails(): void
|
|
{
|
|
$canary = config('clusev.honeypot.canaries.api_key');
|
|
|
|
// The ban + 403 must survive a broken audit table (DB hiccup / un-run migration): dropping
|
|
// audit_events makes every AuditEvent::create throw (banNow's auth.ip_banned AND the honeytoken
|
|
// row), yet the trip must NOT become a 500 that leaks this is a real app, and the ban (a separate
|
|
// banned_ips write, run first + guarded) must still land.
|
|
Schema::drop('audit_events');
|
|
|
|
$this->fromIp('POST', '/broadcasting/auth', self::ATTACKER_IP, ['api_key' => $canary])
|
|
->assertStatus(403);
|
|
|
|
$this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists());
|
|
}
|
|
|
|
public function test_honeytoken_audit_is_capped_per_ip_but_still_bans(): void
|
|
{
|
|
$canary = config('clusev.honeypot.canaries.api_key');
|
|
$ip = '203.0.113.98';
|
|
|
|
// An exempt/rotating source could otherwise loop honeytoken trips to grow audit_events. Over the
|
|
// per-hour cap: no NEW honeytoken audit row, but the ban + 403 still land.
|
|
Cache::put('honeytoken:audit:'.md5($ip), 1000, now()->addHour());
|
|
|
|
$this->fromIp('POST', '/broadcasting/auth', $ip, ['api_key' => $canary])->assertStatus(403);
|
|
|
|
$this->assertSame(0, AuditEvent::where('action', 'security.honeytoken_used')->where('ip', $ip)->count());
|
|
$this->assertTrue(BannedIp::where('ip', $ip)->exists());
|
|
}
|
|
|
|
public function test_login_post_with_password_equal_to_canary_does_not_ban(): void
|
|
{
|
|
// `password` is no longer scanned — a login carrying a value that happens to equal a canary
|
|
// (e.g. a reflected value) must NOT trip the honeytoken tripwire.
|
|
$this->fromIp('POST', '/broadcasting/auth', self::ATTACKER_IP, ['password' => self::CANARY_DB]);
|
|
|
|
$this->assertDatabaseCount('banned_ips', 0);
|
|
$this->assertFalse(AuditEvent::where('action', 'security.honeytoken_used')->exists());
|
|
}
|
|
|
|
public function test_get_query_string_with_canary_token_does_not_ban(): void
|
|
{
|
|
// The query string is never scanned — a reflected GET decoy link ?token=<canary> must NOT ban
|
|
// the victim who clicks it.
|
|
$this->fromIp('GET', '/login?token='.self::CANARY_API, self::ATTACKER_IP);
|
|
|
|
$this->assertDatabaseCount('banned_ips', 0);
|
|
$this->assertFalse(AuditEvent::where('action', 'security.honeytoken_used')->exists());
|
|
}
|
|
|
|
public function test_authenticated_user_hitting_decoy_gets_404_and_is_not_banned(): void
|
|
{
|
|
$user = User::factory()->create(['must_change_password' => false]);
|
|
|
|
$response = $this->actingAs($user)->fromIp('GET', '/wp-login.php', self::ATTACKER_IP);
|
|
|
|
$response->assertStatus(404);
|
|
$this->assertDatabaseCount('banned_ips', 0);
|
|
$this->assertFalse(AuditEvent::where('action', 'security.honeypot_hit')->exists());
|
|
}
|
|
|
|
public function test_ban_now_fires_even_when_bruteforce_protection_is_disabled(): void
|
|
{
|
|
// Disabling the brute-force login toggle must NOT silently disable honeypot bans: banNow no
|
|
// longer consults enabled(). The honeypot flag alone gates whether the trap runs.
|
|
Setting::put('bruteforce_enabled', '0');
|
|
|
|
$banned = app(BruteforceGuard::class)->banNow(self::ATTACKER_IP, 'honeytoken');
|
|
|
|
$this->assertTrue($banned);
|
|
$this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists());
|
|
}
|
|
|
|
public function test_disabled_honeypot_returns_404_without_ban_or_audit(): void
|
|
{
|
|
config(['clusev.honeypot.enabled' => false]);
|
|
|
|
$response = $this->fromIp('GET', '/wp-login.php', self::ATTACKER_IP);
|
|
|
|
$response->assertStatus(404);
|
|
$this->assertDatabaseCount('banned_ips', 0);
|
|
$this->assertFalse(AuditEvent::where('action', 'security.honeypot_hit')->exists());
|
|
}
|
|
|
|
public function test_exempt_ip_gets_deception_but_is_not_banned(): void
|
|
{
|
|
// Default test client IP is 127.0.0.1 (loopback → exempt): deception still fires, ban does not.
|
|
$response = $this->get('/wp-login.php');
|
|
|
|
$response->assertStatus(200);
|
|
$response->assertSee('wp-submit', false);
|
|
|
|
// The hit is still audited, but banNow no-ops for the exempt IP → no ban row.
|
|
$this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists());
|
|
$this->assertDatabaseCount('banned_ips', 0);
|
|
}
|
|
}
|