mirror of
https://github.com/yggdrasil-network/yggdrasil-go.git
synced 2025-08-24 16:05:07 +03:00
Add brute force protection to authentication system
- Implemented IP-based blocking after 3 failed login attempts, with a 1-minute lockout period. - Enhanced login handler to check for blocked IPs and record failed attempts. - Added tests for brute force protection and successful login clearing failed attempts. - Updated README and example configuration to document new security features.
This commit is contained in:
parent
113dcbb72a
commit
a984fba30d
5 changed files with 309 additions and 20 deletions
|
@ -9,6 +9,7 @@ This module provides a web interface for managing Yggdrasil node through a brows
|
||||||
- ✅ Development and production build modes
|
- ✅ Development and production build modes
|
||||||
- ✅ Custom session-based authentication
|
- ✅ Custom session-based authentication
|
||||||
- ✅ Beautiful login page (password-only)
|
- ✅ Beautiful login page (password-only)
|
||||||
|
- ✅ **Brute force protection** with IP blocking
|
||||||
- ✅ Session management with automatic cleanup
|
- ✅ Session management with automatic cleanup
|
||||||
- ✅ IPv4 and IPv6 support
|
- ✅ IPv4 and IPv6 support
|
||||||
- ✅ Path traversal attack protection
|
- ✅ Path traversal attack protection
|
||||||
|
@ -90,6 +91,10 @@ server.Stop()
|
||||||
- Custom session-based authentication (password protection)
|
- Custom session-based authentication (password protection)
|
||||||
- HttpOnly and Secure cookies
|
- HttpOnly and Secure cookies
|
||||||
- Session expiration (24 hours)
|
- Session expiration (24 hours)
|
||||||
|
- **Brute force protection**: IP blocking after 3 failed attempts
|
||||||
|
- **Temporary lockout**: 1-minute timeout for blocked IPs
|
||||||
|
- Automatic cleanup of expired blocks and sessions
|
||||||
|
- Real IP detection (supports X-Forwarded-For, X-Real-IP headers)
|
||||||
- Health check endpoint always accessible without authentication
|
- Health check endpoint always accessible without authentication
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
|
@ -145,3 +145,143 @@ func TestHealthEndpointNoAuth(t *testing.T) {
|
||||||
t.Errorf("Expected 'OK', got '%s'", rr.Body.String())
|
t.Errorf("Expected 'OK', got '%s'", rr.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBruteForceProtection(t *testing.T) {
|
||||||
|
logger := log.New(nil, "test: ", log.Flags())
|
||||||
|
server := Server("127.0.0.1:0", "testpassword", logger)
|
||||||
|
|
||||||
|
// Test multiple failed attempts from same IP
|
||||||
|
clientIP := "192.168.1.100"
|
||||||
|
|
||||||
|
// First 3 attempts should be allowed but fail
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
t.Run(fmt.Sprintf("Failed_attempt_%d", i), func(t *testing.T) {
|
||||||
|
loginData := `{"password":"wrongpassword"}`
|
||||||
|
req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.RemoteAddr = clientIP + ":12345"
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
server.loginHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("Expected 401 for failed attempt %d, got %d", i, rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4th attempt should be blocked
|
||||||
|
t.Run("Blocked_attempt", func(t *testing.T) {
|
||||||
|
loginData := `{"password":"wrongpassword"}`
|
||||||
|
req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.RemoteAddr = clientIP + ":12345"
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
server.loginHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusTooManyRequests {
|
||||||
|
t.Errorf("Expected 429 for blocked attempt, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Even correct password should be blocked during block period
|
||||||
|
t.Run("Correct_password_while_blocked", func(t *testing.T) {
|
||||||
|
loginData := `{"password":"testpassword"}`
|
||||||
|
req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.RemoteAddr = clientIP + ":12345"
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
server.loginHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusTooManyRequests {
|
||||||
|
t.Errorf("Expected 429 even for correct password while blocked, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBruteForceProtectionDifferentIPs(t *testing.T) {
|
||||||
|
logger := log.New(nil, "test: ", log.Flags())
|
||||||
|
server := Server("127.0.0.1:0", "testpassword", logger)
|
||||||
|
|
||||||
|
// Failed attempts from one IP shouldn't affect another IP
|
||||||
|
ip1 := "192.168.1.100"
|
||||||
|
ip2 := "192.168.1.101"
|
||||||
|
|
||||||
|
// Block first IP
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
loginData := `{"password":"wrongpassword"}`
|
||||||
|
req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.RemoteAddr = ip1 + ":12345"
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
server.loginHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("Expected 401 for failed attempt %d from IP1, got %d", i, rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second IP should still be able to attempt login
|
||||||
|
t.Run("Different_IP_not_blocked", func(t *testing.T) {
|
||||||
|
loginData := `{"password":"wrongpassword"}`
|
||||||
|
req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.RemoteAddr = ip2 + ":12345"
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
server.loginHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("Expected 401 for different IP (not blocked), got %d", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuccessfulLoginClearsFailedAttempts(t *testing.T) {
|
||||||
|
logger := log.New(nil, "test: ", log.Flags())
|
||||||
|
server := Server("127.0.0.1:0", "testpassword", logger)
|
||||||
|
|
||||||
|
clientIP := "192.168.1.100"
|
||||||
|
|
||||||
|
// Make 2 failed attempts
|
||||||
|
for i := 1; i <= 2; i++ {
|
||||||
|
loginData := `{"password":"wrongpassword"}`
|
||||||
|
req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.RemoteAddr = clientIP + ":12345"
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
server.loginHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("Expected 401 for failed attempt %d, got %d", i, rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful login should clear failed attempts
|
||||||
|
t.Run("Successful_login_clears_attempts", func(t *testing.T) {
|
||||||
|
loginData := `{"password":"testpassword"}`
|
||||||
|
req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.RemoteAddr = clientIP + ":12345"
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
server.loginHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected 200 for correct password, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify failed attempts were cleared
|
||||||
|
server.attemptsMux.RLock()
|
||||||
|
_, exists := server.failedAttempts[clientIP]
|
||||||
|
server.attemptsMux.RUnlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
t.Error("Failed attempts should be cleared after successful login")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -33,9 +33,13 @@
|
||||||
// - Session-based authentication with secure cookies
|
// - Session-based authentication with secure cookies
|
||||||
// - 24-hour session expiration
|
// - 24-hour session expiration
|
||||||
// - Automatic session cleanup
|
// - Automatic session cleanup
|
||||||
|
// - Brute force protection (3 failed attempts = 1 minute block)
|
||||||
|
// - IP-based blocking with automatic cleanup
|
||||||
//
|
//
|
||||||
// Security recommendations:
|
// Security recommendations:
|
||||||
// - Use a strong, unique password (12+ characters)
|
// - Use a strong, unique password (12+ characters)
|
||||||
// - Bind to localhost (127.0.0.1) unless you need remote access
|
// - Bind to localhost (127.0.0.1) unless you need remote access
|
||||||
// - Consider using HTTPS reverse proxy for production deployments
|
// - Consider using HTTPS reverse proxy for production deployments
|
||||||
// - Sessions are stored in memory and lost on server restart
|
// - Sessions are stored in memory and lost on server restart
|
||||||
|
// - Failed login attempts are tracked per IP address
|
||||||
|
// - If behind a reverse proxy, ensure X-Forwarded-For headers are set correctly
|
|
@ -16,24 +16,39 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebUIServer struct {
|
type WebUIServer struct {
|
||||||
server *http.Server
|
server *http.Server
|
||||||
log core.Logger
|
log core.Logger
|
||||||
listen string
|
listen string
|
||||||
password string
|
password string
|
||||||
sessions map[string]time.Time // sessionID -> expiry time
|
sessions map[string]time.Time // sessionID -> expiry time
|
||||||
sessionsMux sync.RWMutex
|
sessionsMux sync.RWMutex
|
||||||
|
failedAttempts map[string]*FailedLoginInfo // IP -> failed login info
|
||||||
|
attemptsMux sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FailedLoginInfo struct {
|
||||||
|
Count int
|
||||||
|
LastAttempt time.Time
|
||||||
|
BlockedUntil time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxFailedAttempts = 3
|
||||||
|
BlockDuration = 1 * time.Minute
|
||||||
|
AttemptWindow = 15 * time.Minute // Reset counter if no attempts in 15 minutes
|
||||||
|
)
|
||||||
|
|
||||||
func Server(listen string, password string, log core.Logger) *WebUIServer {
|
func Server(listen string, password string, log core.Logger) *WebUIServer {
|
||||||
return &WebUIServer{
|
return &WebUIServer{
|
||||||
listen: listen,
|
listen: listen,
|
||||||
password: password,
|
password: password,
|
||||||
log: log,
|
log: log,
|
||||||
sessions: make(map[string]time.Time),
|
sessions: make(map[string]time.Time),
|
||||||
|
failedAttempts: make(map[string]*FailedLoginInfo),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,6 +94,89 @@ func (w *WebUIServer) createSession() string {
|
||||||
return sessionID
|
return sessionID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getClientIP extracts the real client IP from request
|
||||||
|
func (w *WebUIServer) getClientIP(r *http.Request) string {
|
||||||
|
// Check for forwarded IP headers (for reverse proxies)
|
||||||
|
forwarded := r.Header.Get("X-Forwarded-For")
|
||||||
|
if forwarded != "" {
|
||||||
|
// Take the first IP in the chain
|
||||||
|
ips := strings.Split(forwarded, ",")
|
||||||
|
return strings.TrimSpace(ips[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
realIP := r.Header.Get("X-Real-IP")
|
||||||
|
if realIP != "" {
|
||||||
|
return realIP
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract IP from RemoteAddr
|
||||||
|
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
|
// isIPBlocked checks if an IP address is currently blocked
|
||||||
|
func (w *WebUIServer) isIPBlocked(ip string) bool {
|
||||||
|
w.attemptsMux.RLock()
|
||||||
|
defer w.attemptsMux.RUnlock()
|
||||||
|
|
||||||
|
info, exists := w.failedAttempts[ip]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Now().Before(info.BlockedUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordFailedAttempt records a failed login attempt for an IP
|
||||||
|
func (w *WebUIServer) recordFailedAttempt(ip string) {
|
||||||
|
w.attemptsMux.Lock()
|
||||||
|
defer w.attemptsMux.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
info, exists := w.failedAttempts[ip]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
info = &FailedLoginInfo{}
|
||||||
|
w.failedAttempts[ip] = info
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset counter if last attempt was too long ago
|
||||||
|
if now.Sub(info.LastAttempt) > AttemptWindow {
|
||||||
|
info.Count = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Count++
|
||||||
|
info.LastAttempt = now
|
||||||
|
|
||||||
|
// Block IP if too many failed attempts
|
||||||
|
if info.Count >= MaxFailedAttempts {
|
||||||
|
info.BlockedUntil = now.Add(BlockDuration)
|
||||||
|
w.log.Warnf("IP %s blocked for %v after %d failed login attempts", ip, BlockDuration, info.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearFailedAttempts clears failed attempts for an IP (on successful login)
|
||||||
|
func (w *WebUIServer) clearFailedAttempts(ip string) {
|
||||||
|
w.attemptsMux.Lock()
|
||||||
|
defer w.attemptsMux.Unlock()
|
||||||
|
|
||||||
|
delete(w.failedAttempts, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupFailedAttempts removes old failed attempt records
|
||||||
|
func (w *WebUIServer) cleanupFailedAttempts() {
|
||||||
|
w.attemptsMux.Lock()
|
||||||
|
defer w.attemptsMux.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for ip, info := range w.failedAttempts {
|
||||||
|
// Remove if block period has expired and no recent attempts
|
||||||
|
if now.After(info.BlockedUntil) && now.Sub(info.LastAttempt) > AttemptWindow {
|
||||||
|
delete(w.failedAttempts, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// cleanupExpiredSessions removes expired sessions
|
// cleanupExpiredSessions removes expired sessions
|
||||||
func (w *WebUIServer) cleanupExpiredSessions() {
|
func (w *WebUIServer) cleanupExpiredSessions() {
|
||||||
w.sessionsMux.Lock()
|
w.sessionsMux.Lock()
|
||||||
|
@ -126,13 +224,22 @@ func (w *WebUIServer) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loginHandler handles password authentication
|
// loginHandler handles password authentication with brute force protection
|
||||||
func (w *WebUIServer) loginHandler(rw http.ResponseWriter, r *http.Request) {
|
func (w *WebUIServer) loginHandler(rw http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientIP := w.getClientIP(r)
|
||||||
|
|
||||||
|
// Check if IP is blocked
|
||||||
|
if w.isIPBlocked(clientIP) {
|
||||||
|
w.log.Warnf("Blocked login attempt from %s (IP is temporarily blocked)", clientIP)
|
||||||
|
http.Error(rw, "Too many failed attempts. Please try again later.", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var loginReq LoginRequest
|
var loginReq LoginRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&loginReq); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&loginReq); err != nil {
|
||||||
http.Error(rw, "Invalid request", http.StatusBadRequest)
|
http.Error(rw, "Invalid request", http.StatusBadRequest)
|
||||||
|
@ -141,11 +248,15 @@ func (w *WebUIServer) loginHandler(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Check password
|
// Check password
|
||||||
if subtle.ConstantTimeCompare([]byte(loginReq.Password), []byte(w.password)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(loginReq.Password), []byte(w.password)) != 1 {
|
||||||
w.log.Debugf("Authentication failed for request from %s", r.RemoteAddr)
|
w.log.Debugf("Authentication failed for request from %s", clientIP)
|
||||||
|
w.recordFailedAttempt(clientIP)
|
||||||
http.Error(rw, "Invalid password", http.StatusUnauthorized)
|
http.Error(rw, "Invalid password", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Successful login - clear any failed attempts
|
||||||
|
w.clearFailedAttempts(clientIP)
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
sessionID := w.createSession()
|
sessionID := w.createSession()
|
||||||
|
|
||||||
|
@ -160,7 +271,7 @@ func (w *WebUIServer) loginHandler(rw http.ResponseWriter, r *http.Request) {
|
||||||
MaxAge: 24 * 60 * 60, // 24 hours
|
MaxAge: 24 * 60 * 60, // 24 hours
|
||||||
})
|
})
|
||||||
|
|
||||||
w.log.Debugf("Successful authentication for request from %s", r.RemoteAddr)
|
w.log.Infof("Successful authentication for IP %s", clientIP)
|
||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -196,12 +307,20 @@ func (w *WebUIServer) Start() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start session cleanup routine
|
// Start cleanup routines
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(1 * time.Hour)
|
sessionTicker := time.NewTicker(1 * time.Hour)
|
||||||
defer ticker.Stop()
|
attemptsTicker := time.NewTicker(5 * time.Minute) // Clean failed attempts more frequently
|
||||||
for range ticker.C {
|
defer sessionTicker.Stop()
|
||||||
w.cleanupExpiredSessions()
|
defer attemptsTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-sessionTicker.C:
|
||||||
|
w.cleanupExpiredSessions()
|
||||||
|
case <-attemptsTicker.C:
|
||||||
|
w.cleanupFailedAttempts()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
@ -63,6 +63,13 @@
|
||||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-group input:disabled {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-color: #e9ecef;
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.login-button {
|
.login-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
@ -144,8 +151,22 @@
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Success - redirect to main page
|
// Success - redirect to main page
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
|
} else if (response.status === 429) {
|
||||||
|
// Too many requests - IP blocked
|
||||||
|
errorMessage.textContent = 'Too many failed attempts. Please wait 1 minute before trying again.';
|
||||||
|
errorMessage.style.display = 'block';
|
||||||
|
document.getElementById('password').value = '';
|
||||||
|
document.getElementById('password').disabled = true;
|
||||||
|
|
||||||
|
// Re-enable after 1 minute
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('password').disabled = false;
|
||||||
|
document.getElementById('password').focus();
|
||||||
|
errorMessage.style.display = 'none';
|
||||||
|
}, 60000);
|
||||||
} else {
|
} else {
|
||||||
// Show error message
|
// Invalid password
|
||||||
|
errorMessage.textContent = 'Invalid password. Please try again.';
|
||||||
errorMessage.style.display = 'block';
|
errorMessage.style.display = 'block';
|
||||||
document.getElementById('password').value = '';
|
document.getElementById('password').value = '';
|
||||||
document.getElementById('password').focus();
|
document.getElementById('password').focus();
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue