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

View file

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

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { use, useState, useEffect } from 'react'; 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'; import api from '@/lib/api';
interface FileInfo { interface FileInfo {
@ -19,17 +19,30 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [requirePassword, setRequirePassword] = useState(false); const [requirePassword, setRequirePassword] = useState(false);
const [password, setPassword] = useState(''); 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 [token, setToken] = useState('');
const [fileInfo, setFileInfo] = useState<FileInfo | null>(null); const [fileInfo, setFileInfo] = useState<FileInfo | null>(null);
const [currentPath, setCurrentPath] = useState(''); 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(() => { useEffect(() => {
fetchShareInfo(password, currentPath); fetchShareInfo(password, currentPath);
}, [currentPath]); }, [currentPath]);
async function fetchShareInfo(pwd?: string, path?: string) { async function fetchShareInfo(pwd?: string, path?: string) {
if (lockoutTime > 0) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
setPasswordError(null);
try { try {
const data = await api.getPublicShare(id, pwd, path); const data = await api.getPublicShare(id, pwd, path);
if (data.require_password) { if (data.require_password) {
@ -47,6 +60,13 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri
} catch (err: any) { } catch (err: any) {
if (err.require_password) { if (err.require_password) {
setRequirePassword(true); 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 { } else {
setError(err.error || 'Failed to load share'); 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"> <form onSubmit={handlePasswordSubmit} className="space-y-6">
<div> <div>
<input <div className="relative">
type="password" <input
value={password} type={showPassword ? "text" : "password"}
onChange={(e) => setPassword(e.target.value)} value={password}
placeholder="Enter password" onChange={(e) => setPassword(e.target.value)}
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" placeholder="Enter password"
autoFocus 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> </div>
<button <button
type="submit" 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)' }} style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }}
> >
Unlock Access {lockoutTime > 0 ? `Wait ${lockoutTime}s` : 'Unlock Access'}
</button> </button>
</form> </form>
</div> </div>