Files
certctl/internal/api/handler/auth_breakglass.go
T
shankar0123 36840ddd01 fix(security): close BUNDLE 5 — auth, OIDC, MCP, API + browser security edges
Bundle 5 closure (2026-05-13 acquisition diligence audit). 13-finding
security audit pass across the auth / OIDC / MCP / API / browser-
security surface. Five real closures shipped in code, two false-as-
stated findings annotated with the existing implementation, three
operator-decision items documented for v3 follow-up, three doc-only
fixes (auth architecture narrative aligned with shipped OIDC).

Source findings closed (code):
  S1     break-glass /auth/breakglass/login lacked the documented
         5/min per-source-IP rate limit; handler now owns its own
         SlidingWindowLimiter wired at startup. Doc claim turns true.
  R6     OIDC test_discovery JWKS probe ran on http.DefaultClient;
         now uses an http.Client whose transport wraps
         validation.SafeHTTPDialContext. JWKS URI can no longer
         pivot into reserved-address ranges via DNS rebinding.
  R7     Slack + Teams notifiers built http.Client without the SSRF
         dial-time guard. Both New() constructors now install
         validation.SafeHTTPDialContext; webhook URLs (operator-
         configured via dynamic-config GUI) cannot dial 169.254.x or
         in-cluster reserved ranges. Test seam: newForTest bypasses
         the guard for httptest's 127.0.0.1 binds, mirroring the
         existing internal/connector/notifier/webhook pattern.
  RT-L2  CERTCTL_ACME_INSECURE=true now emits a prominent
         logger.Warn at server boot. Pre-Bundle-5 the knob silently
         disabled ACME directory TLS verification.

Source findings closed (doc):
  finding 1 + HIGH-5  Architecture doc claimed no in-process JWT/
         OIDC/mTLS/SAML and pointed everyone at the
         authenticating-gateway pattern. Auth Bundle 2
         (commit dea5053) shipped native OIDC + sessions +
         break-glass. New §"In-process authentication surface"
         table (api-key / oidc / none) supersedes the old framing;
         "Authenticating-gateway pattern (SAML, mTLS-as-auth,
         LDAP)" section retained for protocols certctl still
         doesn't ship natively.

Source findings verified false (existing implementation):
  S4     OIDC email-domain allowlist — `email_domain_test.go`
         already pins the strict-equality semantics (subdomain not
         auto-accepted, multi-entry no-match path, empty allowlist
         accepts all by-design per RFC 9700 §4.1.1).
  SEC-L1 CSP / HSTS / referrer-policy headers — already shipped at
         internal/api/middleware/securityheaders.go and wired at
         cmd/server/main.go L2003+L2027+L2115.

Operator-decision / deferred (tracked in bundle-5 closure doc):
  S3     CERTCTL_API_KEYS_NAMED parsing is wired, end-to-end
         validation is partial. Operator decides: complete the
         named-key middleware path or deprecate the syntax.
  S5     Audit-middleware best-effort for read paths;
         security-critical writes use WithinTx. Operator decides
         per-path escalation.
  S8     MCP threat model — the binary is a thin protocol bridge,
         no privileges of its own; every tool call carries
         CERTCTL_API_KEY and is auth'd + RBAC-gated server-side.
         Optional CERTCTL_MCP_READ_ONLY gate tracked as v3.
  SEC-H1 2026-05-10 audit CRIT-1/2/4 already closed on master;
         CRIT-3/5 status against the spec folder is operator-
         workstation-validation-only. Documented for follow-up.
  SEC-L2 WebAuthn / FIDO2 / step-up — already documented in
         docs/operator/auth-threat-model.md "Threats Bundle 2 does
         NOT close". v3 work item per CLAUDE.md decision 12.

Full per-finding rationale + receipts at
docs/operator/security-bundle-5-audit-closure.md.

Verification:
  gofmt -l                                                # clean
  go vet ./internal/connector/notifier/slack
    ./internal/connector/notifier/teams ./internal/auth/oidc
    ./internal/api/handler ./cmd/server                  # clean
  go build ./cmd/server [...]                            # clean
  go test -short -count=1 ./internal/connector/notifier/slack
    ./internal/connector/notifier/teams ./internal/api/handler
    ./internal/auth/oidc ./internal/config                # PASS
                                                          # (slack 0.028s + teams
                                                          # 0.023s + handler 11.0s;
                                                          # newForTest seam keeps
                                                          # httptest tests green)

