mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
21aeed4f4e
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
153 lines
5.5 KiB
Go
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")
|
|
}
|