mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 17:48:52 +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:
@@ -58,6 +58,36 @@ func (s *NetworkScanService) GetTarget(ctx context.Context, id string) (*domain.
|
||||
return s.networkScanRepo.Get(ctx, id)
|
||||
}
|
||||
|
||||
// maxCIDRHostBits is the maximum number of host bits allowed in a CIDR range.
|
||||
// A /20 network has 12 host bits = 4096 IPs max. This prevents operators from
|
||||
// accidentally creating scan targets that would exhaust server resources.
|
||||
const maxCIDRHostBits = 12
|
||||
|
||||
// validateCIDRs validates a list of CIDRs for syntax correctness and size limits.
|
||||
// Each CIDR must be a valid CIDR notation or plain IP address, and no single CIDR
|
||||
// may be larger than /20 (4096 IPs). This validation runs at API request time so
|
||||
// operators get an immediate 400 error instead of a silent truncation at scan time.
|
||||
func validateCIDRs(cidrs []string) error {
|
||||
for _, cidr := range cidrs {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
// Try parsing as plain IP (single host)
|
||||
if ip := net.ParseIP(cidr); ip == nil {
|
||||
return fmt.Errorf("invalid CIDR or IP: %s", cidr)
|
||||
}
|
||||
continue // Single IPs are always valid size
|
||||
}
|
||||
// Enforce /20 size cap at API level
|
||||
ones, bits := ipNet.Mask.Size()
|
||||
hostBits := bits - ones
|
||||
if hostBits > maxCIDRHostBits {
|
||||
return fmt.Errorf("CIDR %s is too large (/%d has %d host bits, max /%d with %d host bits = 4096 IPs)",
|
||||
cidr, ones, hostBits, bits-maxCIDRHostBits, maxCIDRHostBits)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTarget creates a new network scan target.
|
||||
func (s *NetworkScanService) CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
|
||||
if target.Name == "" {
|
||||
@@ -66,14 +96,9 @@ func (s *NetworkScanService) CreateTarget(ctx context.Context, target *domain.Ne
|
||||
if len(target.CIDRs) == 0 {
|
||||
return nil, fmt.Errorf("at least one CIDR is required")
|
||||
}
|
||||
// Validate CIDRs
|
||||
for _, cidr := range target.CIDRs {
|
||||
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||
// Try parsing as plain IP
|
||||
if ip := net.ParseIP(cidr); ip == nil {
|
||||
return nil, fmt.Errorf("invalid CIDR or IP: %s", cidr)
|
||||
}
|
||||
}
|
||||
// Validate CIDRs (syntax + /20 size cap)
|
||||
if err := validateCIDRs(target.CIDRs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(target.Ports) == 0 {
|
||||
target.Ports = []int64{443}
|
||||
@@ -115,13 +140,9 @@ func (s *NetworkScanService) UpdateTarget(ctx context.Context, id string, target
|
||||
existing.Name = target.Name
|
||||
}
|
||||
if len(target.CIDRs) > 0 {
|
||||
// Validate new CIDRs
|
||||
for _, cidr := range target.CIDRs {
|
||||
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||
if ip := net.ParseIP(cidr); ip == nil {
|
||||
return nil, fmt.Errorf("invalid CIDR or IP: %s", cidr)
|
||||
}
|
||||
}
|
||||
// Validate new CIDRs (syntax + /20 size cap)
|
||||
if err := validateCIDRs(target.CIDRs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing.CIDRs = target.CIDRs
|
||||
}
|
||||
|
||||
@@ -391,6 +391,92 @@ func TestExpandCIDR_AllowsPrivateRanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// AUDIT-003: CIDR size validation at API level
|
||||
|
||||
func TestValidateCIDRs_AcceptsValidSizes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cidrs []string
|
||||
}{
|
||||
{"single IP", []string{"192.168.1.1"}},
|
||||
{"/24 network", []string{"10.0.0.0/24"}},
|
||||
{"/20 network (max)", []string{"10.0.0.0/20"}},
|
||||
{"/30 tiny network", []string{"10.0.0.0/30"}},
|
||||
{"multiple valid", []string{"10.0.0.0/24", "192.168.1.0/24"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateCIDRs(tt.cidrs)
|
||||
if err != nil {
|
||||
t.Errorf("expected valid CIDRs to be accepted, got error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCIDRs_RejectsOversized(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cidrs []string
|
||||
}{
|
||||
{"/19 too large", []string{"10.0.0.0/19"}},
|
||||
{"/16 way too large", []string{"10.0.0.0/16"}},
|
||||
{"/8 massive", []string{"10.0.0.0/8"}},
|
||||
{"/0 everything", []string{"0.0.0.0/0"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateCIDRs(tt.cidrs)
|
||||
if err == nil {
|
||||
t.Errorf("expected oversized CIDR %v to be rejected", tt.cidrs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCIDRs_RejectsInvalid(t *testing.T) {
|
||||
err := validateCIDRs([]string{"not-a-cidr"})
|
||||
if err == nil {
|
||||
t.Error("expected invalid CIDR to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkScanService_CreateTarget_RejectsOversizedCIDR(t *testing.T) {
|
||||
repo := &mockNetworkScanRepo{}
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
svc := NewNetworkScanService(repo, nil, auditService, nil)
|
||||
|
||||
_, err := svc.CreateTarget(context.Background(), &domain.NetworkScanTarget{
|
||||
Name: "Test",
|
||||
CIDRs: []string{"10.0.0.0/8"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected CreateTarget to reject /8 CIDR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkScanService_UpdateTarget_RejectsOversizedCIDR(t *testing.T) {
|
||||
repo := &mockNetworkScanRepo{
|
||||
targets: []*domain.NetworkScanTarget{
|
||||
{ID: "nst-1", Name: "Original", CIDRs: []string{"10.0.0.0/24"}, Enabled: true},
|
||||
},
|
||||
}
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
svc := NewNetworkScanService(repo, nil, auditService, nil)
|
||||
|
||||
// Try to update from /24 to /8 — should be rejected
|
||||
_, err := svc.UpdateTarget(context.Background(), "nst-1", &domain.NetworkScanTarget{
|
||||
CIDRs: []string{"10.0.0.0/8"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected UpdateTarget to reject /8 CIDR update (bypass attempt)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandCIDR_SingleLoopbackIP(t *testing.T) {
|
||||
ips := expandCIDR("127.0.0.1")
|
||||
if len(ips) != 0 {
|
||||
|
||||
Reference in New Issue
Block a user