Audit-Closes: BUNDLE-5 S1 R6 R7 RT-L2 finding-1 HIGH-5
Audit-Verifies-False: S4 SEC-L1
Audit-Defers: S3 S5 S8 SEC-H1 SEC-L2
2026-05-13 01:18:45 +00:00

363 lines
14 KiB
Go

// Package handler — Auth Bundle 2 Phase 7.5 / break-glass admin HTTP surface.
//
// 4 endpoints across two access levels:
//
// 1. Public (auth-bypass; the whole point is to log in WITHOUT
// existing creds):
// POST /auth/breakglass/login
// Rate-limited at 5/minute per source IP via the existing
// rate limiter middleware. When CERTCTL_BREAKGLASS_ENABLED=false,
// returns 404 (NOT 403) so the surface is invisible to scanners.
//
// 2. RBAC-gated (auth.breakglass.admin):
// POST /api/v1/auth/breakglass/credentials
// POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock
// DELETE /api/v1/auth/breakglass/credentials/{actor_id}
//
// The handler delegates to internal/auth/breakglass.Service for the
// load-bearing logic (Argon2id hashing, lockout state machine,
// constant-time-compare, identical-shape errors). This file is purely
// HTTP shape — request-binding, status-code mapping, audit attribution
// for the caller-actor-id wire-up.
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/certctl-io/certctl/internal/auth/breakglass"
bgdomain "github.com/certctl-io/certctl/internal/auth/breakglass/domain"
sessiondomain "github.com/certctl-io/certctl/internal/auth/session/domain"
"github.com/certctl-io/certctl/internal/ratelimit"
)
// =============================================================================
// AuthBreakglassHandler.
// =============================================================================
// BreakglassService is the projection of *breakglass.Service the
// handler consumes. Defining the projection here keeps the handler
// stub-friendly + decoupled from the wider service surface.
type BreakglassService interface {
Enabled() bool
SetPassword(ctx context.Context, callerActorID, targetActorID, plaintext string) (*breakglass.SetPasswordResult, error)
Authenticate(ctx context.Context, actorID, plaintext, ip, userAgent string) (*breakglass.AuthenticateResult, error)
Unlock(ctx context.Context, callerActorID, targetActorID string) error
RemoveCredential(ctx context.Context, callerActorID, targetActorID string) error
List(ctx context.Context) ([]*bgdomain.BreakglassCredential, error)
}
// AuthBreakglassHandler ships the Phase 7.5 surface.
//
// Bundle 5 closure (S1): the docstring at the top of this file claimed
// the login endpoint was "Rate-limited at 5/minute per source IP via
// the existing rate limiter middleware" but no per-route limiter was
// wired — `/auth/breakglass/login` is registered via `r.mux.Handle`
// in router.go::AuthExemptRouterRoutes and bypasses the global RPS
// middleware that wraps `r.Register`-mounted routes. The login handler
// now owns its own SlidingWindowLimiter (5 attempts / minute / source
// IP, 50 000 key cap) so the documented behavior actually ships.
//
// Wired at startup via SetLoginRateLimiter (called from cmd/server/main.go
// alongside the other per-handler rate limiters that close audit
// findings H-9 / H-12 / Bundle 3 D7 / etc.). Defense-in-depth: even
// when the limiter is nil (legacy / test), the service-layer Argon2id
// lockout state machine still protects against brute force — but a
// nil limiter is a misconfiguration the integration test catches.
type AuthBreakglassHandler struct {
svc BreakglassService
cookieAttrs SessionCookieAttrs
// loginLimiter rate-limits POST /auth/breakglass/login by source IP.
// nil-safe: when unset, the handler skips the limiter check and
// relies on the service-layer Argon2id lockout. Production deploys
// MUST set this via SetLoginRateLimiter.
loginLimiter *ratelimit.SlidingWindowLimiter
}
// NewAuthBreakglassHandler constructs the handler.
func NewAuthBreakglassHandler(svc BreakglassService, cookieAttrs SessionCookieAttrs) *AuthBreakglassHandler {
return &AuthBreakglassHandler{svc: svc, cookieAttrs: cookieAttrs}
}
// SetLoginRateLimiter wires the per-source-IP rate limiter the Login
// handler enforces. Bundle 5 closure (S1) — see the AuthBreakglassHandler
// type docstring for the full rationale.
func (h *AuthBreakglassHandler) SetLoginRateLimiter(l *ratelimit.SlidingWindowLimiter) {
h.loginLimiter = l
}
// =============================================================================
// 1. Public login endpoint.
// =============================================================================
type breakglassLoginRequest struct {
ActorID string `json:"actor_id"`
Password string `json:"password"`
}
// Login handles POST /auth/breakglass/login.
//
// Auth-bypass — the whole point is to log in WITHOUT existing creds.
// When Service.Enabled() == false, returns 404 (NOT 403) so the surface
// is invisible to scanners. On success, sets the post-login session
// cookie + CSRF cookie + 204 No Content. On any failure (wrong password,
// locked account, no credential, unknown actor): uniform 401 + identical
// timing.
func (h *AuthBreakglassHandler) Login(w http.ResponseWriter, r *http.Request) {
if h.svc == nil || !h.svc.Enabled() {
// Surface invisibility — 404 (NOT 403) per Phase 7.5 spec.
http.NotFound(w, r)
return
}
var req breakglassLoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Even invalid JSON returns 401 (identical to wrong-password) —
// no scanner-friendly 400 that distinguishes "wrong shape" vs
// "wrong password".
Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
if strings.TrimSpace(req.ActorID) == "" || req.Password == "" {
Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
ip := clientIPFromRequest(r)
// Bundle 5 closure (S1): per-source-IP rate limit. 5 attempts /
// minute / IP (default; configurable via the constructor at
// cmd/server/main.go). Returns 429 with no body so the response
// shape matches the rest of the auth surface (scanner-unfriendly).
// Audited by the service layer on the next attempt — we don't
// audit the rate-limit hit itself here because that would let an
// attacker flood the audit table with rate-limit rows from a
// single IP.
if h.loginLimiter != nil {
if err := h.loginLimiter.Allow(ip, time.Now()); err != nil {
Error(w, http.StatusTooManyRequests, "too many requests")
return
}
}
res, err := h.svc.Authenticate(r.Context(), req.ActorID, req.Password, ip, r.UserAgent())
if err != nil {
// All authenticate errors map to the SAME 401 + same body.
// The service has already audited the specific failure category.
Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
// Set the post-login session cookie + CSRF cookie. Same attributes
// as the OIDC callback handler in auth_session_oidc.go; we
// duplicate the 8-line cookie-set block here so the break-glass
// handler doesn't import the OIDC handler package.
now := time.Now().UTC()
expires := now.Add(8 * time.Hour) // matches default SessionConfig.AbsoluteTimeout
http.SetCookie(w, &http.Cookie{
Name: sessiondomain.PostLoginCookieName,
Value: res.CookieValue,
Path: "/",
Expires: expires,
Secure: h.cookieAttrs.Secure,
HttpOnly: true,
SameSite: h.cookieAttrs.SameSite,
})
http.SetCookie(w, &http.Cookie{
Name: sessiondomain.CSRFCookieName,
Value: res.CSRFToken,
Path: "/",
Expires: expires,
Secure: h.cookieAttrs.Secure,
HttpOnly: false, // intentional — GUI must read it
SameSite: h.cookieAttrs.SameSite,
})
w.WriteHeader(http.StatusNoContent)
}
// =============================================================================
// 2. Admin endpoints.
// =============================================================================
type breakglassSetPasswordRequest struct {
ActorID string `json:"actor_id"`
Password string `json:"password"`
}
// SetPassword handles POST /api/v1/auth/breakglass/credentials.
// Permission: auth.breakglass.admin (gated at the router via rbacGate).
//
// When Service.Enabled() == false, returns 404 — admin endpoints share
// the surface-invisibility property with the login endpoint so an
// attacker probing for break-glass via the admin surface gets the same
// signal as probing the login endpoint.
func (h *AuthBreakglassHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
if h.svc == nil || !h.svc.Enabled() {
http.NotFound(w, r)
return
}
caller, err := callerFromRequest(r)
if err != nil {
writeAuthError(w, err)
return
}
var req breakglassSetPasswordRequest
if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil {
Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
res, serr := h.svc.SetPassword(r.Context(), caller.ActorID, req.ActorID, req.Password)
if serr != nil {
switch {
case errors.Is(serr, breakglass.ErrWeakPassword):
Error(w, http.StatusBadRequest, "password fails strength requirements (min 12 bytes, max 256 bytes)")
case errors.Is(serr, breakglass.ErrUnauthenticated):
Error(w, http.StatusUnauthorized, "Authentication required")
case errors.Is(serr, breakglass.ErrDisabled):
http.NotFound(w, r)
default:
Error(w, http.StatusInternalServerError, "could not set password")
}
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"actor_id": res.ActorID,
"created_at": res.CreatedAt.Format(time.RFC3339),
})
}
// Unlock handles POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock.
// Permission: auth.breakglass.admin.
func (h *AuthBreakglassHandler) Unlock(w http.ResponseWriter, r *http.Request) {
if h.svc == nil || !h.svc.Enabled() {
http.NotFound(w, r)
return
}
caller, err := callerFromRequest(r)
if err != nil {
writeAuthError(w, err)
return
}
targetID := r.PathValue("actor_id")
if targetID == "" {
Error(w, http.StatusBadRequest, "missing actor_id path param")
return
}
if uerr := h.svc.Unlock(r.Context(), caller.ActorID, targetID); uerr != nil {
switch {
case errors.Is(uerr, breakglass.ErrDisabled):
http.NotFound(w, r)
case errors.Is(uerr, breakglass.ErrUnauthenticated):
Error(w, http.StatusUnauthorized, "Authentication required")
default:
// repository.ErrBreakglassNotFound surfaces as a wrapped
// error here; we map to 404 via string match to avoid
// importing repository.
if strings.Contains(uerr.Error(), "not found") {
Error(w, http.StatusNotFound, "credential not found")
} else {
Error(w, http.StatusInternalServerError, "could not unlock credential")
}
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// Remove handles DELETE /api/v1/auth/breakglass/credentials/{actor_id}.
// Permission: auth.breakglass.admin.
func (h *AuthBreakglassHandler) Remove(w http.ResponseWriter, r *http.Request) {
if h.svc == nil || !h.svc.Enabled() {
http.NotFound(w, r)
return
}
caller, err := callerFromRequest(r)
if err != nil {
writeAuthError(w, err)
return
}
targetID := r.PathValue("actor_id")
if targetID == "" {
Error(w, http.StatusBadRequest, "missing actor_id path param")
return
}
if rerr := h.svc.RemoveCredential(r.Context(), caller.ActorID, targetID); rerr != nil {
switch {
case errors.Is(rerr, breakglass.ErrDisabled):
http.NotFound(w, r)
case errors.Is(rerr, breakglass.ErrUnauthenticated):
Error(w, http.StatusUnauthorized, "Authentication required")
default:
if strings.Contains(rerr.Error(), "not found") {
Error(w, http.StatusNotFound, "credential not found")
} else {
Error(w, http.StatusInternalServerError, "could not remove credential")
}
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// breakglassCredentialResponse is the wire shape returned by ListCredentials.
// Intentionally omits PasswordHash — the admin GUI only needs metadata to
// render the credentialed-actor table.
type breakglassCredentialResponse struct {
ActorID string `json:"actor_id"`
CreatedAt string `json:"created_at"`
LastPasswordChangeAt string `json:"last_password_change_at"`
FailureCount int `json:"failure_count"`
LockedUntil *string `json:"locked_until,omitempty"`
LastFailureAt *string `json:"last_failure_at,omitempty"`
}
type listBreakglassCredentialsResponse struct {
Credentials []breakglassCredentialResponse `json:"credentials"`
}
// ListCredentials handles GET /api/v1/auth/breakglass/credentials.
// Permission: auth.breakglass.admin.
//
// Audit 2026-05-10 CRIT-4 closure — backs the admin GUI Break-glass
// page. Returns 404 when CERTCTL_BREAKGLASS_ENABLED=false (surface
// invisibility, consistent with the other break-glass admin endpoints).
// The password hash is NEVER serialized to the wire.
func (h *AuthBreakglassHandler) ListCredentials(w http.ResponseWriter, r *http.Request) {
if h.svc == nil || !h.svc.Enabled() {
http.NotFound(w, r)
return
}
creds, err := h.svc.List(r.Context())
if err != nil {
if errors.Is(err, breakglass.ErrDisabled) {
http.NotFound(w, r)
return
}
Error(w, http.StatusInternalServerError, "could not list break-glass credentials")
return
}
resp := listBreakglassCredentialsResponse{Credentials: make([]breakglassCredentialResponse, 0, len(creds))}
for _, c := range creds {
row := breakglassCredentialResponse{
ActorID: c.ActorID,
CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339),
LastPasswordChangeAt: c.LastPasswordChangeAt.UTC().Format(time.RFC3339),
FailureCount: c.FailureCount,
}
if c.LockedUntil != nil {
s := c.LockedUntil.UTC().Format(time.RFC3339)
row.LockedUntil = &s
}
if c.LastFailureAt != nil {
s := c.LastFailureAt.UTC().Format(time.RFC3339)
row.LastFailureAt = &s
}
resp.Credentials = append(resp.Credentials, row)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}