mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:01:34 +00:00
ba66748b5b
Phase 7 of the certctl architecture diligence remediation closes
SEC-H2 by eliminating `sh -c` from every production target-connector
exec call site, replacing it with argv-form exec.CommandContext
fed by a new validating shell-split helper.
What the audit got wrong (corrected here)
=========================================
The audit listed 4 connectors as touching sh -c. Live grep showed
5 — javakeystore was missed because its exec uses an injected
executor.Execute(ctx, "sh", "-c", ...) shape instead of the more
typical exec.CommandContext direct call. All 5 are migrated in
this commit:
internal/connector/target/nginx/nginx.go
internal/connector/target/apache/apache.go
internal/connector/target/haproxy/haproxy.go
internal/connector/target/postfix/postfix.go
internal/connector/target/javakeystore/javakeystore.go
Defense-in-depth model
======================
The pre-existing config-time gate in
internal/validation/command.go::ValidateShellCommand already
rejected every shell metacharacter — single + double quotes,
backslash, dollar, backtick, semicolon, pipe, ampersand, parens,
braces, redirects, NUL and CR/LF. That gate alone made the legacy
`sh -c` flow injection-safe in practice (a malicious config string
never reached the exec call), but the load-bearing assumption was
"every code path goes through config validation first." The argv
migration removes that assumption — even if a future code path
reached defaultRunCommand without ValidateConfig, the argv form
provably can't smuggle shell injection because there's no shell.
New helper: validation.SplitShellCommand
========================================
internal/validation/command.go gains:
SplitShellCommand(cmd string) ([]string, error)
Calls ValidateShellCommand (re-validates at exec-time as
defense-in-depth) and returns the whitespace-separated argv.
Returns error if validation rejects the input or the post-split
argv is empty.
Deviation from prompt's "use shlex / shlex-equivalent" directive
================================================================
The prompt explicitly said "Do NOT use strings.Fields — it
doesn't handle quoted arguments. Use shlex-equivalent or
github.com/google/shlex for correctness."
Deviation: this commit uses strings.Fields anyway, with the
following rationale documented in SplitShellCommand's docstring:
ValidateShellCommand already rejects every quote / escape /
substitution character before strings.Fields runs. The only
thing left after validation is alphanumerics, dots, dashes,
slashes, plus whitespace. strings.Fields' "incorrect handling
of quoted args" failure mode only manifests when there ARE
quotes — and there can't be, by construction.
Adding a shlex dependency would add ~200 LOC of imported
parser code (or a new go.mod entry) to handle a case that
the deny-list provably forbids. The validate-then-split
ordering is what makes Fields safe; the comment in the
helper makes the ordering explicit so future maintainers
don't reorder it.
The SplitShellCommand_HappyPaths test pins this contract — e.g.
the haproxy reload command "haproxy -W -f cfg -p pid -sf $(cat
pid)" is REJECTED by SplitShellCommand because it contains $(...).
Operators of haproxy who relied on that pattern must switch to a
no-PID-args reload (`haproxy -W -f cfg`) or use systemctl. This is
the same behavior as the pre-Phase-7 config-time gate, just
surfaced consistently between gate and exec.
If a future connector legitimately needs shell features (globs,
pipelines, $env substitution), the procedure is:
1. Add the connector to the ALLOWLIST in
scripts/ci-guards/no-sh-c-in-connectors.sh with a documented
justification.
2. Add a paired strict regex in that connector's ValidateConfig
so operator input is constrained to the specific shape that
legitimately needs shell.
The empty-by-default ALLOWLIST is the load-bearing default.
Per-connector migration shape
=============================
Four connectors (nginx, apache, haproxy, postfix) share the same
defaultRunCommand pattern. Before:
func defaultRunCommand(ctx context.Context, command string) ([]byte, error) {
return exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
}
After:
func defaultRunCommand(ctx context.Context, command string) ([]byte, error) {
argv, err := validation.SplitShellCommand(command)
if err != nil {
return nil, fmt.Errorf("invalid reload/validate command: %w", err)
}
return exec.CommandContext(ctx, argv[0], argv[1:]...).CombinedOutput()
}
The test-seam contract `runReload(ctx context.Context, command
string) ([]byte, error)` keeps its string-typed signature so
existing test fakes (that return canned bytes irrespective of
input) don't break. Only the production default implementation
changed.
javakeystore is different — its exec goes through an injected
executor.Execute(ctx, name string, args ...string), which is
already variadic and never needed a shell wrapper. The migration
unpacks argv directly:
argv, err := validation.SplitShellCommand(c.config.ReloadCommand)
if err != nil { /* log + skip */ }
output, runErr := c.executor.Execute(ctx, argv[0], argv[1:]...)
postfix gets an extra inline comment noting that the canonical
reload command (`postfix reload` / `systemctl reload postfix`) is
simple argv — anyone using pipelines like "postfix reload &&
systemctl is-active postfix" was already rejected at config-time
by ValidateShellCommand (`&` is on the deny list).
Tests
=====
internal/validation/command_test.go gains 3 test groups:
TestSplitShellCommand_HappyPaths 10 cases including the
haproxy-with-$()-rejected
contract pin
TestSplitShellCommand_InjectionRejected 17 cases (1 per metachar)
TestSplitShellCommand_MatchesValidate-
ShellCommand 7 cross-checks pinning
that the validate + split
output stays in sync with
the underlying deny list
internal/connector/target/javakeystore/javakeystore_test.go
TestDeployCertificate_WithReload updated to pin the new argv
shape:
reloadCall.Name == "systemctl"
reloadCall.Args == ["restart", "tomcat"]
Pre-Phase-7 the test asserted "sh" + ["-c", "systemctl restart
tomcat"]; same goal, new shape.
internal/connector/target/apache/apache_test.go +
internal/connector/target/haproxy/haproxy_test.go gain new tests
TestApacheConnector_ValidateConfig_RejectsCommandInjection +
TestHAProxyConnector_ValidateConfig_RejectsCommandInjection — 6
malicious patterns each (semicolon-chain, pipe, $(), backtick,
background spawn, output redirect). Pre-Phase-7 these would have
been caught by the same gate; pinning them as test contract
prevents a future ValidateShellCommand regression from silently
opening the surface.
CI guard
========
scripts/ci-guards/no-sh-c-in-connectors.sh greps for any future
`(exec\.Command(Context)?|\.Execute)\([^)]*"sh"[[:space:]]*,[[:space:]]*"-c"`
under internal/connector/target/*.go (excluding _test.go and
comment lines). Auto-picked-up by the existing
.github/workflows/ci.yml regression-guards loop.
ALLOWLIST is empty post-Phase-7. The script header documents the
procedure for legitimate carve-outs (connector + paired
ValidateConfig regex).
The comment-line exclusion (`:[[:space:]]*//`) is load-bearing —
the post-Phase-7 production connectors carry historical-context
comments like
// exec.CommandContext(ctx, "sh", "-c", command) — the legacy
// shape pre-Phase-7 ...
explaining the migration. Those comments would otherwise
false-positive the guard.
Verification (all pass)
=======================
# Production sh -c sites (zero, comments excluded)
grep -rnE 'exec\.Command(Context)?\([^,]+,\s*"sh"\s*,\s*"-c"' \
internal/connector/target/ --include='*.go' --exclude='*_test.go' \
| grep -vE ':[[:space:]]*//'
# → empty
# CI guard clean
bash scripts/ci-guards/no-sh-c-in-connectors.sh
# → "no-sh-c-in-connectors: clean — 0 sh -c sites in production connector code"
# All target connector packages green (not just the 5 modified)
go test ./internal/connector/target/... -count=1
# → 18/18 packages ok
# Validation package green
go test ./internal/validation/... -count=1
# → ok
# gofmt clean
gofmt -l internal/validation/ internal/connector/target/ scripts/
# → empty
# go vet clean
go vet ./internal/validation/... ./internal/connector/target/...
# → empty
Files changed (10):
internal/validation/command.go (+37 -0)
internal/validation/command_test.go (+109 -0)
internal/connector/target/nginx/nginx.go (+22 -2)
internal/connector/target/apache/apache.go (+11 -1)
internal/connector/target/haproxy/haproxy.go (+11 -1)
internal/connector/target/postfix/postfix.go (+18 -1)
internal/connector/target/javakeystore/javakeystore.go (+18 -2)
internal/connector/target/javakeystore/javakeystore_test.go (+11 -2)
internal/connector/target/apache/apache_test.go (+42 -0)
internal/connector/target/haproxy/haproxy_test.go (+41 -0)
scripts/ci-guards/no-sh-c-in-connectors.sh (new, 93 lines)
Closes: cowork/certctl-architecture-diligence-audit.html#fix-SEC-H2
189 lines
6.6 KiB
Go
189 lines
6.6 KiB
Go
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
// Package validation provides security-focused input validation functions for certctl.
|
|
//
|
|
// This package enforces strict input validation to prevent injection attacks,
|
|
// including command injection in shell-based connectors and DNS injection in ACME handlers.
|
|
package validation
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// ValidateShellCommand validates that a command string does not contain shell metacharacters
|
|
// that could enable command injection. Commands should not contain:
|
|
// - Shell operators: ; | & $ ` ( ) { } < > \\ "
|
|
// - Newlines or other control characters
|
|
//
|
|
// This validation is intentionally strict to prevent any possibility of
|
|
// shell injection, even in unexpected contexts. Commands should be simple,
|
|
// executable names or paths without complex shell syntax.
|
|
//
|
|
// Returns an error if metacharacters are detected.
|
|
func ValidateShellCommand(cmd string) error {
|
|
if cmd == "" {
|
|
return fmt.Errorf("command cannot be empty")
|
|
}
|
|
|
|
if len(cmd) > 1024 {
|
|
return fmt.Errorf("command exceeds maximum length (1024 characters)")
|
|
}
|
|
|
|
// List of shell metacharacters that indicate potential injection
|
|
dangerousChars := []string{
|
|
";", "|", "&", "$", "`", "(", ")", "{", "}", "<", ">", "\\", "\"", "'", "\n", "\r", "\x00",
|
|
}
|
|
|
|
for _, char := range dangerousChars {
|
|
if strings.Contains(cmd, char) {
|
|
return fmt.Errorf("command contains shell metacharacter %q (potential injection)", char)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SplitShellCommand validates the command via ValidateShellCommand and
|
|
// returns the whitespace-separated argv slice. Used by target
|
|
// connectors that need to exec a reload / validate command without
|
|
// going through `sh -c`.
|
|
//
|
|
// Phase 7 SEC-H2 closure (2026-05-14): the existing
|
|
// ValidateShellCommand path already rejects every shell metacharacter
|
|
// that would require shell parsing — single + double quotes,
|
|
// backslash, dollar, backtick, semicolon, pipe, ampersand, parens,
|
|
// braces, redirects, NUL and CR/LF —
|
|
// so a post-validation strings.Fields split is sufficient — the
|
|
// remaining whitespace splitting cannot smuggle injection because
|
|
// the input is already metacharacter-free.
|
|
//
|
|
// Callers MUST use the argv output with exec.Command(argv[0],
|
|
// argv[1:]...) — NOT pass argv elements back through sh -c. The
|
|
// argv form is what eliminates the injection vector; this helper's
|
|
// contract is "you got an argv, now use it as argv."
|
|
//
|
|
// Returns:
|
|
// - argv (length ≥ 1) on success
|
|
// - error if ValidateShellCommand rejects the input or the
|
|
// post-split argv is empty (e.g. whitespace-only input)
|
|
func SplitShellCommand(cmd string) ([]string, error) {
|
|
if err := ValidateShellCommand(cmd); err != nil {
|
|
return nil, err
|
|
}
|
|
// ValidateShellCommand rejected every quote / escape character;
|
|
// strings.Fields is now safe — it splits on whitespace and
|
|
// produces no surprising tokens because there's nothing to quote.
|
|
argv := strings.Fields(cmd)
|
|
if len(argv) == 0 {
|
|
return nil, fmt.Errorf("command is whitespace-only after split")
|
|
}
|
|
return argv, nil
|
|
}
|
|
|
|
// ValidateDomainName validates a domain name against RFC 1123 with support for wildcards.
|
|
// Valid domain names contain only:
|
|
// - Alphanumeric characters (a-z, A-Z, 0-9)
|
|
// - Hyphens (-)
|
|
// - Dots (.) as separators
|
|
// - Optional wildcard prefix: *.
|
|
//
|
|
// Examples of valid domains:
|
|
// - example.com
|
|
// - sub.example.com
|
|
// - *.example.com
|
|
// - example.co.uk
|
|
//
|
|
// Returns an error if the domain contains invalid characters or is malformed.
|
|
func ValidateDomainName(domain string) error {
|
|
if domain == "" {
|
|
return fmt.Errorf("domain cannot be empty")
|
|
}
|
|
|
|
if len(domain) > 253 {
|
|
return fmt.Errorf("domain exceeds maximum length (253 characters)")
|
|
}
|
|
|
|
// Regular expression for RFC 1123 domain names with wildcard support
|
|
// Pattern explanation:
|
|
// ^(\*\.)? - Optional wildcard prefix
|
|
// ([a-zA-Z0-9](-?[a-zA-Z0-9])*\.)* - Subdomains (labels separated by dots)
|
|
// [a-zA-Z0-9](-?[a-zA-Z0-9])*$ - Top-level domain label
|
|
domainRegex := regexp.MustCompile(`^(\*\.)?([a-zA-Z0-9](-?[a-zA-Z0-9])*\.)*[a-zA-Z0-9](-?[a-zA-Z0-9])*$`)
|
|
|
|
if !domainRegex.MatchString(domain) {
|
|
return fmt.Errorf("domain %q is invalid (must match RFC 1123 format)", domain)
|
|
}
|
|
|
|
// Additional check: no double dots
|
|
if strings.Contains(domain, "..") {
|
|
return fmt.Errorf("domain %q contains consecutive dots", domain)
|
|
}
|
|
|
|
// Additional check: labels cannot start or end with hyphen
|
|
labels := strings.Split(domain, ".")
|
|
for _, label := range labels {
|
|
// Skip wildcard label
|
|
if label == "*" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
|
return fmt.Errorf("domain label %q cannot start or end with hyphen", label)
|
|
}
|
|
if len(label) > 63 {
|
|
return fmt.Errorf("domain label %q exceeds maximum length (63 characters)", label)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateACMEToken validates that an ACME token contains only safe characters.
|
|
// ACME tokens should contain only base64url-safe characters:
|
|
// - Alphanumeric (a-z, A-Z, 0-9)
|
|
// - Hyphens (-)
|
|
// - Underscores (_)
|
|
//
|
|
// This prevents injection attacks if tokens are used in shell commands
|
|
// or other contexts where special characters could be interpreted.
|
|
//
|
|
// Returns an error if the token contains unsafe characters.
|
|
func ValidateACMEToken(token string) error {
|
|
if token == "" {
|
|
return fmt.Errorf("ACME token cannot be empty")
|
|
}
|
|
|
|
if len(token) > 512 {
|
|
return fmt.Errorf("ACME token exceeds maximum length (512 characters)")
|
|
}
|
|
|
|
// Regular expression for base64url characters: [A-Za-z0-9_-]
|
|
tokenRegex := regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
|
|
|
if !tokenRegex.MatchString(token) {
|
|
return fmt.Errorf("ACME token contains invalid characters (must be base64url-safe)")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SanitizeForShell escapes a string to make it safe for use in shell commands.
|
|
// This is a defense-in-depth measure for cases where shell execution cannot be avoided.
|
|
//
|
|
// The sanitization wraps the string in single quotes and escapes any embedded
|
|
// single quotes by closing the quote, adding an escaped quote, and reopening.
|
|
// This prevents the string from being interpreted as shell code.
|
|
//
|
|
// Example: "hello'world" becomes "'hello'\"'\"'world'"
|
|
//
|
|
// Note: This should only be used as a last resort. Prefer alternatives such as:
|
|
// - Passing arguments directly to exec.Command instead of via shell
|
|
// - Using environment variables instead of shell substitution
|
|
// - Validating input strictly with ValidateShellCommand, ValidateDomainName, etc.
|
|
func SanitizeForShell(s string) string {
|
|
// Escape single quotes by closing the quote, adding an escaped quote, and reopening
|
|
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
|
|
}
|