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
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m21s
This commit is contained in:
parent
5262103a1a
commit
78ba0bf2d5
3 changed files with 65 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
type={showPassword ? "text" : "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"
|
||||
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>
|
||||
|
|
|
|||
Reference in a new issue