mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:51:32 +00:00
fix: security audit remediation (AUDIT-001, 003, 004, 005, 006, 018)
- AUDIT-001: Validate OpenSSL revoke inputs (hex-only serials, RFC 5280 reasons) - AUDIT-003: Enforce /20 CIDR size cap at API level (create + update) - AUDIT-004: Support comma-separated CERTCTL_AUTH_SECRET for zero-downtime key rotation - AUDIT-005: Add ReadHeaderTimeout (5s) to prevent Slowloris - AUDIT-006: Document audit trail query parameter exclusion rationale - AUDIT-018: Add immediate-run-on-start to short-lived expiry scheduler loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+6
-5
@@ -445,11 +445,12 @@ func main() {
|
||||
// Server configuration
|
||||
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: finalHandler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
Addr: addr,
|
||||
Handler: finalHandler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// Start HTTP server in background
|
||||
|
||||
@@ -478,7 +478,9 @@ flowchart LR
|
||||
| Agent health check | 2 minutes | 1 minute | Marks agents as offline if heartbeat is stale |
|
||||
| Notification processor | 1 minute | 1 minute | Sends pending notifications via configured channels |
|
||||
| Short-lived expiry | 30 seconds | 30 seconds | Marks expired short-lived certificates (profile TTL < 1 hour) |
|
||||
| Network scanner | 6 hours | 30 minutes | Probes TLS endpoints on configured CIDR ranges, stores discovered certs (M21, opt-in via `CERTCTL_NETWORK_SCAN_ENABLED`) |
|
||||
| Network scanner | 6 hours | 30 minutes | Probes TLS endpoints on configured CIDR ranges, stores discovered certs (M21, opt-in via `CERTCTL_NETWORK_SCAN_ENABLED`). CIDR size validated at API level — max /20 (4096 IPs) per range. |
|
||||
|
||||
Each loop uses `sync/atomic.Bool` idempotency guards to prevent concurrent tick execution — if a loop iteration is still running when the next tick fires, the tick is skipped with a warning log. All loops (including short-lived expiry check) run immediately on startup before entering their ticker interval, ensuring no gap between scheduler start and first execution. Graceful shutdown uses `sync.WaitGroup` with `WaitForCompletion()` to drain all in-flight work before process exit.
|
||||
|
||||
Each operation has a context timeout to prevent indefinite hangs if external services become unresponsive.
|
||||
|
||||
@@ -709,7 +711,9 @@ Audit events cannot be modified or deleted. They support filtering by actor, act
|
||||
|
||||
### API Audit Log
|
||||
|
||||
In addition to application-level audit events, certctl records every HTTP API call via middleware. The audit middleware captures method, path, actor (extracted from auth context), SHA-256 request body hash (truncated to 16 characters), response status code, and request latency. Health and readiness probes are excluded to avoid noise.
|
||||
In addition to application-level audit events, certctl records every HTTP API call via middleware. The audit middleware captures method, URL path (excluding query parameters — see security note below), actor (extracted from auth context), SHA-256 request body hash (truncated to 16 characters), response status code, and request latency. Health and readiness probes are excluded to avoid noise.
|
||||
|
||||
**Security: Query Parameter Exclusion** — The audit middleware intentionally records `r.URL.Path` only (not `r.URL.String()` or `r.RequestURI`). Query strings may contain cursor tokens, API keys passed as params, or other sensitive filter values. Since the audit trail is append-only with no deletion capability, any sensitive data recorded would persist permanently.
|
||||
|
||||
Audit recording is async (via goroutine) so it never blocks the HTTP response. If audit persistence fails, the error is logged immediately — the API call still succeeds. The middleware sits after the auth middleware in the stack so the actor identity is available from context.
|
||||
|
||||
|
||||
@@ -393,7 +393,7 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
||||
|
||||
**Operator Responsibility**:
|
||||
- **Issue API keys to users/systems** requiring API access (outside certctl; you maintain key registry).
|
||||
- **Rotate API keys periodically** (recommendation: annually, or when personnel changes).
|
||||
- **Rotate API keys using zero-downtime rotation** — `CERTCTL_AUTH_SECRET` supports comma-separated keys (e.g., `new-key,old-key`). Add the new key, migrate clients, then remove the old key. Recommendation: rotate at least annually, or immediately when personnel changes.
|
||||
- **Revoke API keys immediately** when user leaves or token is compromised (set `enabled=false` in API key management — not yet implemented in v1, owner must track manually).
|
||||
- **Enforce strong TLS** on control plane: TLS 1.2+, modern ciphers (configure on reverse proxy or `CERTCTL_TLS_*` env vars if operator-controlled).
|
||||
- **Protect `.env` and credential files** where API key is defined (restrict file system access, no version control).
|
||||
@@ -452,7 +452,7 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
|
||||
- **Immutable API Audit Log** (M19) — Middleware captures every API call:
|
||||
- `audit_events` table (append-only, no UPDATE/DELETE):
|
||||
- `method`: HTTP method (GET, POST, PUT, DELETE)
|
||||
- `path`: API endpoint path (e.g., `/api/v1/certificates`)
|
||||
- `path`: API endpoint path only, excluding query parameters (e.g., `/api/v1/certificates` — query strings intentionally omitted to prevent sensitive data persistence in the append-only audit trail)
|
||||
- `actor`: authenticated user/service (extracted from API key or context)
|
||||
- `body_hash`: SHA-256 hash of request body (truncated to 16 chars, first 8 chars shown in logs)
|
||||
- `status_code`: HTTP response status (200, 201, 400, 401, 404, 500, etc.)
|
||||
|
||||
@@ -49,6 +49,7 @@ Each section includes:
|
||||
- **Configurable CORS** — API restricts cross-origin requests via `CERTCTL_CORS_ORIGINS` allowlist or wildcard. Preflight caching prevents chatty browser auth flows.
|
||||
- **Token Bucket Rate Limiting** — Per-IP rate limiting (configurable via `CERTCTL_RATE_LIMIT_RPS` / `CERTCTL_RATE_LIMIT_BURST`) returns 429 Too Many Requests with Retry-After header. Prevents credential stuffing and brute-force attacks.
|
||||
- **No Password Storage** — certctl does not store user passwords. API keys are the sole authentication mechanism. Your API key generation, distribution, and rotation policies are your responsibility (see "Operator Responsibility" below).
|
||||
- **Zero-Downtime Key Rotation** — `CERTCTL_AUTH_SECRET` accepts comma-separated keys (e.g., `new-key,old-key`). All listed keys are validated with constant-time comparison. Operators can add a new key, migrate clients, then remove the old key — no service restart required for the client migration phase. A single-key warning is logged at startup to encourage rotation configuration.
|
||||
|
||||
**Evidence Locations**:
|
||||
|
||||
@@ -232,7 +233,7 @@ Each section includes:
|
||||
|
||||
**certctl Implementation** (V2):
|
||||
|
||||
- **Immutable API Audit Trail** (M19) — Every API call is recorded to `audit_events` table (append-only, no update/delete). Recorded: HTTP method, path, query parameters, actor (user/agent ID), SHA-256 hash of request body (truncated 16 chars for brevity), response status code, latency in milliseconds. Excluded paths (health, ready) are configurable. Audit records are async (non-blocking) and include a timestamp.
|
||||
- **Immutable API Audit Trail** (M19) — Every API call is recorded to `audit_events` table (append-only, no update/delete). Recorded: HTTP method, URL path (query parameters intentionally excluded — see security note), actor (user/agent ID), SHA-256 hash of request body (truncated 16 chars for brevity), response status code, latency in milliseconds. Excluded paths (health, ready) are configurable. Audit records are async (non-blocking) and include a timestamp. **Security: Query parameters are excluded from the audit path** because they may contain cursor tokens, API keys, or sensitive filter values; since the audit trail is append-only with no deletion, any sensitive data recorded would persist permanently.
|
||||
- **Audit Trail API** — `GET /api/v1/audit?actor=...&action=...&resource_id=...&created_after=...&created_before=...` allows searching for anomalous patterns (e.g., "who accessed certificate XYZ and when?", "did anyone revoke certs at 2 AM?").
|
||||
- **Expiration Threshold Alerting** — Certificate renewal policies define alert thresholds (days before expiry): default `[30, 14, 7, 0]`. When a certificate approaches a threshold, a notification is enqueued. Deduplication prevents duplicate alerts for the same cert at the same threshold. Auto status transition: cert moves to `Expiring` status at 30 days, `Expired` at 0 days.
|
||||
- **Certificate Status Auto-Transitions** — When a cert is issued, it's `Active`. As expiry approaches, status auto-transitions to `Expiring` (at 30d threshold). At expiry, status becomes `Expired`. Revoked certs move to `Revoked`. These transitions are recorded in the audit trail.
|
||||
|
||||
+1
-1
@@ -284,7 +284,7 @@ Script-based issuer connector for organizations with existing CA tooling. Delega
|
||||
| `CERTCTL_OPENSSL_CRL_SCRIPT` | No | Script that outputs DER-encoded CRL on stdout |
|
||||
| `CERTCTL_OPENSSL_TIMEOUT_SECONDS` | No | Script execution timeout (default: 30s) |
|
||||
|
||||
The sign script receives the CSR PEM on stdin and should output the signed certificate PEM on stdout. The connector parses the certificate to extract serial number, validity dates, and chain information.
|
||||
The sign script receives the CSR PEM on stdin and should output the signed certificate PEM on stdout. The connector parses the certificate to extract serial number, validity dates, and chain information. Before shell execution, serial numbers are validated as hex-only (`^[0-9a-fA-F]+$`) and revocation reason codes are validated against the RFC 5280 specification to prevent command injection.
|
||||
|
||||
### Revocation Across Issuers
|
||||
|
||||
|
||||
@@ -84,6 +84,8 @@ curl http://localhost:8443/health
|
||||
Open **http://localhost:8443** in your browser.
|
||||
|
||||
> **Note:** The Docker Compose demo runs with authentication disabled (`CERTCTL_AUTH_TYPE=none`) so you can explore immediately. For production, set `CERTCTL_AUTH_TYPE=api-key` and `CERTCTL_AUTH_SECRET=<your-secret>` in your environment, then pass `Authorization: Bearer <your-secret>` on all API requests. The dashboard will prompt for your API key on first load.
|
||||
>
|
||||
> **Key rotation:** `CERTCTL_AUTH_SECRET` accepts comma-separated keys (e.g., `CERTCTL_AUTH_SECRET=new-key,old-key`). Both keys are valid simultaneously, enabling zero-downtime rotation: add the new key, roll clients over, then remove the old key.
|
||||
|
||||
The dashboard comes pre-loaded with 15 demo certificates across multiple teams, environments, and statuses — expiring certs, expired certs, active certs, failed renewals. A realistic snapshot of what certificate management looks like in a real organization.
|
||||
|
||||
|
||||
@@ -78,7 +78,12 @@ func NewAuditLog(recorder AuditRecorder, cfg AuditConfig) func(http.Handler) htt
|
||||
|
||||
latency := time.Since(start).Milliseconds()
|
||||
|
||||
// Record audit event asynchronously (best-effort, don't block response)
|
||||
// Record audit event asynchronously (best-effort, don't block response).
|
||||
// SECURITY: We intentionally use r.URL.Path (not r.URL.String() or r.RequestURI)
|
||||
// to prevent query parameters from being recorded in the immutable audit trail.
|
||||
// Query strings may contain cursor tokens, API keys passed as params, or other
|
||||
// sensitive filter values. Since the audit trail is append-only with no deletion
|
||||
// capability, any sensitive data recorded would persist permanently.
|
||||
go func() {
|
||||
if err := recorder.RecordAPICall(
|
||||
context.Background(),
|
||||
|
||||
@@ -328,6 +328,46 @@ func TestAuditLog_CapturesLatency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_ExcludesQueryParamsFromPath(t *testing.T) {
|
||||
recorder := newWaitableAuditRecorder()
|
||||
mw := NewAuditLog(recorder, AuditConfig{})
|
||||
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Send a request with sensitive query parameters
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?api_key=secret123&cursor=abc&status=active", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if !recorder.Wait(1 * time.Second) {
|
||||
t.Fatal("timeout waiting for audit record")
|
||||
}
|
||||
|
||||
calls := recorder.getCalls()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 audit call, got %d", len(calls))
|
||||
}
|
||||
|
||||
// Path should contain ONLY the path, no query parameters
|
||||
if calls[0].Path != "/api/v1/certificates" {
|
||||
t.Errorf("expected path /api/v1/certificates (no query params), got %s", calls[0].Path)
|
||||
}
|
||||
if strings.Contains(calls[0].Path, "api_key") {
|
||||
t.Error("audit path contains 'api_key' — query parameters leaked into audit trail")
|
||||
}
|
||||
if strings.Contains(calls[0].Path, "secret123") {
|
||||
t.Error("audit path contains sensitive value 'secret123' — query parameters leaked into audit trail")
|
||||
}
|
||||
if strings.Contains(calls[0].Path, "cursor") {
|
||||
t.Error("audit path contains 'cursor' — query parameters leaked into audit trail")
|
||||
}
|
||||
if strings.Contains(calls[0].Path, "?") {
|
||||
t.Error("audit path contains '?' — query string leaked into audit trail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditServiceAdapter_TranslatesCallToEvent(t *testing.T) {
|
||||
var capturedActor, capturedActorType, capturedAction, capturedResourceType, capturedResourceID string
|
||||
var capturedDetails map[string]interface{}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewAuth_MultiKeyAcceptsBothKeys(t *testing.T) {
|
||||
cfg := AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "key-one,key-two",
|
||||
}
|
||||
|
||||
mw := NewAuth(cfg)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// First key should work
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req1.Header.Set("Authorization", "Bearer key-one")
|
||||
rr1 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr1, req1)
|
||||
if rr1.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for first key, got %d", rr1.Code)
|
||||
}
|
||||
|
||||
// Second key should work
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req2.Header.Set("Authorization", "Bearer key-two")
|
||||
rr2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr2, req2)
|
||||
if rr2.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for second key, got %d", rr2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuth_MultiKeyRejectsInvalidKey(t *testing.T) {
|
||||
cfg := AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "key-one,key-two",
|
||||
}
|
||||
|
||||
mw := NewAuth(cfg)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Invalid key should be rejected
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req.Header.Set("Authorization", "Bearer wrong-key")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for invalid key, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuth_MultiKeyWithSpaces(t *testing.T) {
|
||||
// Keys with leading/trailing spaces should be trimmed
|
||||
cfg := AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: " key-one , key-two ",
|
||||
}
|
||||
|
||||
mw := NewAuth(cfg)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req.Header.Set("Authorization", "Bearer key-one")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for trimmed key, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuth_SingleKeyStillWorks(t *testing.T) {
|
||||
cfg := AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "my-single-key",
|
||||
}
|
||||
|
||||
mw := NewAuth(cfg)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req.Header.Set("Authorization", "Bearer my-single-key")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for single key, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuth_NoneMode(t *testing.T) {
|
||||
cfg := AuthConfig{
|
||||
Type: "none",
|
||||
Secret: "",
|
||||
}
|
||||
|
||||
mw := NewAuth(cfg)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// No auth header needed in none mode
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 in none mode, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuth_MissingAuthHeader(t *testing.T) {
|
||||
cfg := AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "test-key",
|
||||
}
|
||||
|
||||
mw := NewAuth(cfg)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for missing auth, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuth_InvalidBearerFormat(t *testing.T) {
|
||||
cfg := AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "test-key",
|
||||
}
|
||||
|
||||
mw := NewAuth(cfg)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req.Header.Set("Authorization", "Basic dGVzdDp0ZXN0")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for non-Bearer auth, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuth_RemovedKeyIsRejected(t *testing.T) {
|
||||
// Simulate key rotation: only key-two is configured (key-one was removed)
|
||||
cfg := AuthConfig{
|
||||
Type: "api-key",
|
||||
Secret: "key-two",
|
||||
}
|
||||
|
||||
mw := NewAuth(cfg)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Old key should be rejected
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req.Header.Set("Authorization", "Bearer key-one")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for removed key, got %d", rr.Code)
|
||||
}
|
||||
|
||||
// New key should work
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req2.Header.Set("Authorization", "Bearer key-two")
|
||||
rr2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr2, req2)
|
||||
if rr2.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for current key, got %d", rr2.Code)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -100,12 +101,17 @@ func HashAPIKey(key string) string {
|
||||
// AuthConfig holds configuration for the Auth middleware.
|
||||
type AuthConfig struct {
|
||||
Type string // "api-key", "jwt", "none"
|
||||
Secret string // The raw API key (server compares against this)
|
||||
Secret string // The raw API key or comma-separated list of valid API keys
|
||||
}
|
||||
|
||||
// NewAuth creates an authentication middleware based on config.
|
||||
// When Type is "none", all requests pass through (demo/development mode).
|
||||
// When Type is "api-key", requests must include a valid Bearer token.
|
||||
// The Secret field supports a comma-separated list of valid API keys for
|
||||
// zero-downtime key rotation. Rotation workflow:
|
||||
// 1. Add new key to comma-separated list, restart server
|
||||
// 2. Update all agents/clients to use new key
|
||||
// 3. Remove old key from list, restart server
|
||||
func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
|
||||
if cfg.Type == "none" {
|
||||
return func(next http.Handler) http.Handler {
|
||||
@@ -113,8 +119,21 @@ func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-compute hash of the expected key for constant-time comparison
|
||||
expectedHash := HashAPIKey(cfg.Secret)
|
||||
// Pre-compute hashes of all valid keys for constant-time comparison.
|
||||
// Supports comma-separated list for zero-downtime key rotation.
|
||||
keys := strings.Split(cfg.Secret, ",")
|
||||
var expectedHashes []string
|
||||
for _, k := range keys {
|
||||
k = strings.TrimSpace(k)
|
||||
if k != "" {
|
||||
expectedHashes = append(expectedHashes, HashAPIKey(k))
|
||||
}
|
||||
}
|
||||
|
||||
// Warn if only one key is configured in production mode
|
||||
if len(expectedHashes) == 1 {
|
||||
slog.Warn("only one API key configured — consider adding a rotation key via comma-separated CERTCTL_AUTH_SECRET for zero-downtime rotation")
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -136,8 +155,16 @@ func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
|
||||
token := authHeader[7:]
|
||||
tokenHash := HashAPIKey(token)
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(expectedHash)) != 1 {
|
||||
// Check against all valid keys using constant-time comparison
|
||||
authorized := false
|
||||
for _, expectedHash := range expectedHashes {
|
||||
if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(expectedHash)) == 1 {
|
||||
authorized = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !authorized {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized)
|
||||
return
|
||||
|
||||
@@ -32,9 +32,12 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/validation"
|
||||
)
|
||||
|
||||
// Config represents the OpenSSL/Custom CA issuer connector configuration.
|
||||
@@ -258,6 +261,36 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// hexSerialRegex validates that a serial number contains only hexadecimal characters.
|
||||
// Certificate serial numbers are integers represented in hex (RFC 5280).
|
||||
var hexSerialRegex = regexp.MustCompile(`^[0-9a-fA-F]+$`)
|
||||
|
||||
// validateSerial validates a certificate serial number for safe use in shell commands.
|
||||
// Serial numbers must be non-empty, hex-only strings with no shell metacharacters.
|
||||
func validateSerial(serial string) error {
|
||||
if serial == "" {
|
||||
return fmt.Errorf("serial number cannot be empty")
|
||||
}
|
||||
if !hexSerialRegex.MatchString(serial) {
|
||||
return fmt.Errorf("serial number %q contains non-hex characters (expected ^[0-9a-fA-F]+$)", serial)
|
||||
}
|
||||
if err := validation.ValidateShellCommand(serial); err != nil {
|
||||
return fmt.Errorf("serial number failed shell safety validation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRevocationReason validates a revocation reason against RFC 5280 reason codes.
|
||||
func validateRevocationReason(reason string) error {
|
||||
if !domain.IsValidRevocationReason(reason) {
|
||||
return fmt.Errorf("invalid revocation reason %q (must be a valid RFC 5280 reason code)", reason)
|
||||
}
|
||||
if err := validation.ValidateShellCommand(reason); err != nil {
|
||||
return fmt.Errorf("revocation reason failed shell safety validation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate by calling the revoke script if configured.
|
||||
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||
if c.config.RevokeScript == "" {
|
||||
@@ -270,6 +303,14 @@ func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.Revoca
|
||||
reason = *request.Reason
|
||||
}
|
||||
|
||||
// Validate serial number (hex-only) and reason code (RFC 5280) before shell execution
|
||||
if err := validateSerial(request.Serial); err != nil {
|
||||
return fmt.Errorf("revocation input validation failed: %w", err)
|
||||
}
|
||||
if err := validateRevocationReason(reason); err != nil {
|
||||
return fmt.Errorf("revocation input validation failed: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("revoking certificate via revoke script",
|
||||
"serial", request.Serial,
|
||||
"reason", reason)
|
||||
|
||||
@@ -289,7 +289,7 @@ func TestOpenSSLConnector(t *testing.T) {
|
||||
}
|
||||
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "test-serial-12345",
|
||||
Serial: "ABCDEF1234567890",
|
||||
}
|
||||
|
||||
// Should return nil (no-op) when revoke script not configured
|
||||
@@ -324,8 +324,10 @@ func TestOpenSSLConnector(t *testing.T) {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
reason := "keyCompromise"
|
||||
revokeReq := issuer.RevocationRequest{
|
||||
Serial: "test-serial-12345",
|
||||
Serial: "ABCDEF1234567890",
|
||||
Reason: &reason,
|
||||
}
|
||||
|
||||
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||
@@ -334,6 +336,139 @@ func TestOpenSSLConnector(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Test 15: RevokeCertificate rejects injection payloads in serial number
|
||||
t.Run("RevokeCertificate_InjectionSerial", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
revokeScript := filepath.Join(tmpDir, "revoke.sh")
|
||||
if err := os.WriteFile(revokeScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create revoke script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
RevokeScript: revokeScript,
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
injectionPayloads := []string{
|
||||
"1234;rm -rf /",
|
||||
"1234|cat /etc/passwd",
|
||||
"1234&whoami",
|
||||
"$(id)",
|
||||
"`id`",
|
||||
"1234\nid",
|
||||
"../../../etc/passwd",
|
||||
"test-serial-12345", // hyphens not allowed (not hex)
|
||||
}
|
||||
|
||||
for _, payload := range injectionPayloads {
|
||||
t.Run(payload, func(t *testing.T) {
|
||||
req := issuer.RevocationRequest{Serial: payload}
|
||||
err := connector.RevokeCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Errorf("Expected injection payload %q to be rejected, but it was accepted", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Test 16: RevokeCertificate rejects invalid reason codes
|
||||
t.Run("RevokeCertificate_InvalidReason", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
revokeScript := filepath.Join(tmpDir, "revoke.sh")
|
||||
if err := os.WriteFile(revokeScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create revoke script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
RevokeScript: revokeScript,
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
invalidReasons := []string{
|
||||
"notARealReason",
|
||||
"keyCompromise;rm -rf /",
|
||||
"$(whoami)",
|
||||
"`id`",
|
||||
}
|
||||
|
||||
for _, reason := range invalidReasons {
|
||||
t.Run(reason, func(t *testing.T) {
|
||||
r := reason
|
||||
req := issuer.RevocationRequest{
|
||||
Serial: "ABCDEF1234567890",
|
||||
Reason: &r,
|
||||
}
|
||||
err := connector.RevokeCertificate(ctx, req)
|
||||
if err == nil {
|
||||
t.Errorf("Expected invalid reason %q to be rejected, but it was accepted", reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Test 17: RevokeCertificate accepts all valid RFC 5280 reason codes
|
||||
t.Run("RevokeCertificate_ValidReasons", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
signScript := filepath.Join(tmpDir, "sign.sh")
|
||||
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create sign script: %v", err)
|
||||
}
|
||||
revokeScript := filepath.Join(tmpDir, "revoke.sh")
|
||||
if err := os.WriteFile(revokeScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
|
||||
t.Fatalf("Failed to create revoke script: %v", err)
|
||||
}
|
||||
|
||||
config := &openssl.Config{
|
||||
SignScript: signScript,
|
||||
RevokeScript: revokeScript,
|
||||
}
|
||||
connector := openssl.New(config, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
validReasons := []string{
|
||||
"unspecified", "keyCompromise", "caCompromise", "affiliationChanged",
|
||||
"superseded", "cessationOfOperation", "certificateHold", "privilegeWithdrawn",
|
||||
}
|
||||
|
||||
for _, reason := range validReasons {
|
||||
t.Run(reason, func(t *testing.T) {
|
||||
r := reason
|
||||
req := issuer.RevocationRequest{
|
||||
Serial: "ABCDEF1234567890",
|
||||
Reason: &r,
|
||||
}
|
||||
err := connector.RevokeCertificate(ctx, req)
|
||||
if err != nil {
|
||||
t.Errorf("Expected valid reason %q to be accepted, got error: %v", reason, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Test 10: GetOrderStatus always returns "completed"
|
||||
t.Run("GetOrderStatus", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -356,6 +356,15 @@ func (s *Scheduler) shortLivedExpiryCheckLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(s.shortLivedExpiryCheckInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run immediately on start (with idempotency guard)
|
||||
s.shortLivedExpiryCheckRunning.Store(true)
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
defer s.shortLivedExpiryCheckRunning.Store(false)
|
||||
s.runShortLivedExpiryCheck(ctx)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -58,6 +58,36 @@ func (s *NetworkScanService) GetTarget(ctx context.Context, id string) (*domain.
|
||||
return s.networkScanRepo.Get(ctx, id)
|
||||
}
|
||||
|
||||
// maxCIDRHostBits is the maximum number of host bits allowed in a CIDR range.
|
||||
// A /20 network has 12 host bits = 4096 IPs max. This prevents operators from
|
||||
// accidentally creating scan targets that would exhaust server resources.
|
||||
const maxCIDRHostBits = 12
|
||||
|
||||
// validateCIDRs validates a list of CIDRs for syntax correctness and size limits.
|
||||
// Each CIDR must be a valid CIDR notation or plain IP address, and no single CIDR
|
||||
// may be larger than /20 (4096 IPs). This validation runs at API request time so
|
||||
// operators get an immediate 400 error instead of a silent truncation at scan time.
|
||||
func validateCIDRs(cidrs []string) error {
|
||||
for _, cidr := range cidrs {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
// Try parsing as plain IP (single host)
|
||||
if ip := net.ParseIP(cidr); ip == nil {
|
||||
return fmt.Errorf("invalid CIDR or IP: %s", cidr)
|
||||
}
|
||||
continue // Single IPs are always valid size
|
||||
}
|
||||
// Enforce /20 size cap at API level
|
||||
ones, bits := ipNet.Mask.Size()
|
||||
hostBits := bits - ones
|
||||
if hostBits > maxCIDRHostBits {
|
||||
return fmt.Errorf("CIDR %s is too large (/%d has %d host bits, max /%d with %d host bits = 4096 IPs)",
|
||||
cidr, ones, hostBits, bits-maxCIDRHostBits, maxCIDRHostBits)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTarget creates a new network scan target.
|
||||
func (s *NetworkScanService) CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
|
||||
if target.Name == "" {
|
||||
@@ -66,14 +96,9 @@ func (s *NetworkScanService) CreateTarget(ctx context.Context, target *domain.Ne
|
||||
if len(target.CIDRs) == 0 {
|
||||
return nil, fmt.Errorf("at least one CIDR is required")
|
||||
}
|
||||
// Validate CIDRs
|
||||
for _, cidr := range target.CIDRs {
|
||||
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||
// Try parsing as plain IP
|
||||
if ip := net.ParseIP(cidr); ip == nil {
|
||||
return nil, fmt.Errorf("invalid CIDR or IP: %s", cidr)
|
||||
}
|
||||
}
|
||||
// Validate CIDRs (syntax + /20 size cap)
|
||||
if err := validateCIDRs(target.CIDRs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(target.Ports) == 0 {
|
||||
target.Ports = []int64{443}
|
||||
@@ -115,13 +140,9 @@ func (s *NetworkScanService) UpdateTarget(ctx context.Context, id string, target
|
||||
existing.Name = target.Name
|
||||
}
|
||||
if len(target.CIDRs) > 0 {
|
||||
// Validate new CIDRs
|
||||
for _, cidr := range target.CIDRs {
|
||||
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||
if ip := net.ParseIP(cidr); ip == nil {
|
||||
return nil, fmt.Errorf("invalid CIDR or IP: %s", cidr)
|
||||
}
|
||||
}
|
||||
// Validate new CIDRs (syntax + /20 size cap)
|
||||
if err := validateCIDRs(target.CIDRs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing.CIDRs = target.CIDRs
|
||||
}
|
||||
|
||||
@@ -391,6 +391,92 @@ func TestExpandCIDR_AllowsPrivateRanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// AUDIT-003: CIDR size validation at API level
|
||||
|
||||
func TestValidateCIDRs_AcceptsValidSizes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cidrs []string
|
||||
}{
|
||||
{"single IP", []string{"192.168.1.1"}},
|
||||
{"/24 network", []string{"10.0.0.0/24"}},
|
||||
{"/20 network (max)", []string{"10.0.0.0/20"}},
|
||||
{"/30 tiny network", []string{"10.0.0.0/30"}},
|
||||
{"multiple valid", []string{"10.0.0.0/24", "192.168.1.0/24"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateCIDRs(tt.cidrs)
|
||||
if err != nil {
|
||||
t.Errorf("expected valid CIDRs to be accepted, got error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCIDRs_RejectsOversized(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cidrs []string
|
||||
}{
|
||||
{"/19 too large", []string{"10.0.0.0/19"}},
|
||||
{"/16 way too large", []string{"10.0.0.0/16"}},
|
||||
{"/8 massive", []string{"10.0.0.0/8"}},
|
||||
{"/0 everything", []string{"0.0.0.0/0"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateCIDRs(tt.cidrs)
|
||||
if err == nil {
|
||||
t.Errorf("expected oversized CIDR %v to be rejected", tt.cidrs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCIDRs_RejectsInvalid(t *testing.T) {
|
||||
err := validateCIDRs([]string{"not-a-cidr"})
|
||||
if err == nil {
|
||||
t.Error("expected invalid CIDR to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkScanService_CreateTarget_RejectsOversizedCIDR(t *testing.T) {
|
||||
repo := &mockNetworkScanRepo{}
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
svc := NewNetworkScanService(repo, nil, auditService, nil)
|
||||
|
||||
_, err := svc.CreateTarget(context.Background(), &domain.NetworkScanTarget{
|
||||
Name: "Test",
|
||||
CIDRs: []string{"10.0.0.0/8"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected CreateTarget to reject /8 CIDR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkScanService_UpdateTarget_RejectsOversizedCIDR(t *testing.T) {
|
||||
repo := &mockNetworkScanRepo{
|
||||
targets: []*domain.NetworkScanTarget{
|
||||
{ID: "nst-1", Name: "Original", CIDRs: []string{"10.0.0.0/24"}, Enabled: true},
|
||||
},
|
||||
}
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
svc := NewNetworkScanService(repo, nil, auditService, nil)
|
||||
|
||||
// Try to update from /24 to /8 — should be rejected
|
||||
_, err := svc.UpdateTarget(context.Background(), "nst-1", &domain.NetworkScanTarget{
|
||||
CIDRs: []string{"10.0.0.0/8"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected UpdateTarget to reject /8 CIDR update (bypass attempt)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandCIDR_SingleLoopbackIP(t *testing.T) {
|
||||
ips := expandCIDR("127.0.0.1")
|
||||
if len(ips) != 0 {
|
||||
|
||||
Reference in New Issue
Block a user