Release: v0.1.0-alpha
Release Docker Image / Build & Push Docker Image (release) Failing after 1m30s

This commit is contained in:
2026-04-05 16:56:16 +02:00
parent 23ff731579
commit fd13e67aef
89 changed files with 18786 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package security
import (
"crypto/rand"
"crypto/subtle"
"fmt"
"net/http"
)
// CSRFMiddleware returns middleware that implements the Double-Submit Cookie
// pattern for CSRF protection.
//
// On every request a _csrf cookie is ensured (generated if absent). On
// state-changing methods (POST, PUT, DELETE, PATCH) the middleware validates
// that the request carries a matching token either as:
// - a form field named "_csrf", or
// - an X-CSRF-Token request header (for AJAX / fetch calls).
//
// The cookie is NOT HttpOnly so that client-side JavaScript can read the
// value and inject it into forms / fetch headers automatically.
func CSRFMiddleware(secureCookies bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// --- Ensure CSRF token exists ---
token := ""
if c, err := r.Cookie("_csrf"); err == nil && len(c.Value) == 64 {
token = c.Value
}
if token == "" {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
token = fmt.Sprintf("%x", b)
http.SetCookie(w, &http.Cookie{
Name: "_csrf",
Value: token,
Path: "/",
HttpOnly: false, // JS must be able to read it
Secure: secureCookies,
SameSite: http.SameSiteStrictMode,
MaxAge: 86400, // 24 hours
})
}
// --- Validate on state-changing methods ---
if r.Method == http.MethodPost || r.Method == http.MethodPut ||
r.Method == http.MethodDelete || r.Method == http.MethodPatch {
// Accept the token from either the form body or a request header
submitted := r.FormValue("_csrf")
if submitted == "" {
submitted = r.Header.Get("X-CSRF-Token")
}
if subtle.ConstantTimeCompare([]byte(submitted), []byte(token)) != 1 {
http.Error(w, "Forbidden invalid or missing CSRF token", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
}
+65
View File
@@ -0,0 +1,65 @@
// 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"
)
// HeadersMiddleware returns middleware that sets security-relevant HTTP
// response headers on every response.
//
// These headers protect against clickjacking, MIME-sniffing, information
// leakage and other common web attacks. They are set regardless of
// whether TLS is in use.
func HeadersMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// Prevent the page from being embedded in an iframe (clickjacking)
h.Set("X-Frame-Options", "DENY")
// Stop browsers from MIME-sniffing the content type
h.Set("X-Content-Type-Options", "nosniff")
// Control what information is sent in the Referer header
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Restrict browser features that are not needed
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
// Content Security Policy fairly strict but allows inline
// styles/scripts that Tabler and the app currently use.
h.Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; "+
"font-src 'self' data:; "+
"connect-src 'self'; "+
"frame-ancestors 'none'; "+
"form-action 'self'; "+
"base-uri 'self'")
// Opt out of Google FLoC / Topics
h.Set("X-Permitted-Cross-Domain-Policies", "none")
// Cache control for authenticated pages prevent caching of
// sensitive data. Static assets set their own cache headers.
if r.URL.Path != "" && !isStaticAsset(r.URL.Path) {
h.Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
h.Set("Pragma", "no-cache")
}
next.ServeHTTP(w, r)
})
}
}
// isStaticAsset returns true for paths that serve static files.
func isStaticAsset(path string) bool {
return len(path) > 8 && (path[:8] == "/static/" || path[:8] == "/avatar/")
}
+112
View File
@@ -0,0 +1,112 @@
// 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"
"net/http"
"strings"
)
// trustedNets holds the parsed trusted proxy CIDR ranges.
// Set once at startup via Init().
var trustedNets []*net.IPNet
// Init parses the trusted proxy configuration and prepares the package
// for use. Must be called once at startup before any middleware runs.
//
// trustedProxies is a comma-separated list of CIDRs or IPs, e.g.
// "10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16" or "10.0.1.92".
// An empty string means no trusted proxies are configured (legacy mode:
// proxy headers are trusted unconditionally for backward compatibility).
func Init(trustedProxies string) {
trustedNets = nil
if trustedProxies == "" {
return
}
for _, entry := range strings.Split(trustedProxies, ",") {
cidr := strings.TrimSpace(entry)
if cidr == "" {
continue
}
// Plain IP → convert to single-host CIDR
if !strings.Contains(cidr, "/") {
if strings.Contains(cidr, ":") {
cidr += "/128"
} else {
cidr += "/32"
}
}
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
continue // skip invalid entries silently
}
trustedNets = append(trustedNets, ipNet)
}
}
// ClientIP extracts the real client IP address from the request.
//
// When trusted proxies are configured, X-Forwarded-For is walked from
// right to left and the first non-trusted IP is returned (secure approach).
// When no trusted proxies are configured, the legacy behavior is used
// (leftmost X-Forwarded-For entry, i.e. the value the first proxy saw).
func ClientIP(r *http.Request) string {
remoteIP := extractRemoteIP(r.RemoteAddr)
if len(trustedNets) > 0 {
// Strict mode: only honour proxy headers when the direct peer is trusted
if !isTrustedIP(remoteIP) {
return remoteIP
}
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
parts := strings.Split(fwd, ",")
// Walk right-to-left: rightmost untrusted IP is the real client
for i := len(parts) - 1; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if ip != "" && !isTrustedIP(ip) {
return ip
}
}
}
if real := r.Header.Get("X-Real-Ip"); real != "" {
return real
}
return remoteIP
}
// Legacy mode (no trusted proxies configured): trust headers as before
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
parts := strings.SplitN(fwd, ",", 2)
return strings.TrimSpace(parts[0])
}
if real := r.Header.Get("X-Real-Ip"); real != "" {
return real
}
return remoteIP
}
// extractRemoteIP strips the port from r.RemoteAddr.
func extractRemoteIP(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err == nil {
return host
}
return addr
}
// isTrustedIP checks if an IP is within any of the configured trusted networks.
func isTrustedIP(ip string) bool {
parsed := net.ParseIP(ip)
if parsed == nil {
return false
}
for _, n := range trustedNets {
if n.Contains(parsed) {
return true
}
}
return false
}
+96
View File
@@ -0,0 +1,96 @@
// 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"
"strings"
"sync"
"time"
)
// rateLimiter implements a fixed-window IP-based rate limiter.
type rateLimiter struct {
mu sync.Mutex
clients map[string]*window
limit int
period time.Duration
}
type window struct {
count int
resetAt time.Time
}
func newRateLimiter(limit int, period time.Duration) *rateLimiter {
rl := &rateLimiter{
clients: make(map[string]*window),
limit: limit,
period: period,
}
// Background goroutine to evict expired entries (prevent memory leak)
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
rl.mu.Lock()
for ip, w := range rl.clients {
if now.After(w.resetAt) {
delete(rl.clients, ip)
}
}
rl.mu.Unlock()
}
}()
return rl
}
func (rl *rateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
w, ok := rl.clients[ip]
if !ok || now.After(w.resetAt) {
rl.clients[ip] = &window{count: 1, resetAt: now.Add(rl.period)}
return true
}
w.count++
return w.count <= rl.limit
}
// RateLimitMiddleware returns middleware that rate-limits requests to the
// login endpoint by client IP address.
//
// loginLimit is the maximum number of login attempts per IP per minute.
// A value of 0 disables rate limiting entirely.
func RateLimitMiddleware(loginLimit int) func(http.Handler) http.Handler {
if loginLimit <= 0 {
// Disabled pass through
return func(next http.Handler) http.Handler { return next }
}
loginRL := newRateLimiter(loginLimit, 1*time.Minute)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only rate-limit POST to login endpoints
if r.Method == http.MethodPost && isLoginPath(r.URL.Path) {
ip := ClientIP(r)
if !loginRL.allow(ip) {
http.Error(w, "Too Many Requests please try again later", http.StatusTooManyRequests)
return
}
}
next.ServeHTTP(w, r)
})
}
}
// isLoginPath returns true for login-related POST paths.
func isLoginPath(path string) bool {
return path == "/login" || strings.HasPrefix(path, "/login/")
}
+30
View File
@@ -0,0 +1,30 @@
// 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"
)
// SizeLimitMiddleware returns middleware that limits the size of incoming
// request bodies to maxBytes. This prevents denial-of-service attacks via
// excessively large uploads. A value of 0 disables the limit.
//
// The limit is enforced with http.MaxBytesReader which causes the server
// to return 413 Request Entity Too Large if the body exceeds the limit.
func SizeLimitMiddleware(maxBytes int64) func(http.Handler) http.Handler {
if maxBytes <= 0 {
return func(next http.Handler) http.Handler { return next }
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil && r.ContentLength != 0 {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
}
next.ServeHTTP(w, r)
})
}
}