mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
596e675ec7
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
363 lines
14 KiB
Go
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)
|
|
}
|