mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:31:39 +00:00
5c8d37e0f0
Closes Audit-2026-04-25 H-006 (High), H-007 (High), M-011 (Medium),
L-006 (Low — verified-already-closed via C-1 master closure in v2.0.54).
Hardens the orchestrator-facing surface — k8s probes, agent enrollment,
shutdown audit drain, scheduler config plumbing.
What changed
- internal/api/handler/health.go — split contract:
* /health stays shallow 200 (k8s liveness — process alive)
* /ready accepts *sql.DB; runs db.PingContext(2s); 503 on failure
* Nil DB path returns 200 + db=not_configured (test fixtures)
- internal/api/handler/agent_bootstrap.go (NEW) — verifyBootstrapToken:
* empty expected = warn-mode pass-through
* non-empty = `Authorization: Bearer <token>` required
* crypto/subtle.ConstantTimeCompare; length-mismatch path runs dummy
compare to keep timing uniform
* ErrBootstrapTokenInvalid sentinel
- internal/api/handler/agents.go — RegisterAgent calls verifyBootstrapToken
BEFORE body parse so unauth probes don't even allocate a JSON decoder
- internal/config/config.go — two new env vars:
* CERTCTL_AGENT_BOOTSTRAP_TOKEN (Auth.AgentBootstrapToken)
* CERTCTL_AUDIT_FLUSH_TIMEOUT_SECONDS (Server.AuditFlushTimeoutSeconds)
- cmd/server/main.go — 3 changes:
* pass *sql.DB into NewHealthHandler (H-006)
* pass cfg.Auth.AgentBootstrapToken into NewAgentHandler (H-007)
* configurable shutdown audit-flush timeout (M-011)
* one-shot startup WARN when bootstrap token unset (deprecation)
- new tests: agent_bootstrap_test.go (full deny/accept/warn-mode coverage,
constant-time compare path, length-mismatch); health_test.go extended
with /ready DB-probe failure (503), nil-DB pass-through, /health-shallow
L-006 verified
- cmd/server/main.go:557 already calls
sched.SetShortLivedExpiryCheckInterval(cfg.Scheduler.ShortLivedExpiryCheckInterval)
per the C-1 master closure in v2.0.54. Bundle 5 confirms; no code change.
Threat model: TB-1 (operator/orchestrator), TB-2 (Agent↔Server).
- CWE-754 (Improper Check for Unusual or Exceptional Conditions) for H-006
- CWE-306 + CWE-288 (Missing Authentication for Critical Function) for H-007
Verification
- go vet ./... → clean
- go build ./... → clean
- go test -short -count=1 ./... → all packages pass
- targeted Bundle-5 regressions → all pass
- npx tsc --noEmit (web) → clean
- npx vitest run (web) → in-flight (sandbox 45s
ceiling exceeded; no failure markers in dot stream; no frontend
changes in this bundle so no regression risk)
- python3 yaml.safe_load(api/openapi.yaml) → 89 paths
Backward compatibility
- Bootstrap token defaults to empty (warn-mode) — existing demo
deployments unaffected. Server logs deprecation WARN; v2.2.0 will
require it.
- Audit flush timeout default 30s preserves prior behaviour.
- Helm chart already routes readiness probe to /ready (no chart change
needed); now /ready actually probes the DB.
Bundle 5 of the 2026-04-25 comprehensive audit.
103 lines
3.8 KiB
Go
103 lines
3.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Bundle-5 / Audit H-007 / CWE-306 + CWE-288:
|
|
//
|
|
// Pre-Bundle-5, POST /api/v1/agents accepted any request and registered
|
|
// the supplied agent payload — any host with network reach to the server
|
|
// could enroll a fake agent and start polling for work without a shared
|
|
// secret. This file implements the bootstrap-token defence.
|
|
//
|
|
// Contract:
|
|
//
|
|
// - When CERTCTL_AGENT_BOOTSTRAP_TOKEN is empty (the v2.0.x default), the
|
|
// handler accepts registrations as before. main.go logs a one-shot WARN
|
|
// at startup announcing the v2.2.0 deprecation: bootstrap token will
|
|
// become required in v2.2.0 and unset will fail-loud.
|
|
//
|
|
// - When the token is non-empty, every registration request must carry
|
|
// `Authorization: Bearer <token>` whose value matches the configured
|
|
// token byte-for-byte. The compare uses crypto/subtle.ConstantTimeCompare
|
|
// to defeat timing oracles.
|
|
//
|
|
// - Mismatch / missing / malformed → 401 with
|
|
// {"error":"invalid_or_missing_bootstrap_token"} JSON body. The handler
|
|
// does NOT echo what the client sent (defence-in-depth against credential
|
|
// shape leakage to a token spray probe).
|
|
//
|
|
// Generation guidance (lives in docs/quickstart.md): `openssl rand -hex 32`
|
|
// for 256-bit entropy. Operators rotate by setting the new value, restarting
|
|
// the server, then re-issuing the new token to whoever drives agent
|
|
// enrollment.
|
|
|
|
// ErrBootstrapTokenInvalid is the sentinel returned by verifyBootstrapToken
|
|
// on any non-accept path (missing header, malformed Bearer token, mismatch).
|
|
// Handlers translate this into HTTP 401 with a fixed error string.
|
|
var ErrBootstrapTokenInvalid = errors.New("invalid or missing agent bootstrap token")
|
|
|
|
// bootstrapWarnOnce gates the one-shot deprecation WARN to a single emission
|
|
// per process so a busy registration endpoint doesn't flood the log.
|
|
var bootstrapWarnOnce sync.Once
|
|
|
|
// verifyBootstrapToken returns nil when the request should proceed and
|
|
// ErrBootstrapTokenInvalid when it should be rejected.
|
|
//
|
|
// Parameters:
|
|
//
|
|
// r — incoming HTTP request
|
|
// expected — the configured token; empty = warn-mode pass-through
|
|
//
|
|
// Token extraction order:
|
|
// 1. `Authorization: Bearer <token>` (canonical)
|
|
// 2. (Future) X-Certctl-Bootstrap-Token: <token> — reserved, not yet read
|
|
//
|
|
// All comparisons use crypto/subtle.ConstantTimeCompare. Even when the
|
|
// presented token is the wrong length, we still copy bytes through the
|
|
// constant-time path so the timing signature is uniform.
|
|
func verifyBootstrapToken(r *http.Request, expected string) error {
|
|
if expected == "" {
|
|
// Warn-mode pass-through. The startup WARN in main.go is the
|
|
// operator-visible signal; this fast path stays silent so a busy
|
|
// endpoint doesn't add log noise per request.
|
|
return nil
|
|
}
|
|
|
|
authHeader := r.Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
return ErrBootstrapTokenInvalid
|
|
}
|
|
|
|
const bearerPrefix = "Bearer "
|
|
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
|
return ErrBootstrapTokenInvalid
|
|
}
|
|
|
|
presented := strings.TrimPrefix(authHeader, bearerPrefix)
|
|
if presented == "" {
|
|
return ErrBootstrapTokenInvalid
|
|
}
|
|
|
|
// Constant-time compare. We pad the shorter side so the comparison
|
|
// runs in a length-independent code path; subtle.ConstantTimeCompare
|
|
// requires equal-length slices.
|
|
expectedBytes := []byte(expected)
|
|
presentedBytes := []byte(presented)
|
|
if len(expectedBytes) != len(presentedBytes) {
|
|
// Run a dummy compare to keep the timing similar regardless of
|
|
// length-vs-content failure mode.
|
|
_ = subtle.ConstantTimeCompare(expectedBytes, expectedBytes)
|
|
return ErrBootstrapTokenInvalid
|
|
}
|
|
if subtle.ConstantTimeCompare(expectedBytes, presentedBytes) != 1 {
|
|
return ErrBootstrapTokenInvalid
|
|
}
|
|
return nil
|
|
}
|