docs: update project documentation to reflect security remediation

Update README, architecture guide, and feature inventory to document all
changes from the security remediation pass (17 tickets):

- README: Add CI pipeline section (race detection, golangci-lint,
  govulncheck, per-layer coverage thresholds), CORS deny-by-default
  behavior, input validation, SSRF protection, scheduler concurrency
  safety. Update test count to 1050+. Add race detection and govulncheck
  to development commands.

- Architecture guide: Update testing strategy with scheduler tests, fuzz
  tests, and revised CI pipeline description. Add security model sections
  for input validation, CORS, and concurrency safety. Update test count.

- Feature inventory: Document CORS deny-by-default behavior.

- SECURITY_REMEDIATION.md: New file documenting all 17 remediated tickets
  with CWE classifications, before/after behavior, 3 deferred tickets
  with rationale, CI pipeline changes, and breaking CORS change.

Missing docs flagged as future additions:
- Formal threat model document
- Disaster recovery runbook
- Version upgrade guide
- Capacity planning benchmarks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shankar
2026-03-27 21:50:51 -04:00
parent 6b15caaf31
commit 79aedd980e
4 changed files with 242 additions and 7 deletions
+24 -2
View File
@@ -232,16 +232,26 @@ make install-tools
# Run tests
make test
# Run tests with race detection (same as CI)
go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/...
# Run with coverage
make test-coverage
# Lint
# Lint (runs golangci-lint with project config)
make lint
# Vulnerability scan
govulncheck ./...
# Format
make fmt
```
### CI Pipeline
Every push and PR runs: `go vet`, `go test -race` (race detection), `golangci-lint` (11 linters including gosec and bodyclose), `govulncheck` (dependency CVE scanning), and per-layer coverage thresholds (service 60%, handler 60%, domain 40%, middleware 50%). Frontend CI runs TypeScript type checking, Vitest tests, and Vite production build. See `.github/workflows/ci.yml` for details.
### Docker Compose
```bash
@@ -263,6 +273,18 @@ make docker-clean # Stop + remove volumes
- API key and JWT auth types supported; `none` for demo/development
- Auth type and secret configured via `CERTCTL_AUTH_TYPE` and `CERTCTL_AUTH_SECRET`
### CORS
- **Deny-by-default**: Empty `CERTCTL_CORS_ORIGINS` blocks all cross-origin requests. Operators must explicitly list allowed origins (comma-separated) or set `*` for development.
### Input Validation
- Shell command injection prevention on all connector scripts (strict character whitelist, no metacharacters)
- RFC 1123 domain name validation, base64url ACME token validation
- SSRF protection in network scanner (loopback, link-local, multicast, broadcast ranges filtered)
### Concurrency Safety
- Scheduler loops protected by `sync/atomic.Bool` idempotency guards — duplicate ticks are skipped
- Graceful shutdown waits up to 30 seconds for in-flight work before database close
### Audit Trail
- Immutable append-only log in PostgreSQL (`audit_events` table)
- Every lifecycle action attributed to an actor with timestamp and resource reference
@@ -378,7 +400,7 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
### V2: Operational Maturity
18 milestones complete, 950+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
18 milestones complete, 1050+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
**What shipped (all ✅):**
+196
View File
@@ -0,0 +1,196 @@
# Security Remediation Changelog
Comprehensive security audit and remediation performed March 2026 against the certctl V2 codebase. This document tracks every change, the vulnerability addressed, CWE classification, and before/after behavior.
## Summary
- **Tickets remediated:** 17 of 20
- **Tickets deferred:** 3 (TICKET-003, TICKET-007, TICKET-010)
- **New tests added:** 100+
- **CWE classes addressed:** 11 distinct CWE categories
## Remediated Tickets
### TICKET-001: Shell Command Injection in Connector Scripts (CRITICAL)
- **CWE:** CWE-78 (OS Command Injection)
- **Severity:** CRITICAL
- **Files created:** `internal/validation/command.go`, `internal/validation/command_test.go`
- **What changed:** New `ValidateShellCommand()` function blocks all shell metacharacters (`;|&$\`(){}><"'\n\r\x00`). `ValidateDomainName()` enforces RFC 1123 compliance. `ValidateACMEToken()` restricts to base64url characters. `SanitizeForShell()` provides defense-in-depth single-quote wrapping.
- **Before:** OpenSSL and ACME connectors passed user-controlled strings directly to shell commands.
- **After:** All shell-facing inputs validated against strict character whitelists; 80+ adversarial test cases.
### TICKET-002: Scheduler Race Conditions and Ungraceful Shutdown (CRITICAL)
- **CWE:** CWE-362 (Race Condition), CWE-404 (Improper Resource Shutdown)
- **Severity:** CRITICAL
- **Files modified:** `internal/scheduler/scheduler.go`, `cmd/server/main.go`
- **Files created:** `internal/scheduler/scheduler_test.go`
- **What changed:** Added `sync/atomic.Bool` idempotency guards on all 6 scheduler loops — if a loop tick fires while the previous iteration is still running, it logs a warning and skips. Added `sync.WaitGroup` for in-flight work tracking. New `WaitForCompletion(timeout)` method blocks until all goroutines finish or timeout expires. Server main wires `sched.WaitForCompletion(30*time.Second)` before database close.
- **Before:** Concurrent scheduler ticks could produce duplicate jobs; `os.Exit` during in-flight work could corrupt state.
- **After:** Each loop runs at most one concurrent iteration; graceful shutdown waits up to 30s for in-flight work.
### TICKET-004: CORS Misconfiguration — Wildcard Allowed by Default (HIGH)
- **CWE:** CWE-942 (Overly Permissive CORS Policy)
- **Severity:** HIGH
- **Files modified:** `internal/api/middleware/middleware.go`
- **Files created:** `internal/api/middleware/cors_test.go`
- **What changed:** Empty `CERTCTL_CORS_ORIGINS` now denies all cross-origin requests (no CORS headers set). Previously, an empty config implicitly allowed all origins.
- **Before:** Deploying without setting `CERTCTL_CORS_ORIGINS` left the API open to cross-origin requests from any domain.
- **After:** Deny-by-default. Operators must explicitly configure allowed origins. 9 test cases cover deny-default, specific origins, wildcard, and preflight behavior.
### TICKET-005: No Race Detection in CI (HIGH)
- **CWE:** CWE-362 (Race Condition)
- **Severity:** HIGH
- **Files modified:** `.github/workflows/ci.yml`
- **What changed:** Added `go test -race` step targeting service, handler, middleware, and scheduler packages with `-count=1 -timeout 300s`.
- **Before:** Data races could ship undetected.
- **After:** Every CI run catches races with Go's built-in race detector.
### TICKET-006: 18-Positional-Parameter Function Signature (MEDIUM)
- **CWE:** CWE-1078 (Inappropriate Source Code Style)
- **Severity:** MEDIUM
- **Files modified:** `internal/api/router/router.go`, `cmd/server/main.go`, `internal/integration/lifecycle_test.go`, `internal/integration/negative_test.go`
- **What changed:** Replaced `RegisterHandlers(18 positional params)` with `HandlerRegistry` struct containing 18 named fields. All call sites updated to use struct literal initialization.
- **Before:** Adding or reordering a handler required updating every call site; parameter order bugs were easy to introduce.
- **After:** Named fields make call sites self-documenting; new handlers added without breaking existing code.
### TICKET-008: No Static Analysis in CI (MEDIUM)
- **CWE:** CWE-1078 (Inappropriate Source Code Style)
- **Severity:** MEDIUM
- **Files modified:** `.github/workflows/ci.yml`
- **Files created:** `.golangci.yml`
- **What changed:** Added `golangci-lint` (11 linters: errcheck, govet, staticcheck, unused, gosimple, ineffassign, typecheck, gocritic, gosec, bodyclose, noctx) and `govulncheck` steps to CI pipeline.
- **Before:** Code quality and known CVEs checked only manually.
- **After:** Every push and PR runs static analysis and vulnerability scanning.
### TICKET-009: Missing HTTP Client Timeouts in Notifier Connectors (HIGH)
- **CWE:** CWE-400 (Uncontrolled Resource Consumption)
- **Severity:** HIGH
- **Files modified:** Slack, Teams, PagerDuty, OpsGenie connector files
- **What changed:** All notifier HTTP clients now use explicit `Timeout: 10 * time.Second` instead of `http.DefaultClient` (no timeout).
- **Before:** A hung webhook endpoint could block a notifier goroutine indefinitely.
- **After:** All outbound HTTP calls timeout after 10 seconds.
### TICKET-012: Context Propagation — `context.Background()` Misuse (MEDIUM)
- **CWE:** CWE-755 (Improper Handling of Exceptional Conditions)
- **Severity:** MEDIUM
- **Files modified:** Multiple service files
- **What changed:** Replaced `context.Background()` usage in request-handling code with proper `ctx` propagation from the incoming HTTP request.
- **Before:** Cancellation signals (client disconnect, shutdown) were not propagated to downstream operations.
- **After:** Request context flows through service calls, enabling proper cancellation and timeout propagation.
### TICKET-013: SSRF in Network Scanner — No Reserved IP Filtering (HIGH)
- **CWE:** CWE-918 (Server-Side Request Forgery)
- **Severity:** HIGH
- **Files modified:** `internal/service/network_scan.go`
- **What changed:** Added `isReservedIP()` function that filters loopback (127.0.0.0/8), link-local (169.254.0.0/16 — includes cloud metadata at 169.254.169.254), multicast (224.0.0.0/4), and broadcast (255.255.255.255) addresses. RFC 1918 private ranges explicitly allowed since certctl is self-hosted for internal networks.
- **Before:** Network scanner could probe cloud metadata endpoints (169.254.169.254) or loopback services.
- **After:** Reserved ranges filtered before CIDR expansion; private ranges preserved for legitimate internal scanning.
### TICKET-014: Agent Verify Tests Generate Invalid Certificates (MEDIUM)
- **CWE:** CWE-1060 (Excessive Runtime Resource Consumption)
- **Severity:** MEDIUM
- **Files modified:** `cmd/agent/verify_test.go`
- **What changed:** `generateTestCert()` now creates valid self-signed ECDSA P-256 certificates with proper serial numbers, validity periods, and key usage.
- **Before:** Tests used invalid certificate stubs that could mask real parsing bugs.
- **After:** Tests exercise real X.509 certificate parsing paths.
### TICKET-015: Flaky Async Audit Tests Using `time.Sleep` (MEDIUM)
- **CWE:** CWE-362 (Race Condition in Tests)
- **Severity:** MEDIUM
- **Files modified:** `internal/api/middleware/audit_test.go`
- **What changed:** Replaced `time.Sleep(50ms)` with `waitableAuditRecorder` that uses channel-based synchronization. Tests block on a channel until the async audit goroutine completes.
- **Before:** Tests depended on wall-clock timing; could flake under load.
- **After:** Deterministic synchronization; no timing dependency.
### TICKET-016: Undocumented `InsecureSkipVerify: true` Usage (LOW)
- **CWE:** CWE-295 (Improper Certificate Validation)
- **Severity:** LOW
- **Files modified:** `cmd/agent/verify.go`, `internal/service/network_scan.go`
- **What changed:** Added detailed security comments explaining why `InsecureSkipVerify: true` is intentional and scoped: it's required for discovery/verification probing of all certificates (including self-signed, expired, internal CA) and is never used for control-plane API calls or issuer communication.
- **Before:** `InsecureSkipVerify` appeared without explanation, creating audit findings.
- **After:** Each usage site documents the security rationale with ticket references.
### TICKET-017: Coverage Thresholds Too Low (MEDIUM)
- **CWE:** CWE-1078 (Inappropriate Source Code Style)
- **Severity:** MEDIUM
- **Files modified:** `.github/workflows/ci.yml`
- **What changed:** Raised CI coverage thresholds: service 30% → 60%, handler 50% → 60%. Added new layers: domain 40%, middleware 50%.
- **Before:** Tests could degrade significantly before CI flagged it.
- **After:** Per-layer coverage floors prevent regression in any single layer.
### TICKET-018: No Fuzz Testing (LOW)
- **CWE:** CWE-20 (Improper Input Validation)
- **Severity:** LOW
- **Files created:** `internal/validation/command_fuzz_test.go`, `internal/domain/revocation_fuzz_test.go`
- **What changed:** Added Go native fuzz tests (`testing/fuzz`) for command validation functions and revocation domain parsing. Fuzz targets exercise `ValidateShellCommand`, `ValidateDomainName`, `ValidateACMEToken` with random inputs.
- **Before:** Input validation tested only with known adversarial inputs.
- **After:** Continuous fuzzing can discover edge cases human testers miss.
### TICKET-019: Inconsistent Error Wrapping (LOW)
- **CWE:** CWE-755 (Improper Handling of Exceptional Conditions)
- **Severity:** LOW
- **Files modified:** Multiple service and handler files
- **What changed:** Standardized on `fmt.Errorf("context: %w", err)` wrapping pattern throughout the codebase. Ensures `errors.Is()` and `errors.As()` work correctly across error chains.
- **Before:** Mix of `%v` (loses error chain) and `%w` (preserves chain) formatting.
- **After:** Consistent `%w` wrapping enables proper error type checking.
### TICKET-020: Missing Godoc on Config Structs (LOW)
- **CWE:** CWE-1078 (Inappropriate Source Code Style)
- **Severity:** LOW
- **Files modified:** `internal/config/config.go`
- **What changed:** Added godoc comments to all fields in all config structs (ServerConfig, KeygenConfig, CAConfig, ACMEConfig, StepCAConfig, OpenSSLConfig, NotifierConfig, ESTConfig, VerificationConfig, DiscoveryConfig, NetworkScanConfig).
- **Before:** Configuration semantics discoverable only by reading code or CLAUDE.md.
- **After:** `go doc` and IDE tooltips show purpose, default values, and env var names.
## Deferred Tickets
### TICKET-003: No Repository Layer Test Scaffolding (HIGH)
- **CWE:** CWE-1060 (Excessive Runtime Resource Consumption)
- **Rationale:** Requires `testcontainers-go` infrastructure (Docker-in-Docker) for real PostgreSQL instances. Estimated 2-3 day effort. Scheduled for next sprint.
### TICKET-007: CertificateService God Object (MEDIUM)
- **CWE:** CWE-1060 (Excessive Runtime Resource Consumption)
- **Rationale:** 700+ line service file mixing CRUD, revocation, CRL/OCSP, and deployment logic. Decomposition into RevocationService, CRLService, OCSPService, and DeploymentService is a multi-day refactor with high regression risk. Scheduled for dedicated refactor sprint.
### TICKET-010: Missing Request Body Size Limits (MEDIUM)
- **CWE:** CWE-400 (Uncontrolled Resource Consumption)
- **Rationale:** Requires `http.MaxBytesReader` integration across all handlers. Lower risk in practice since API key auth limits exposure to authenticated clients. Scheduled for next sprint.
## CI Pipeline Changes
The CI pipeline now enforces:
1. **`go vet`** — basic static analysis
2. **`go test -race`** — race detection on service, handler, middleware, scheduler packages
3. **`golangci-lint`** — 11 linters (errcheck, govet, staticcheck, unused, gosimple, ineffassign, typecheck, gocritic, gosec, bodyclose, noctx)
4. **`govulncheck`** — known CVE scanning against Go dependencies
5. **Coverage thresholds** — service 60%, handler 60%, domain 40%, middleware 50%
6. **Frontend** — TypeScript type check, Vitest tests, Vite production build
## Configuration Changes
### Breaking Change: CORS Deny-by-Default
**Before:** Empty `CERTCTL_CORS_ORIGINS` implicitly allowed all cross-origin requests.
**After:** Empty `CERTCTL_CORS_ORIGINS` denies all cross-origin requests. Set `CERTCTL_CORS_ORIGINS=http://localhost:3000` for development or `CERTCTL_CORS_ORIGINS=*` to restore previous behavior.
This affects any deployment that relied on the implicit wildcard CORS behavior without explicitly setting the env var.
+19 -3
View File
@@ -713,6 +713,18 @@ In addition to application-level audit events, certctl records every HTTP API ca
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.
### Input Validation and SSRF Protection
All shell-facing inputs (connector scripts, domain names, ACME tokens) are validated through `internal/validation/command.go` before reaching shell execution. `ValidateShellCommand()` denies all shell metacharacters. `ValidateDomainName()` enforces RFC 1123. `ValidateACMEToken()` restricts to base64url characters. The network scanner filters reserved IP ranges (loopback, link-local including cloud metadata 169.254.169.254, multicast, broadcast) to prevent SSRF, while preserving RFC 1918 private ranges for legitimate internal scanning.
### CORS
CORS uses a **deny-by-default** posture: when `CERTCTL_CORS_ORIGINS` is empty, no CORS headers are set and only same-origin requests can read responses. Operators must explicitly configure allowed origins. This prevents accidental exposure of the API to cross-origin requests in production.
### Concurrency Safety
The background scheduler uses `sync/atomic.Bool` idempotency guards on all 6 loops — if a tick fires while the previous iteration is still running, it skips. A `sync.WaitGroup` tracks all in-flight goroutines. `WaitForCompletion(timeout)` blocks during shutdown until all work finishes or the timeout expires, preventing state corruption from mid-flight database operations during process exit.
### Logging
All logging throughout the service layer uses Go's `log/slog` package for structured, queryable logs. This replaces ad-hoc `fmt.Printf` statements with consistent key-value logging that includes request context, operation names, and error details. Agents also implement exponential backoff on network failures to gracefully handle temporary connectivity issues with the control plane.
@@ -895,7 +907,7 @@ This data flow is pull-based and non-blocking. Agents discover at their own pace
## Testing Strategy
certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 900+ tests across five layers (service, handler, integration, connector, and frontend). The goal is high-confidence regression prevention at the service and handler layers, where the most complex business logic lives, combined with integration tests that exercise the full request path from HTTP to database.
certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 1050+ tests across six layers (service, handler, integration, connector, frontend, and scheduler). The goal is high-confidence regression prevention at the service and handler layers, where the most complex business logic lives, combined with integration tests that exercise the full request path from HTTP to database.
**Service layer unit tests** (`internal/service/*_test.go`) — ~238 test functions across 15 files with mock repositories. These test all business logic in isolation: certificate CRUD with validation, certificate revocation (success, already-revoked, archived, invalid reason, all RFC 5280 reason codes, issuer notification, notification service integration, OCSP/CRL generation), agent lifecycle (registration, heartbeat, CSR submission with both keygen modes), job state machine (creation, processing, cancellation, retry logic), policy evaluation (all 5 rule types, violation creation), renewal and issuance flow (server-side and agent-side keygen paths), notification deduplication (threshold tag matching, channel routing), team/owner/agent group CRUD with pagination and audit recording, issuer service CRUD with connection testing, and the issuer connector adapter (type translation between connector and service layers including revocation). Mock repositories are simple structs with function fields, avoiding heavy mocking frameworks — this keeps tests readable and avoids coupling to mock library APIs.
@@ -907,11 +919,15 @@ certctl uses a layered testing approach aligned with the handler → service →
**CLI tests** (`internal/cli/client_test.go`) — 14 tests covering all 10 CLI subcommands with httptest mock servers, PEM parsing for bulk import, auth header verification, and JSON/table output formatting.
**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs: Go (build, vet, test with coverage, coverage threshold enforcement) and Frontend (TypeScript type check, Vitest test suite, Vite production build). The Go job runs all tests with `-coverprofile`, then enforces coverage thresholds: service layer must be at least 30% (current: ~35%) and handler layer must be at least 50% (current: ~63%). These thresholds act as regression floors — they can only go up. The service layer threshold is deliberately lower because much of the service code depends on postgres repositories and external connectors that require real infrastructure to test meaningfully. Connector tests are included via `./internal/connector/issuer/...` and `./internal/connector/target/...` (covers Local CA, ACME, step-ca, NGINX, Apache, HAProxy, Traefik, and Caddy packages with unit tests for certificate signing logic, DNS solver, issuer validation, and deployment flows). The Frontend job runs `npx vitest run` between the TypeScript check and production build steps.
**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs: Go (build, vet, race detection, static analysis, vulnerability scanning, test with coverage, coverage threshold enforcement) and Frontend (TypeScript type check, Vitest test suite, Vite production build). The Go job runs `go test -race` on service, handler, middleware, and scheduler packages to catch data races. It runs `golangci-lint` with 11 linters (errcheck, govet, staticcheck, unused, gosimple, ineffassign, typecheck, gocritic, gosec, bodyclose, noctx) configured in `.golangci.yml`. It runs `govulncheck ./...` to scan dependencies for known CVEs. Coverage thresholds are enforced per-layer: service 60%, handler 60%, domain 40%, middleware 50%. These thresholds act as regression floors — they can only go up. Connector tests are included via `./internal/connector/issuer/...` and `./internal/connector/target/...` (covers Local CA, ACME, step-ca, NGINX, Apache, HAProxy, Traefik, and Caddy packages with unit tests for certificate signing logic, DNS solver, issuer validation, and deployment flows). The Frontend job runs `npx vitest run` between the TypeScript check and production build steps.
**Connector tests** (`internal/connector/`) — 57 test functions covering issuer, target, and notifier connectors. The Local CA connector has tests for self-signed and sub-CA modes (RSA, ECDSA, config validation, non-CA cert rejection). The ACME DNS solver has 10 tests for script-based DNS-01 and DNS-PERSIST-01 challenges (6 DNS-01 tests + 4 DNS-PERSIST-01 tests covering `PresentPersist` success, no-script error, script failure, and wildcard domain handling). The step-ca connector has tests with a mock HTTP server for issuance, renewal, revocation, and error paths. The OpenSSL/Custom CA connector has 14 tests covering config validation, issuance success/failure/timeout, renewal, revocation, and CRL generation. The NGINX target connector has 13 tests covering config validation, certificate deployment (file writing, permissions, validate/reload commands), and deployment validation. Apache httpd and HAProxy connectors each have 3 tests covering config validation, deployment, and validation flows. Traefik and Caddy connectors have tests covering file-based deployment and (for Caddy) dual-mode API/file configuration. Notifier connector tests span 20 tests across Slack (5), Teams (4), PagerDuty (6), and OpsGenie (5) — verifying channel identity, payload formatting, HTTP error handling, connection failures, auth headers, and configuration defaults.
**What's not tested and why:** Postgres repository implementations (`internal/repository/postgres/`) require a real database and are tested only through integration tests, not unit tests. Target connectors for F5 BIG-IP and IIS are interface stubs (implementation planned for a future release). Scheduler loops are time-dependent and tested manually during development. The ACME connector requires a real ACME server (tested manually against Let's Encrypt staging). These are all candidates for future expansion as the test infrastructure matures.
**Scheduler tests** (`internal/scheduler/scheduler_test.go`) — Tests for idempotency guards (`sync/atomic.Bool` CompareAndSwap prevents concurrent loop ticks), `WaitForCompletion` success and timeout paths, and multi-loop idempotency.
**Fuzz tests** (`internal/validation/command_fuzz_test.go`, `internal/domain/revocation_fuzz_test.go`) — Go native fuzz tests (`testing/fuzz`) for command validation functions and revocation domain parsing. These exercise `ValidateShellCommand`, `ValidateDomainName`, and `ValidateACMEToken` with random inputs to discover edge cases.
**What's not tested and why:** Postgres repository implementations (`internal/repository/postgres/`) require a real database and are tested only through integration tests, not unit tests — a `testcontainers-go` scaffolding for isolated PostgreSQL instances is planned. Target connectors for F5 BIG-IP and IIS are interface stubs (implementation planned for V3). The ACME connector requires a real ACME server (tested manually against Let's Encrypt staging). These are all candidates for future expansion as the test infrastructure matures.
## What's Next
+3 -2
View File
@@ -43,8 +43,9 @@ Protects the control plane from being overwhelmed by a single client — whether
Required for the web dashboard to communicate with the API when served from a different origin (e.g., during development on `localhost:3000` while the API runs on `localhost:8443`). Without CORS headers, browsers block the requests silently.
- **Configurable Per-Origin Allowlist** — `CERTCTL_CORS_ORIGINS` (comma-separated or wildcard)
- **Preflight Caching** — Standard CORS headers
- **Deny-by-Default** — Empty `CERTCTL_CORS_ORIGINS` blocks all cross-origin requests (secure default)
- **Configurable Per-Origin Allowlist** — `CERTCTL_CORS_ORIGINS` (comma-separated or `*` for wildcard)
- **Preflight Caching** — Standard CORS headers with `Access-Control-Max-Age`
### Query Features (M20)