Files
certctl/internal/connector/target/configcheck/configcheck.go
T
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00

153 lines
5.5 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
// 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")
}