feat(honeypot): trap fake-login submits + capture the attempted credentials

Previously a POST to a fake login hit a Laravel 419 (CSRF) or 405, unmasking the framework, and the
phpMyAdmin/generic forms even posted to real paths (/index.php, /login). Now: the decoy paths are
CSRF-exempt (bootstrap/app.php) and drop BlockBannedIp (an already-banned prober stays inside the
deception, always fed a fake — never a revealing 403 — while the ban still blocks them from the REAL
login). Every fake form posts back to its own decoy, so a submit re-serves the fake page (looks like a
failed login) and trap() records the guessed username + password as threat intel (meta.tried_user /
tried_pass). DetectHoneytoken stays on the group, so a submitted canary is still caught.
feat/v1-foundation
boban 2026-07-05 10:34:05 +02:00
parent 6c70d885f7
commit 473fa8c0d0
4 changed files with 115 additions and 25 deletions

View File

@ -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;
<div id="page">
<div class="logo"><svg viewBox="0 0 64 64" role="img" aria-label="database"><ellipse cx="32" cy="14" rx="22" ry="9" fill="#235a81"/><path d="M10 14v18c0 5 10 9 22 9s22-4 22-9V14c0 5-10 9-22 9s-22-4-22-9z" fill="#2f6d99"/><path d="M10 32v18c0 5 10 9 22 9s22-4 22-9V32c0 5-10 9-22 9s-22-4-22-9z" fill="#235a81"/></svg><span>php<b>My</b>Admin</span></div>
<h1>Welcome to phpMyAdmin</h1>
<form method="post" action="index.php" name="login_form" class="login">
<form method="post" action="phpmyadmin" name="login_form" class="login">
<fieldset>
<legend>Log in</legend>
<div class="item"><label for="input_username">Username:</label><input type="text" name="pma_username" id="input_username" value="" class="textfield" autocomplete="username"></div>
@ -254,7 +270,7 @@ button:hover{background:#1d4ed8}
<main>
<div class="brand"><svg viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="lock"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg><span>Admin Console</span></div>
<h1>Sign in to continue</h1>
<form method="post" action="login">
<form method="post" action="administrator">
<label for="username">Username</label>
<input type="text" name="username" id="username" autocomplete="username">
<label for="password">Password</label>

View File

@ -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(

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\HoneypotController;
use App\Http\Middleware\BlockBannedIp;
use App\Http\Middleware\EnsureSecurityOnboarded;
use App\Http\Middleware\SetLocale;
use App\Http\Middleware\VerifyTerminalSidecarSecret;
@ -129,6 +130,11 @@ Route::post('/_internal/terminal/resolve', function (Request $request) {
// (/locale, /_caddy/ask, /version.json, /update-status.json, /_internal/*) and the auth groups
// below (/login, /dashboard, …) — none collide. The two dotfile decoys (/.env, /.git/config) are
// additionally allowed through the nginx dotfile-deny guard (docker/nginx/nginx.conf).
// 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');
@ -145,6 +151,7 @@ Route::any('/vendor/{path?}', [HoneypotController::class, 'trap'])->where('path'
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');

View File

@ -0,0 +1,59 @@
<?php
namespace Tests\Feature;
use App\Models\AuditEvent;
use App\Models\BannedIp;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class HoneypotSubmitTest extends TestCase
{
use RefreshDatabase;
public function test_posting_the_fake_login_is_trapped_and_captures_credentials(): void
{
config(['clusev.honeypot.enabled' => 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);
}
}