feat(security): implement brute force rate limit for share passwords and improve UX with inline errors and visibility toggle
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m21s

This commit is contained in:
Elijah 2026-05-23 17:08:25 -07:00
parent 5262103a1a
commit 78ba0bf2d5
3 changed files with 65 additions and 14 deletions

View file

@ -19,6 +19,7 @@ import (
type ShareHandler struct {
DB *database.DB
Config *config.Config
Guard *middleware.BruteForceGuard
}
type ShareRequest struct {
@ -197,8 +198,14 @@ func (h *ShareHandler) GetPublicShare(c *fiber.Ctx) error {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"require_password": true})
}
if !verifyPassword(req.Password, *hash) {
if h.Guard != nil {
h.Guard.RecordFailure(c.IP())
}
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid password"})
}
if h.Guard != nil {
h.Guard.RecordSuccess(c.IP())
}
}
// Send file info
@ -273,8 +280,14 @@ func (h *ShareHandler) CreateShareToken(c *fiber.Ctx) error {
if hash != nil {
if req.Password == "" || !verifyPassword(req.Password, *hash) {
if h.Guard != nil {
h.Guard.RecordFailure(c.IP())
}
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid password"})
}
if h.Guard != nil {
h.Guard.RecordSuccess(c.IP())
}
}
// Generate a short-lived download token with the share ID

View file

@ -67,7 +67,8 @@ func main() {
tusHandler := handlers.NewTusHandler(db, cfg)
webdavHandler := handlers.NewWebDAVHandler(db, cfg)
archiveHandler := &handlers.ArchiveHandler{DB: db, Config: cfg}
shareHandler := &handlers.ShareHandler{DB: db, Config: cfg}
shareGuard := middleware.NewBruteForceGuard(3, 60)
shareHandler := &handlers.ShareHandler{DB: db, Config: cfg, Guard: shareGuard}
// Initialize background workers
workers.InitTaskManager(cfg)
@ -130,8 +131,8 @@ func main() {
Max: 500,
Expiration: 1 * time.Minute,
}))
public.Post("/share/:id/token", shareHandler.CreateShareToken)
public.Post("/share/:id", shareHandler.GetPublicShare)
public.Post("/share/:id/token", shareGuard.Check(), shareHandler.CreateShareToken)
public.Post("/share/:id", shareGuard.Check(), shareHandler.GetPublicShare)
public.Get("/download/:id", shareHandler.DownloadPublicShare)
// Auth routes (with brute force protection)

View file

@ -1,7 +1,7 @@
'use client';
import { use, useState, useEffect } from 'react';
import { Shield, Download, Lock, File, Folder, Loader2, ChevronRight, Home, Image as ImageIcon, Film, Music, FileText, Archive } from 'lucide-react';
import { Shield, Download, Lock, File, Folder, Loader2, ChevronRight, Home, Image as ImageIcon, Film, Music, FileText, Archive, Eye, EyeOff } from 'lucide-react';
import api from '@/lib/api';
interface FileInfo {
@ -19,17 +19,30 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri
const [error, setError] = useState<string | null>(null);
const [requirePassword, setRequirePassword] = useState(false);
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [lockoutTime, setLockoutTime] = useState(0);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [token, setToken] = useState('');
const [fileInfo, setFileInfo] = useState<FileInfo | null>(null);
const [currentPath, setCurrentPath] = useState('');
useEffect(() => {
if (lockoutTime <= 0) return;
const timer = setInterval(() => {
setLockoutTime(prev => Math.max(0, prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [lockoutTime]);
useEffect(() => {
fetchShareInfo(password, currentPath);
}, [currentPath]);
async function fetchShareInfo(pwd?: string, path?: string) {
if (lockoutTime > 0) return;
setLoading(true);
setError(null);
setPasswordError(null);
try {
const data = await api.getPublicShare(id, pwd, path);
if (data.require_password) {
@ -47,6 +60,13 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri
} catch (err: any) {
if (err.require_password) {
setRequirePassword(true);
} else if (err.retry_after) {
setRequirePassword(true);
setLockoutTime(err.retry_after);
setPasswordError(err.error || 'Too many attempts. Locked out.');
} else if (requirePassword || pwd) {
setRequirePassword(true);
setPasswordError(err.error || 'Invalid password');
} else {
setError(err.error || 'Failed to load share');
}
@ -117,21 +137,38 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri
<form onSubmit={handlePasswordSubmit} className="space-y-6">
<div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
className="w-full px-5 py-4 rounded-xl outline-none bg-black/20 border border-white/10 text-white placeholder-white/40 focus:border-white/30 transition-colors"
autoFocus
/>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
disabled={lockoutTime > 0}
className="w-full px-5 py-4 pr-12 rounded-xl outline-none bg-black/20 border border-white/10 text-white placeholder-white/40 focus:border-white/30 transition-colors disabled:opacity-50"
autoFocus
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white transition-colors"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{passwordError && (
<p className="mt-2 text-sm text-red-400">{passwordError}</p>
)}
{lockoutTime > 0 && (
<p className="mt-2 text-sm text-yellow-400">Locked out. Try again in {lockoutTime}s.</p>
)}
</div>
<button
type="submit"
className="w-full py-4 rounded-xl text-white font-semibold transition-all hover:scale-[1.02] active:scale-[0.98] shadow-lg"
disabled={lockoutTime > 0}
className="w-full py-4 rounded-xl text-white font-semibold transition-all hover:scale-[1.02] active:scale-[0.98] shadow-lg disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed"
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }}
>
Unlock Access
{lockoutTime > 0 ? `Wait ${lockoutTime}s` : 'Unlock Access'}
</button>
</form>
</div>