ZDDC/zddc/internal/handler/middleware.go
ZDDC 460d5fdada feat(server): TLS hardening per NIST SP 800-52 Rev. 2 + HSTS
The TLS configuration was using Go stdlib defaults — secure for typical
commercial use, but federal evaluators need an explicit cipher
allowlist they can map to a FIPS-validated implementation. Pin the
cipher and curve lists to NIST SP 800-52 Rev. 2 § 3.3 conformant
values:

  Ciphers (TLS 1.2):
    TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
    TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
    TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
    TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
    TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
    TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305

  Curves: X25519, P-256, P-384

  MinVersion: TLS 1.2 (already set; 1.3 used when negotiated)

TLS 1.3 cipher selection is not operator-controllable in Go stdlib
(the runtime picks from a fixed AEAD-only set); all of those
already meet the federal bar so no change needed there.

Also adds HSTSMiddleware emitting `Strict-Transport-Security:
max-age=31536000; includeSubDomains` when zddc-server is itself
terminating TLS (ZDDC_TLS_CERT != none). Behind an upstream proxy
terminating TLS the proxy is responsible for HSTS, so the middleware
only wraps the chain when useTLS=true.

Test coverage:
  * TLSConfig(none) returns nil + useTLS=false
  * TLSConfig(selfsigned) sets the exact NIST allowlist
  * Negative test asserting weak ciphers (CBC, RC4, 3DES, RSA-key-
    exchange) are NOT in the list — guardrail against regressions

Federal-readiness gap analysis updated: this control is now partially
complete. OCSP stapling and CT-log inclusion remain on the list for
full DoD STIG conformance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:55:52 -05:00

165 lines
5.7 KiB
Go

package handler
import (
"context"
"net/http"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"log/slog"
)
type contextKey string
// EmailKey is the context key for the authenticated user's email.
const EmailKey contextKey = "email"
// DeciderKey is the context key for the request's policy decider.
// Set by ACLMiddleware so handlers deep in the stack can issue policy
// queries without taking the decider as an explicit parameter. Although
// the decider is an app-wide singleton (not per-request state), routing
// it through context keeps the call-site signatures stable across the
// "swap internal evaluator for external OPA" plumbing change.
const DeciderKey contextKey = "policy-decider"
// ACLMiddleware extracts the user email from the configured header and stores
// it (along with the policy decider) in the request context. It does NOT
// enforce ACL itself — each handler performs its own ACL check via
// policy.AllowFromChain.
func ACLMiddleware(cfg config.Config, decider policy.Decider, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
email := r.Header.Get(cfg.EmailHeader)
// DEBUG-level header dump for diagnosing proxy / SSO header
// passthrough. Off by default (LogLevel info); enable with
// ZDDC_LOG_LEVEL=debug. Logs the configured header name, the
// observed value at that name, and the full request header
// map so an operator can see exactly what reached the binary.
// Note: at debug level this also captures auth tokens, cookies,
// and anything else upstream proxies forward — only enable in
// trusted environments.
slog.Debug("request headers",
"configured", cfg.EmailHeader,
"observed", email,
"headers", r.Header)
ctx := context.WithValue(r.Context(), EmailKey, email)
if decider != nil {
ctx = context.WithValue(ctx, DeciderKey, decider)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// EmailFromContext extracts the user email from the request context.
func EmailFromContext(r *http.Request) string {
if v, ok := r.Context().Value(EmailKey).(string); ok {
return v
}
return ""
}
// DeciderFromContext extracts the policy decider from the request
// context. Returns the internal decider as a fallback if none was
// installed — this matches the "no OPA configured" semantics and
// keeps test setups that don't install ACLMiddleware functional.
func DeciderFromContext(r *http.Request) policy.Decider {
if v, ok := r.Context().Value(DeciderKey).(policy.Decider); ok {
return v
}
return &policy.InternalDecider{}
}
// responseWriter wraps http.ResponseWriter to capture status code and bytes written.
type responseWriter struct {
http.ResponseWriter
status int
bytes int
wrote bool
}
// WriteHeader records the status code and writes it to the underlying ResponseWriter.
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.wrote = true
rw.ResponseWriter.WriteHeader(code)
}
// Write records the bytes written and writes to the underlying ResponseWriter.
func (rw *responseWriter) Write(b []byte) (int, error) {
n, err := rw.ResponseWriter.Write(b)
rw.bytes += n
return n, err
}
// HSTSMiddleware sets the Strict-Transport-Security response header,
// instructing browsers to refuse plain-HTTP connections to this host
// for the next year (NIST SP 800-52 Rev. 2 § 4.4.6, also DoD STIG
// expectation; OWASP recommendation max-age >= 1 year). Use ONLY when
// zddc-server is itself terminating TLS — when an upstream proxy
// terminates, that proxy should set HSTS instead.
//
// includeSubDomains is set; preload is not (preload requires
// pre-submitting the domain to the browser-vendor list — out of
// scope for this server, and operators who want it can override
// upstream).
//
// max-age = 31536000 = 365 days.
func HSTSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
next.ServeHTTP(w, r)
})
}
// AccessLogMiddleware logs a structured line per HTTP request after the
// response is written.
//
// Always emits to slog.Default() (stderr) so server-lifecycle logs and
// access logs share an output stream by default.
//
// If `auditLogger` is non-nil, the same structured fields are also written
// to it. The intended caller wires up auditLogger with a JSON handler
// pointing at a rotating file (see cmd/zddc-server's setupAccessAuditLog),
// so an operator gets a persisted audit trail on disk in addition to the
// stderr stream — useful when stderr is not journald-captured (e.g.
// container logging where the orchestrator drops stderr after restarts).
func AccessLogMiddleware(auditLogger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture request start time
start := time.Now()
// Wrap the ResponseWriter
wrapped := &responseWriter{ResponseWriter: w, status: 200}
// Serve the request
next.ServeHTTP(wrapped, r)
// Calculate duration
durationMs := int(time.Since(start).Milliseconds())
// Get email from context
email := EmailFromContext(r)
if email == "" {
email = "anonymous"
}
args := []any{
"ts", start.Format(time.RFC3339),
"email", email,
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.status,
"bytes", wrapped.bytes,
"duration_ms", durationMs,
}
// Stderr stream (existing behavior).
slog.Info("access", args...)
// Audit file (when configured). Same fields, separate handler so
// the file can be JSON-formatted regardless of stderr's handler.
if auditLogger != nil {
auditLogger.Info("access", args...)
}
})
}