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();
|
||||
|
||||
$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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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