fix: harden auth timing, cookie attrs, password gen bias, email template escaping; add security tests

This commit is contained in:
2026-04-08 20:45:16 +02:00
parent fe31ef5a3c
commit ca402eb88e
7 changed files with 549 additions and 25 deletions
+1 -1
View File
@@ -180,7 +180,7 @@ WARN: KEYWARDEN_TRUSTED_PROXIES not set proxy headers (X-Forwarded-For) are
- Cookie name: `keywarden_session`
- Cookie flags:
- `HttpOnly` — Not accessible via JavaScript
- `SameSite=Lax` — Prevents CSRF from external sites
- `SameSite=Strict` — Prevents CSRF from external sites
- `Secure` — Only over HTTPS (when enabled)
- `MaxAge=86400` — 24 hours
- Sessions stored in-memory (not persisted across restarts)
+26 -8
View File
@@ -77,6 +77,10 @@ func (s *Service) Register(username, email, password, role string, mustChangePas
}, nil
}
// dummyHash is a pre-computed bcrypt hash used for constant-time comparison
// when a user is not found, preventing timing-based username enumeration.
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy-constant-time-padding"), bcrypt.DefaultCost)
// Login authenticates a user and returns the user if successful
func (s *Service) Login(username, password string) (*models.User, error) {
user := &models.User{}
@@ -86,6 +90,10 @@ func (s *Service) Login(username, password string) (*models.User, error) {
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.MFAEnabled, &user.MFASecret, &user.Theme, &user.EmailNotifyLogin, &user.MustChangePassword, &user.FailedLoginAttempts, &user.LockedUntil, &user.CreatedAt, &user.UpdatedAt)
if err == sql.ErrNoRows {
// Perform a dummy bcrypt comparison to prevent timing-based username enumeration.
// Without this, an attacker could distinguish "user not found" (fast) from
// "wrong password" (slow due to bcrypt) by measuring response time.
bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
return nil, ErrInvalidCredentials
}
if err != nil {
@@ -311,17 +319,27 @@ func (s *Service) markInitialSetupComplete() {
)
}
// generateSecurePassword creates a cryptographically secure random password
// generateSecurePassword creates a cryptographically secure random password.
// It uses rejection sampling to avoid modulo bias when mapping random bytes
// to the character set.
func generateSecurePassword(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "", err
const cLen = byte(len(charset)) // 62
const maxUnbiased = 256 - (256 % int(cLen)) // 252 — largest multiple of 62 that fits in a byte
result := make([]byte, length)
for i := 0; i < length; {
var b [1]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
if int(b[0]) >= maxUnbiased {
continue // reject to eliminate modulo bias
}
result[i] = charset[b[0]%cLen]
i++
}
for i := range b {
b[i] = charset[b[i]%byte(len(charset))]
}
return string(b), nil
return string(result), nil
}
// UpdateUser updates user details (admin function)
+134
View File
@@ -0,0 +1,134 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package auth
import (
"strings"
"testing"
"unicode"
)
// ---------- generateSecurePassword ----------
func TestGenerateSecurePassword_Length(t *testing.T) {
for _, length := range []int{8, 16, 20, 32, 64} {
pw, err := generateSecurePassword(length)
if err != nil {
t.Fatalf("generateSecurePassword(%d) error: %v", length, err)
}
if len(pw) != length {
t.Fatalf("generateSecurePassword(%d) returned length %d", length, len(pw))
}
}
}
func TestGenerateSecurePassword_CharacterSet(t *testing.T) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
pw, err := generateSecurePassword(1000)
if err != nil {
t.Fatalf("generateSecurePassword error: %v", err)
}
for i, c := range pw {
if !strings.ContainsRune(charset, c) {
t.Fatalf("character at position %d (%c) not in allowed charset", i, c)
}
}
}
func TestGenerateSecurePassword_Uniqueness(t *testing.T) {
passwords := make(map[string]bool)
for i := 0; i < 100; i++ {
pw, err := generateSecurePassword(20)
if err != nil {
t.Fatalf("generateSecurePassword error: %v", err)
}
if passwords[pw] {
t.Fatal("generated duplicate password — insufficient randomness")
}
passwords[pw] = true
}
}
func TestGenerateSecurePassword_NoBias(t *testing.T) {
// Generate many characters and check that the distribution is roughly
// uniform. With rejection sampling and charset length 62, each character
// should appear about 1/62 ≈ 1.6% of the time. We use a generous margin.
const total = 62000
pw, err := generateSecurePassword(total)
if err != nil {
t.Fatalf("generateSecurePassword error: %v", err)
}
freq := make(map[rune]int)
for _, c := range pw {
freq[c]++
}
expected := float64(total) / 62.0 // ~1000
for c, count := range freq {
ratio := float64(count) / expected
// Allow 20% deviation (generous for 62k samples)
if ratio < 0.8 || ratio > 1.2 {
t.Errorf("character %c appeared %d times (expected ~%.0f, ratio %.2f) — possible bias", c, count, expected, ratio)
}
}
}
// ---------- dummyHash (timing attack prevention) ----------
func TestDummyHash_IsValid(t *testing.T) {
if dummyHash == nil {
t.Fatal("dummyHash should not be nil")
}
if len(dummyHash) == 0 {
t.Fatal("dummyHash should not be empty")
}
// It should be a valid bcrypt hash (starts with $2a$ or $2b$)
s := string(dummyHash)
if !strings.HasPrefix(s, "$2a$") && !strings.HasPrefix(s, "$2b$") {
t.Fatalf("dummyHash does not look like a bcrypt hash: %s", s)
}
}
// ---------- Password character class helpers ----------
func TestPasswordCharacterClasses(t *testing.T) {
tests := []struct {
password string
hasUpper bool
hasLower bool
hasDigit bool
hasSpecial bool
}{
{"abc", false, true, false, false},
{"ABC", true, false, false, false},
{"123", false, false, true, false},
{"!@#", false, false, false, true},
{"aB1!", true, true, true, true},
{"", false, false, false, false},
}
for _, tt := range tests {
var upper, lower, digit, special bool
for _, r := range tt.password {
if unicode.IsUpper(r) {
upper = true
}
if unicode.IsLower(r) {
lower = true
}
if unicode.IsDigit(r) {
digit = true
}
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
special = true
}
}
if upper != tt.hasUpper || lower != tt.hasLower || digit != tt.hasDigit || special != tt.hasSpecial {
t.Errorf("password %q: got upper=%v lower=%v digit=%v special=%v, want upper=%v lower=%v digit=%v special=%v",
tt.password, upper, lower, digit, special, tt.hasUpper, tt.hasLower, tt.hasDigit, tt.hasSpecial)
}
}
}
+5 -2
View File
@@ -19,9 +19,12 @@ type Service struct {
key []byte // 32 bytes for AES-256
}
// NewService creates a new encryption service from a passphrase
// NewService creates a new encryption service from a passphrase.
//
// NOTE: Key derivation currently uses SHA-256 for backward compatibility with
// existing encrypted data. A migration to a proper KDF (e.g. Argon2id) would
// require re-encrypting all stored secrets and is tracked as a future improvement.
func NewService(passphrase string) *Service {
// Derive a 32-byte key from the passphrase using SHA-256
hash := sha256.Sum256([]byte(passphrase))
return &Service{key: hash[:]}
}
+14 -10
View File
@@ -567,11 +567,13 @@ func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc {
h.mu.Unlock()
logging.Info("Session expired for user ID %d due to inactivity (%v timeout)", sess.UserID, timeout)
http.SetCookie(w, &http.Cookie{
Name: "keywarden_session",
Value: "",
Path: "/",
Secure: h.secureCookies,
MaxAge: -1,
Name: "keywarden_session",
Value: "",
Path: "/",
HttpOnly: true,
Secure: h.secureCookies,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
@@ -856,11 +858,13 @@ func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
}
http.SetCookie(w, &http.Cookie{
Name: "keywarden_session",
Value: "",
Path: "/",
Secure: h.secureCookies,
MaxAge: -1,
Name: "keywarden_session",
Value: "",
Path: "/",
HttpOnly: true,
Secure: h.secureCookies,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
+19 -4
View File
@@ -8,11 +8,12 @@ import (
"bytes"
"crypto/tls"
"fmt"
htmltpl "html/template"
"mime"
"net"
"net/smtp"
"strings"
"text/template"
texttpl "text/template"
"time"
"git.techniverse.net/scriptos/keywarden/internal/config"
@@ -66,7 +67,7 @@ func (s *Service) SendLoginNotification(toEmail string, data LoginNotificationDa
return nil
}
htmlBody, err := renderTemplate(loginNotificationHTML, data)
htmlBody, err := renderHTMLTemplate(loginNotificationHTML, data)
if err != nil {
return fmt.Errorf("failed to render HTML template: %w", err)
}
@@ -108,7 +109,7 @@ func (s *Service) SendInvitation(toEmail string, data InvitationData) error {
return fmt.Errorf("email is not configured (KEYWARDEN_SMTP_HOST not set)")
}
htmlBody, err := renderTemplate(invitationHTML, data)
htmlBody, err := renderHTMLTemplate(invitationHTML, data)
if err != nil {
return fmt.Errorf("failed to render invitation HTML template: %w", err)
}
@@ -317,7 +318,21 @@ func (s *Service) send(to string, msg []byte) error {
}
func renderTemplate(tmplStr string, data interface{}) (string, error) {
tmpl, err := template.New("email").Parse(tmplStr)
tmpl, err := texttpl.New("email").Parse(tmplStr)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// renderHTMLTemplate uses html/template for proper context-aware escaping,
// preventing XSS in HTML email bodies when user-supplied data is included.
func renderHTMLTemplate(tmplStr string, data interface{}) (string, error) {
tmpl, err := htmltpl.New("email").Parse(tmplStr)
if err != nil {
return "", err
}
+350
View File
@@ -0,0 +1,350 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package security
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ---------- CSRF Middleware ----------
func TestCSRFMiddleware_SetsTokenCookie(t *testing.T) {
handler := CSRFMiddleware(false)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
cookies := rec.Result().Cookies()
var csrfCookie *http.Cookie
for _, c := range cookies {
if c.Name == "_csrf" {
csrfCookie = c
}
}
if csrfCookie == nil {
t.Fatal("expected _csrf cookie to be set on GET request")
}
if len(csrfCookie.Value) != 64 {
t.Fatalf("expected 64-char hex token, got %d chars", len(csrfCookie.Value))
}
if csrfCookie.SameSite != http.SameSiteStrictMode {
t.Fatal("expected SameSite=Strict on CSRF cookie")
}
}
func TestCSRFMiddleware_BlocksPOSTWithoutToken(t *testing.T) {
handler := CSRFMiddleware(false)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/action", nil)
req.AddCookie(&http.Cookie{Name: "_csrf", Value: strings.Repeat("a", 64)})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 Forbidden for POST without matching token, got %d", rec.Code)
}
}
func TestCSRFMiddleware_AllowsPOSTWithValidToken(t *testing.T) {
token := strings.Repeat("ab", 32) // 64-char hex
handler := CSRFMiddleware(false)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
body := strings.NewReader("_csrf=" + token)
req := httptest.NewRequest(http.MethodPost, "/action", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: "_csrf", Value: token})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for POST with valid CSRF token, got %d", rec.Code)
}
}
func TestCSRFMiddleware_AllowsGETWithoutToken(t *testing.T) {
handler := CSRFMiddleware(false)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/page", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for GET without CSRF token, got %d", rec.Code)
}
}
func TestCSRFMiddleware_AcceptsHeaderToken(t *testing.T) {
token := strings.Repeat("cd", 32) // 64-char hex
handler := CSRFMiddleware(false)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/api/action", nil)
req.Header.Set("X-CSRF-Token", token)
req.AddCookie(&http.Cookie{Name: "_csrf", Value: token})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for POST with X-CSRF-Token header, got %d", rec.Code)
}
}
// ---------- Security Headers Middleware ----------
func TestHeadersMiddleware_SetsSecurityHeaders(t *testing.T) {
handler := HeadersMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
expected := map[string]string{
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
}
for header, want := range expected {
got := rec.Header().Get(header)
if got != want {
t.Errorf("header %s: got %q, want %q", header, got, want)
}
}
csp := rec.Header().Get("Content-Security-Policy")
if csp == "" {
t.Fatal("expected Content-Security-Policy header to be set")
}
if !strings.Contains(csp, "frame-ancestors 'none'") {
t.Error("CSP should contain frame-ancestors 'none'")
}
if !strings.Contains(csp, "form-action 'self'") {
t.Error("CSP should contain form-action 'self'")
}
}
func TestHeadersMiddleware_SetsCacheControlForNonStatic(t *testing.T) {
handler := HeadersMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
cc := rec.Header().Get("Cache-Control")
if !strings.Contains(cc, "no-store") {
t.Errorf("expected no-store in Cache-Control for non-static page, got %q", cc)
}
}
// ---------- Rate Limit Middleware ----------
func TestRateLimitMiddleware_BlocksAfterLimit(t *testing.T) {
handler := RateLimitMiddleware(3)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
for i := 0; i < 3; i++ {
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.0.2.1:12345"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("request %d: expected 200, got %d", i+1, rec.Code)
}
}
// 4th request should be blocked
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.0.2.1:12345"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429 Too Many Requests, got %d", rec.Code)
}
}
func TestRateLimitMiddleware_DisabledWhenZero(t *testing.T) {
handler := RateLimitMiddleware(0)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
for i := 0; i < 100; i++ {
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.0.2.1:12345"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("request %d: expected 200 (rate limiting disabled), got %d", i+1, rec.Code)
}
}
}
func TestRateLimitMiddleware_AllowsGETLogin(t *testing.T) {
handler := RateLimitMiddleware(1)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Exhaust POST limit
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.0.2.1:12345"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
// GET should still work
req = httptest.NewRequest(http.MethodGet, "/login", nil)
req.RemoteAddr = "192.0.2.1:12345"
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected GET /login to pass rate limit, got %d", rec.Code)
}
}
func TestRateLimitMiddleware_SeparatesIPs(t *testing.T) {
handler := RateLimitMiddleware(1)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Exhaust limit for IP 1
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.0.2.1:12345"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
// IP 2 should still be allowed
req = httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.0.2.2:12345"
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected different IP to be allowed, got %d", rec.Code)
}
}
// ---------- Size Limit Middleware ----------
func TestSizeLimitMiddleware_BlocksOversizedBody(t *testing.T) {
handler := SizeLimitMiddleware(10)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 1024)
_, err := r.Body.Read(buf)
if err != nil {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return
}
w.WriteHeader(http.StatusOK)
}))
body := strings.NewReader(strings.Repeat("x", 100))
req := httptest.NewRequest(http.MethodPost, "/upload", body)
req.Header.Set("Content-Type", "application/octet-stream")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code == http.StatusOK {
t.Fatal("expected request with body > 10 bytes to be rejected")
}
}
func TestSizeLimitMiddleware_DisabledWhenZero(t *testing.T) {
handler := SizeLimitMiddleware(0)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
body := strings.NewReader(strings.Repeat("x", 1000))
req := httptest.NewRequest(http.MethodPost, "/upload", body)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 with size limit disabled, got %d", rec.Code)
}
}
// ---------- Proxy / ClientIP ----------
func TestClientIP_RemoteAddrFallback(t *testing.T) {
Init("") // no trusted proxies
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:54321"
ip := ClientIP(req)
if ip != "10.0.0.1" {
t.Fatalf("expected 10.0.0.1, got %s", ip)
}
}
func TestClientIP_XForwardedFor_Legacy(t *testing.T) {
Init("") // legacy mode, trusts headers
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:54321"
req.Header.Set("X-Forwarded-For", "203.0.113.50, 10.0.0.1")
ip := ClientIP(req)
if ip != "203.0.113.50" {
t.Fatalf("expected leftmost XFF IP 203.0.113.50, got %s", ip)
}
}
func TestClientIP_TrustedProxies(t *testing.T) {
Init("10.0.0.0/8")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:54321"
req.Header.Set("X-Forwarded-For", "203.0.113.50, 10.0.0.2")
ip := ClientIP(req)
if ip != "203.0.113.50" {
t.Fatalf("expected rightmost untrusted IP 203.0.113.50, got %s", ip)
}
}
func TestClientIP_UntrustedPeerIgnoresHeaders(t *testing.T) {
Init("10.0.0.0/8")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "203.0.113.99:54321" // not in trusted range
req.Header.Set("X-Forwarded-For", "1.2.3.4")
ip := ClientIP(req)
if ip != "203.0.113.99" {
t.Fatalf("expected direct peer IP when not trusted, got %s", ip)
}
}
// ---------- isStaticAsset ----------
func TestIsStaticAsset(t *testing.T) {
tests := []struct {
path string
want bool
}{
{"/static/css/style.css", true},
{"/avatar/1.png", true},
{"/dashboard", false},
{"/login", false},
{"", false},
{"/short", false},
}
for _, tt := range tests {
got := isStaticAsset(tt.path)
if got != tt.want {
t.Errorf("isStaticAsset(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}