Optimize redirect performance and protect password validation against brute force
This commit is contained in:
12190
composer.lock
generated
Normal file
12190
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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', [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user