fix: security audit remediation (AUDIT-001, 003, 004, 005, 006, 018)

- AUDIT-001: Validate OpenSSL revoke inputs (hex-only serials, RFC 5280 reasons)
- AUDIT-003: Enforce /20 CIDR size cap at API level (create + update)
- AUDIT-004: Support comma-separated CERTCTL_AUTH_SECRET for zero-downtime key rotation
- AUDIT-005: Add ReadHeaderTimeout (5s) to prevent Slowloris
- AUDIT-006: Document audit trail query parameter exclusion rationale
- AUDIT-018: Add immediate-run-on-start to short-lived expiry scheduler loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-28 14:11:16 -04:00
parent 591dcfb139
commit 6d508cf53f
15 changed files with 595 additions and 34 deletions
@@ -32,9 +32,12 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/validation"
)
// Config represents the OpenSSL/Custom CA issuer connector configuration.
@@ -258,6 +261,36 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
return result, nil
}
// hexSerialRegex validates that a serial number contains only hexadecimal characters.
// Certificate serial numbers are integers represented in hex (RFC 5280).
var hexSerialRegex = regexp.MustCompile(`^[0-9a-fA-F]+$`)
// validateSerial validates a certificate serial number for safe use in shell commands.
// Serial numbers must be non-empty, hex-only strings with no shell metacharacters.
func validateSerial(serial string) error {
if serial == "" {
return fmt.Errorf("serial number cannot be empty")
}
if !hexSerialRegex.MatchString(serial) {
return fmt.Errorf("serial number %q contains non-hex characters (expected ^[0-9a-fA-F]+$)", serial)
}
if err := validation.ValidateShellCommand(serial); err != nil {
return fmt.Errorf("serial number failed shell safety validation: %w", err)
}
return nil
}
// validateRevocationReason validates a revocation reason against RFC 5280 reason codes.
func validateRevocationReason(reason string) error {
if !domain.IsValidRevocationReason(reason) {
return fmt.Errorf("invalid revocation reason %q (must be a valid RFC 5280 reason code)", reason)
}
if err := validation.ValidateShellCommand(reason); err != nil {
return fmt.Errorf("revocation reason failed shell safety validation: %w", err)
}
return nil
}
// RevokeCertificate revokes a certificate by calling the revoke script if configured.
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
if c.config.RevokeScript == "" {
@@ -270,6 +303,14 @@ func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.Revoca
reason = *request.Reason
}
// Validate serial number (hex-only) and reason code (RFC 5280) before shell execution
if err := validateSerial(request.Serial); err != nil {
return fmt.Errorf("revocation input validation failed: %w", err)
}
if err := validateRevocationReason(reason); err != nil {
return fmt.Errorf("revocation input validation failed: %w", err)
}
c.logger.Info("revoking certificate via revoke script",
"serial", request.Serial,
"reason", reason)
@@ -289,7 +289,7 @@ func TestOpenSSLConnector(t *testing.T) {
}
revokeReq := issuer.RevocationRequest{
Serial: "test-serial-12345",
Serial: "ABCDEF1234567890",
}
// Should return nil (no-op) when revoke script not configured
@@ -324,8 +324,10 @@ func TestOpenSSLConnector(t *testing.T) {
t.Fatalf("ValidateConfig failed: %v", err)
}
reason := "keyCompromise"
revokeReq := issuer.RevocationRequest{
Serial: "test-serial-12345",
Serial: "ABCDEF1234567890",
Reason: &reason,
}
err := connector.RevokeCertificate(ctx, revokeReq)
@@ -334,6 +336,139 @@ func TestOpenSSLConnector(t *testing.T) {
}
})
// Test 15: RevokeCertificate rejects injection payloads in serial number
t.Run("RevokeCertificate_InjectionSerial", func(t *testing.T) {
tmpDir := t.TempDir()
signScript := filepath.Join(tmpDir, "sign.sh")
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
revokeScript := filepath.Join(tmpDir, "revoke.sh")
if err := os.WriteFile(revokeScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create revoke script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
RevokeScript: revokeScript,
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
injectionPayloads := []string{
"1234;rm -rf /",
"1234|cat /etc/passwd",
"1234&whoami",
"$(id)",
"`id`",
"1234\nid",
"../../../etc/passwd",
"test-serial-12345", // hyphens not allowed (not hex)
}
for _, payload := range injectionPayloads {
t.Run(payload, func(t *testing.T) {
req := issuer.RevocationRequest{Serial: payload}
err := connector.RevokeCertificate(ctx, req)
if err == nil {
t.Errorf("Expected injection payload %q to be rejected, but it was accepted", payload)
}
})
}
})
// Test 16: RevokeCertificate rejects invalid reason codes
t.Run("RevokeCertificate_InvalidReason", func(t *testing.T) {
tmpDir := t.TempDir()
signScript := filepath.Join(tmpDir, "sign.sh")
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
revokeScript := filepath.Join(tmpDir, "revoke.sh")
if err := os.WriteFile(revokeScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create revoke script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
RevokeScript: revokeScript,
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
invalidReasons := []string{
"notARealReason",
"keyCompromise;rm -rf /",
"$(whoami)",
"`id`",
}
for _, reason := range invalidReasons {
t.Run(reason, func(t *testing.T) {
r := reason
req := issuer.RevocationRequest{
Serial: "ABCDEF1234567890",
Reason: &r,
}
err := connector.RevokeCertificate(ctx, req)
if err == nil {
t.Errorf("Expected invalid reason %q to be rejected, but it was accepted", reason)
}
})
}
})
// Test 17: RevokeCertificate accepts all valid RFC 5280 reason codes
t.Run("RevokeCertificate_ValidReasons", func(t *testing.T) {
tmpDir := t.TempDir()
signScript := filepath.Join(tmpDir, "sign.sh")
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
revokeScript := filepath.Join(tmpDir, "revoke.sh")
if err := os.WriteFile(revokeScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create revoke script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
RevokeScript: revokeScript,
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
validReasons := []string{
"unspecified", "keyCompromise", "caCompromise", "affiliationChanged",
"superseded", "cessationOfOperation", "certificateHold", "privilegeWithdrawn",
}
for _, reason := range validReasons {
t.Run(reason, func(t *testing.T) {
r := reason
req := issuer.RevocationRequest{
Serial: "ABCDEF1234567890",
Reason: &r,
}
err := connector.RevokeCertificate(ctx, req)
if err != nil {
t.Errorf("Expected valid reason %q to be accepted, got error: %v", reason, err)
}
})
}
})
// Test 10: GetOrderStatus always returns "completed"
t.Run("GetOrderStatus", func(t *testing.T) {
tmpDir := t.TempDir()