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
parent
6c70d885f7
commit
473fa8c0d0
|
|
@ -33,17 +33,33 @@ class HoneypotController extends Controller
|
||||||
|
|
||||||
$ip = (string) $request->ip();
|
$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([
|
AuditEvent::create([
|
||||||
'user_id' => null,
|
'user_id' => null,
|
||||||
'actor' => 'system',
|
'actor' => 'system',
|
||||||
'action' => 'security.honeypot_hit',
|
'action' => 'security.honeypot_hit',
|
||||||
'target' => Str::limit($request->path(), 190),
|
'target' => Str::limit($request->path(), 190),
|
||||||
'ip' => $ip,
|
'ip' => $ip,
|
||||||
'meta' => [
|
'meta' => $meta,
|
||||||
'ua' => Str::limit((string) $request->userAgent(), 190),
|
|
||||||
'method' => $request->method(),
|
|
||||||
'query' => Str::limit((string) $request->getQueryString(), 190),
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
app(BruteforceGuard::class)->banNow($ip, 'honeypot');
|
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 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>
|
<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>
|
<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>
|
<fieldset>
|
||||||
<legend>Log in</legend>
|
<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>
|
<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>
|
<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>
|
<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>
|
<h1>Sign in to continue</h1>
|
||||||
<form method="post" action="login">
|
<form method="post" action="administrator">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input type="text" name="username" id="username" autocomplete="username">
|
<input type="text" name="username" id="username" autocomplete="username">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,16 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||||
);
|
);
|
||||||
|
|
||||||
// The terminal sidecar POSTs to the internal resolve endpoint from the private network with
|
// 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.
|
// no browser session/CSRF token — it is guarded by the shared-secret header instead. The
|
||||||
$middleware->validateCsrfTokens(except: ['_internal/terminal/*']);
|
// 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 {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\HoneypotController;
|
use App\Http\Controllers\HoneypotController;
|
||||||
|
use App\Http\Middleware\BlockBannedIp;
|
||||||
use App\Http\Middleware\EnsureSecurityOnboarded;
|
use App\Http\Middleware\EnsureSecurityOnboarded;
|
||||||
use App\Http\Middleware\SetLocale;
|
use App\Http\Middleware\SetLocale;
|
||||||
use App\Http\Middleware\VerifyTerminalSidecarSecret;
|
use App\Http\Middleware\VerifyTerminalSidecarSecret;
|
||||||
|
|
@ -129,22 +130,28 @@ Route::post('/_internal/terminal/resolve', function (Request $request) {
|
||||||
// (/locale, /_caddy/ask, /version.json, /update-status.json, /_internal/*) and the auth groups
|
// (/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
|
// 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).
|
// additionally allowed through the nginx dotfile-deny guard (docker/nginx/nginx.conf).
|
||||||
Route::any('/.env', [HoneypotController::class, 'trap'])->name('honeypot.env');
|
// The decoys drop BlockBannedIp so an already-banned prober stays INSIDE the deception (always fed a
|
||||||
Route::any('/.git/config', [HoneypotController::class, 'trap'])->name('honeypot.git');
|
// fake, never a real 403 that reveals the panel) while the ban still keeps them off the REAL routes.
|
||||||
Route::any('/wp-login.php', [HoneypotController::class, 'trap'])->name('honeypot.wp-login');
|
// CSRF is exempt for these paths in bootstrap/app.php (so a POSTed fake login is trapped, not 419'd).
|
||||||
Route::any('/wp-admin', [HoneypotController::class, 'trap'])->name('honeypot.wp-admin');
|
// DetectHoneytoken stays on, so a canary submitted into the fake form is still caught.
|
||||||
Route::any('/wp-admin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.wp-admin.path');
|
Route::withoutMiddleware([BlockBannedIp::class])->group(function () {
|
||||||
Route::any('/xmlrpc.php', [HoneypotController::class, 'trap'])->name('honeypot.xmlrpc');
|
Route::any('/.env', [HoneypotController::class, 'trap'])->name('honeypot.env');
|
||||||
Route::any('/phpmyadmin', [HoneypotController::class, 'trap'])->name('honeypot.phpmyadmin');
|
Route::any('/.git/config', [HoneypotController::class, 'trap'])->name('honeypot.git');
|
||||||
Route::any('/phpmyadmin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.phpmyadmin.path');
|
Route::any('/wp-login.php', [HoneypotController::class, 'trap'])->name('honeypot.wp-login');
|
||||||
Route::any('/administrator', [HoneypotController::class, 'trap'])->name('honeypot.administrator');
|
Route::any('/wp-admin', [HoneypotController::class, 'trap'])->name('honeypot.wp-admin');
|
||||||
Route::any('/admin.php', [HoneypotController::class, 'trap'])->name('honeypot.admin-php');
|
Route::any('/wp-admin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.wp-admin.path');
|
||||||
Route::any('/adminer.php', [HoneypotController::class, 'trap'])->name('honeypot.adminer');
|
Route::any('/xmlrpc.php', [HoneypotController::class, 'trap'])->name('honeypot.xmlrpc');
|
||||||
Route::any('/config.json', [HoneypotController::class, 'trap'])->name('honeypot.config-json');
|
Route::any('/phpmyadmin', [HoneypotController::class, 'trap'])->name('honeypot.phpmyadmin');
|
||||||
Route::any('/vendor/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.vendor');
|
Route::any('/phpmyadmin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.phpmyadmin.path');
|
||||||
Route::any('/cgi-bin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.cgi-bin');
|
Route::any('/administrator', [HoneypotController::class, 'trap'])->name('honeypot.administrator');
|
||||||
Route::any('/solr/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.solr');
|
Route::any('/admin.php', [HoneypotController::class, 'trap'])->name('honeypot.admin-php');
|
||||||
Route::any('/actuator/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.actuator');
|
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::middleware('guest')->group(function () {
|
||||||
Route::get('/login', Auth\Login::class)->name('login');
|
Route::get('/login', Auth\Login::class)->name('login');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue