diff --git a/app/Http/Controllers/HoneypotController.php b/app/Http/Controllers/HoneypotController.php index e5528db..488e418 100644 --- a/app/Http/Controllers/HoneypotController.php +++ b/app/Http/Controllers/HoneypotController.php @@ -33,17 +33,33 @@ class HoneypotController extends Controller $ip = (string) $request->ip(); + $meta = [ + 'ua' => Str::limit((string) $request->userAgent(), 190), + 'method' => $request->method(), + 'query' => Str::limit((string) $request->getQueryString(), 190), + ]; + + // Capture the credentials a prober typed into the fake login — their guesses, which are + // threat intel (a legitimate user never reaches a decoy). The field names span the + // impersonated apps: WordPress (log/pwd), phpMyAdmin (pma_username/pma_password), and the + // generic form (username/password). This is the attacker's OWN submission, so logging + + // banning stays keyed to their IP — no reflected-victim risk. + $user = $request->input('log') ?? $request->input('pma_username') ?? $request->input('username'); + $pass = $request->input('pwd') ?? $request->input('pma_password') ?? $request->input('password'); + if (is_string($user) && $user !== '') { + $meta['tried_user'] = Str::limit($user, 120); + } + if (is_string($pass) && $pass !== '') { + $meta['tried_pass'] = Str::limit($pass, 120); + } + AuditEvent::create([ 'user_id' => null, 'actor' => 'system', 'action' => 'security.honeypot_hit', 'target' => Str::limit($request->path(), 190), 'ip' => $ip, - 'meta' => [ - 'ua' => Str::limit((string) $request->userAgent(), 190), - 'method' => $request->method(), - 'query' => Str::limit((string) $request->getQueryString(), 190), - ], + 'meta' => $meta, ]); app(BruteforceGuard::class)->banNow($ip, 'honeypot'); @@ -209,7 +225,7 @@ form.login legend{background:#e5e5e5;border:1px solid #a0a0a0;border-radius:4px;

Welcome to phpMyAdmin

