Release: v0.1.0-alpha
Release Docker Image / Build & Push Docker Image (release) Failing after 1m30s
Release Docker Image / Build & Push Docker Image (release) Failing after 1m30s
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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/")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/")
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user