Optimize redirect performance and protect password validation against brute force

This commit is contained in:
Bartłomiej Janczak
2026-06-04 12:26:21 +02:00
parent 325a38bfe8
commit 3a9575f5b1
4 changed files with 12245 additions and 14 deletions

12190
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -73,13 +73,27 @@ class ShortUrlRedirectController extends Controller
$sessionKey = "short-url-auth-{$shortUrl->id}";
if (! session()->get($sessionKey)) {
if ($request->isMethod('POST')) {
// Password brute force protection
$ipAddress = ClientIpExtractor::getIp($request);
$passwordLimiterKey = "short_url_password_limit:{$key}:".$ipAddress;
if (RateLimiter::tooManyAttempts($passwordLimiterKey, 5)) { // Max 5 attempts
$retryAfter = RateLimiter::availableIn($passwordLimiterKey);
abort(429, 'Too many incorrect password attempts. Please try again in '.$retryAfter.' seconds.', [
'Retry-After' => $retryAfter,
]);
}
$submitted = $request->input('password');
if ($submitted === $shortUrl->password) {
session()->put($sessionKey, true);
RateLimiter::clear($passwordLimiterKey);
return redirect()->to($request->fullUrl());
}
RateLimiter::hit($passwordLimiterKey, 60); // 1 minute decay
$errors = new MessageBag([
'password' => __('filament-short-url::default.password_error') ?? 'Incorrect password.',
]);
@@ -105,7 +119,7 @@ class ShortUrlRedirectController extends Controller
$matchedApp = AppLinkingEngine::matchApp($destination);
if ($matchedApp !== null) {
$deepLink = AppLinkingEngine::convertToScheme($destination, $matchedApp);
$activePixels = $shortUrl->pixels()->where('is_active', true)->get();
$activePixels = $shortUrl->pixels->where('is_active', true);
return response(view('filament-short-url::app-redirect', [
'destination' => $destination,
@@ -189,7 +203,7 @@ class ShortUrlRedirectController extends Controller
cache()->forget("filament-short-url:{$shortUrl->url_key}");
}
$activePixels = $shortUrl->pixels()->where('is_active', true)->get();
$activePixels = $shortUrl->pixels->where('is_active', true);
if ($activePixels->isNotEmpty()) {
return response(view('filament-short-url::pixel-loading', [

View File

@@ -183,13 +183,13 @@ class ShortUrl extends Model
$ttl = config('filament-short-url.cache_ttl', 3600);
if ($ttl <= 0) {
return static::where('url_key', $key)->first();
return static::where('url_key', $key)->with('pixels')->first();
}
return cache()->remember(
"filament-short-url:{$key}",
$ttl,
fn () => static::where('url_key', $key)->first()
fn () => static::where('url_key', $key)->with('pixels')->first()
);
}
@@ -240,6 +240,7 @@ class ShortUrl extends Model
static::saved(function (self $m) {
cache()->forget("filament-short-url:{$m->url_key}");
cache()->forget("filament-short-url:visits:{$m->id}");
if ($m->wasChanged('url_key')) {
$oldKey = $m->getOriginal('url_key');
@@ -250,6 +251,7 @@ class ShortUrl extends Model
});
static::deleted(function (self $m) {
cache()->forget("filament-short-url:{$m->url_key}");
cache()->forget("filament-short-url:visits:{$m->id}");
cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY);
if (! empty($m->qr_logo)) {
@@ -279,21 +281,15 @@ class ShortUrl extends Model
public function getRealTimeTotalVisits(): int
{
$buffered = 0;
if (config('filament-short-url.counter_buffering.enabled', false)) {
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
$buffered = (int) cache()->get("{$prefix}total:{$this->id}", 0);
return $this->total_visits + $buffered;
}
if ($this->max_visits !== null) {
$dbVal = (int) DB::table($this->table)
->where('id', $this->id)
->value('total_visits');
return $dbVal + $buffered;
}
return $this->total_visits + $buffered;
// Use real-time visit count in cache to keep the cached model instance updated
$cacheKey = "filament-short-url:visits:{$this->id}";
return (int) cache()->remember($cacheKey, 3600, fn () => $this->total_visits);
}
public function isActive(): bool
@@ -561,6 +557,18 @@ class ShortUrl extends Model
$this->newQuery()
->where('id', $this->id)
->increment('total_visits', 1, $updates);
// Keep the real-time cache count incremented
$cacheKey = "filament-short-url:visits:{$this->id}";
try {
if (cache()->has($cacheKey)) {
cache()->increment($cacheKey);
} else {
cache()->put($cacheKey, $this->total_visits + 1, 3600);
}
} catch (\Throwable $e) {
// Ignore cache errors in increment to never disrupt redirection
}
}
/**

View File

@@ -348,6 +348,25 @@ it('requires password to redirect when protected', function () {
$this->get('/s/password123')->assertRedirect('https://example.com');
});
it('applies rate limiting on password attempts when protected', function () {
$shortUrl = createShortUrl([
'url_key' => 'password-ratelimit',
'password' => 'secret-combination',
'track_visits' => false,
]);
// First 5 incorrect attempts should return 200 (renders password prompt again)
for ($i = 0; $i < 5; $i++) {
$this->post('/s/password-ratelimit', ['password' => 'wrong-pass'])
->assertStatus(200)
->assertSee('Incorrect password');
}
// 6th incorrect attempt should be rate limited (429)
$this->post('/s/password-ratelimit', ['password' => 'wrong-pass'])
->assertStatus(429);
});
it('shows warning page before redirecting when enabled', function () {
$shortUrl = createShortUrl([
'url_key' => 'warn1',