mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
d60a0ac297
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)
150 lines
5.4 KiB
Go
150 lines
5.4 KiB
Go
// 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")
|
|
}
|