-
+
Log in
@@ -254,7 +270,7 @@ button:hover{background:#1d4ed8}
Admin Console

Sign in to continue

- + diff --git a/bootstrap/app.php b/bootstrap/app.php index f44a404..ddfb28e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -50,8 +50,16 @@ return Application::configure(basePath: dirname(__DIR__)) ); // The terminal sidecar POSTs to the internal resolve endpoint from the private network with - // no browser session/CSRF token — it is guarded by the shared-secret header instead. - $middleware->validateCsrfTokens(except: ['_internal/terminal/*']); + // no browser session/CSRF token — it is guarded by the shared-secret header instead. The + // honeypot decoys are also exempt: a probe POSTing the fake login must be trapped + logged, + // not bounced with a 419 that unmasks the framework (these paths reach only HoneypotController). + $middleware->validateCsrfTokens(except: [ + '_internal/terminal/*', + '.env', '.git/config', + 'wp-login.php', 'wp-admin', 'wp-admin/*', 'xmlrpc.php', + 'phpmyadmin', 'phpmyadmin/*', 'administrator', 'admin.php', 'adminer.php', + 'config.json', 'vendor/*', 'cgi-bin/*', 'solr/*', 'actuator/*', + ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/routes/web.php b/routes/web.php index fbe43ca..4fc032f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('honeypot.env'); -Route::any('/.git/config', [HoneypotController::class, 'trap'])->name('honeypot.git'); -Route::any('/wp-login.php', [HoneypotController::class, 'trap'])->name('honeypot.wp-login'); -Route::any('/wp-admin', [HoneypotController::class, 'trap'])->name('honeypot.wp-admin'); -Route::any('/wp-admin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.wp-admin.path'); -Route::any('/xmlrpc.php', [HoneypotController::class, 'trap'])->name('honeypot.xmlrpc'); -Route::any('/phpmyadmin', [HoneypotController::class, 'trap'])->name('honeypot.phpmyadmin'); -Route::any('/phpmyadmin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.phpmyadmin.path'); -Route::any('/administrator', [HoneypotController::class, 'trap'])->name('honeypot.administrator'); -Route::any('/admin.php', [HoneypotController::class, 'trap'])->name('honeypot.admin-php'); -Route::any('/adminer.php', [HoneypotController::class, 'trap'])->name('honeypot.adminer'); -Route::any('/config.json', [HoneypotController::class, 'trap'])->name('honeypot.config-json'); -Route::any('/vendor/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.vendor'); -Route::any('/cgi-bin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.cgi-bin'); -Route::any('/solr/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.solr'); -Route::any('/actuator/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.actuator'); +// The decoys drop BlockBannedIp so an already-banned prober stays INSIDE the deception (always fed a +// fake, never a real 403 that reveals the panel) while the ban still keeps them off the REAL routes. +// CSRF is exempt for these paths in bootstrap/app.php (so a POSTed fake login is trapped, not 419'd). +// DetectHoneytoken stays on, so a canary submitted into the fake form is still caught. +Route::withoutMiddleware([BlockBannedIp::class])->group(function () { + Route::any('/.env', [HoneypotController::class, 'trap'])->name('honeypot.env'); + Route::any('/.git/config', [HoneypotController::class, 'trap'])->name('honeypot.git'); + Route::any('/wp-login.php', [HoneypotController::class, 'trap'])->name('honeypot.wp-login'); + Route::any('/wp-admin', [HoneypotController::class, 'trap'])->name('honeypot.wp-admin'); + Route::any('/wp-admin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.wp-admin.path'); + Route::any('/xmlrpc.php', [HoneypotController::class, 'trap'])->name('honeypot.xmlrpc'); + Route::any('/phpmyadmin', [HoneypotController::class, 'trap'])->name('honeypot.phpmyadmin'); + Route::any('/phpmyadmin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.phpmyadmin.path'); + Route::any('/administrator', [HoneypotController::class, 'trap'])->name('honeypot.administrator'); + Route::any('/admin.php', [HoneypotController::class, 'trap'])->name('honeypot.admin-php'); + Route::any('/adminer.php', [HoneypotController::class, 'trap'])->name('honeypot.adminer'); + Route::any('/config.json', [HoneypotController::class, 'trap'])->name('honeypot.config-json'); + Route::any('/vendor/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.vendor'); + Route::any('/cgi-bin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.cgi-bin'); + Route::any('/solr/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.solr'); + Route::any('/actuator/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.actuator'); +}); Route::middleware('guest')->group(function () { Route::get('/login', Auth\Login::class)->name('login'); diff --git a/tests/Feature/HoneypotSubmitTest.php b/tests/Feature/HoneypotSubmitTest.php new file mode 100644 index 0000000..4b35562 --- /dev/null +++ b/tests/Feature/HoneypotSubmitTest.php @@ -0,0 +1,59 @@ + true]); + + $res = $this->call('POST', '/wp-login.php', ['log' => 'admin', 'pwd' => 'Hunter2!'], [], [], ['REMOTE_ADDR' => '203.0.113.77']); + + // Served the fake WordPress page (200), NOT a Laravel 419/405 that would unmask the framework. + $res->assertStatus(200); + $res->assertSee('WordPress', false); + + // The attacker's guessed credentials are logged as threat intel. + $e = AuditEvent::where('action', 'security.honeypot_hit')->latest('id')->first(); + $this->assertNotNull($e); + $this->assertSame('admin', $e->meta['tried_user']); + $this->assertSame('Hunter2!', $e->meta['tried_pass']); + } + + public function test_phpmyadmin_and_generic_submits_are_trapped_not_405(): void + { + config(['clusev.honeypot.enabled' => true]); + + $this->call('POST', '/phpmyadmin', ['pma_username' => 'root', 'pma_password' => 'toor'], [], [], ['REMOTE_ADDR' => '203.0.113.80']) + ->assertStatus(200); + $this->assertSame('root', AuditEvent::where('action', 'security.honeypot_hit')->latest('id')->first()->meta['tried_user']); + + $this->call('POST', '/administrator', ['username' => 'sa', 'password' => 'pw'], [], [], ['REMOTE_ADDR' => '203.0.113.81']) + ->assertStatus(200); + $this->assertSame('sa', AuditEvent::where('action', 'security.honeypot_hit')->latest('id')->first()->meta['tried_user']); + } + + public function test_banned_ip_stays_in_the_decoy_but_is_blocked_from_the_real_login(): void + { + config(['clusev.honeypot.enabled' => true]); + + // First hit from a non-exempt IP instant-bans it. + $this->call('GET', '/wp-login.php', [], [], [], ['REMOTE_ADDR' => '203.0.113.79'])->assertStatus(200); + $this->assertTrue(BannedIp::where('ip', '203.0.113.79')->exists()); + + // The now-banned prober still gets the fake decoy (BlockBannedIp is off for decoys) — kept inside + // the deception, never told they're blocked. + $this->call('GET', '/wp-login.php', [], [], [], ['REMOTE_ADDR' => '203.0.113.79'])->assertStatus(200); + + // …but the ban still keeps them off the REAL login (BlockBannedIp -> 403 for a banned guest). + $this->call('GET', '/login', [], [], [], ['REMOTE_ADDR' => '203.0.113.79'])->assertStatus(403); + } +}