mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 13:08:55 +00:00
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user