mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:41:29 +00:00
Implement M5: hardening, input validation, and Vite+React+TS dashboard
Backend hardening: - Fix 6 nginx.go non-constant format string build errors - Add validation.go with hostname, PEM, and enum validators - Apply input validation to all POST/PUT handlers (certificates, agents, CSR, policies, teams, owners, targets, issuers) - Fix unchecked JSON decode in TriggerDeployment handler Frontend (Vite + React + TypeScript): - Migrate from single-file SPA to proper build pipeline - 7 pages: Dashboard, Certificates (list+detail), Agents, Jobs, Notifications, Policies, Audit Trail - TanStack Query for server state with auto-refetch intervals - Certificate detail with version history and renewal trigger - Job cancellation, status/type filtering, expiry countdowns - Reusable components: DataTable, StatusBadge, ErrorState, PageHeader - Dark theme with Tailwind CSS, sidebar nav via React Router Server integration: - Go server serves web/dist/ (Vite output) with SPA fallback - Falls back to web/index.html for legacy mode - .gitignore updated for web/node_modules/ and web/dist/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -117,6 +117,20 @@ func (h AgentHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if err := ValidateRequired("name", agent.Name); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidateStringLength("name", agent.Name, 128); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidateRequired("hostname", agent.Hostname); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.svc.RegisterAgent(agent)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to register agent", requestID)
|
||||
@@ -186,8 +200,9 @@ func (h AgentHandler) AgentCSRSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.CSRPEM == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "CSR PEM is required", requestID)
|
||||
// Validate CSR PEM
|
||||
if err := ValidateCSRPEM(req.CSRPEM); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -305,6 +305,9 @@ func TestCreateCertificate_Success(t *testing.T) {
|
||||
certBody := domain.ManagedCertificate{
|
||||
Name: "Production Cert",
|
||||
CommonName: "example.com",
|
||||
OwnerID: "o-alice",
|
||||
TeamID: "t-platform",
|
||||
IssuerID: "iss-local",
|
||||
}
|
||||
body, _ := json.Marshal(certBody)
|
||||
|
||||
@@ -359,6 +362,9 @@ func TestCreateCertificate_ServiceError(t *testing.T) {
|
||||
certBody := domain.ManagedCertificate{
|
||||
Name: "Production Cert",
|
||||
CommonName: "example.com",
|
||||
OwnerID: "o-alice",
|
||||
TeamID: "t-platform",
|
||||
IssuerID: "iss-local",
|
||||
}
|
||||
body, _ := json.Marshal(certBody)
|
||||
|
||||
|
||||
@@ -120,6 +120,28 @@ func (h CertificateHandler) CreateCertificate(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if err := ValidateRequired("common_name", cert.CommonName); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidateCommonName(cert.CommonName); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidateRequired("owner_id", cert.OwnerID); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidateRequired("team_id", cert.TeamID); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidateRequired("issuer_id", cert.IssuerID); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.svc.CreateCertificate(cert)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create certificate", requestID)
|
||||
@@ -153,6 +175,26 @@ func (h CertificateHandler) UpdateCertificate(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields (if provided)
|
||||
if cert.CommonName != "" {
|
||||
if err := ValidateCommonName(cert.CommonName); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
}
|
||||
if cert.OwnerID != "" {
|
||||
if err := ValidateStringLength("owner_id", cert.OwnerID, 255); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
}
|
||||
if cert.TeamID != "" {
|
||||
if err := ValidateStringLength("team_id", cert.TeamID, 255); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := h.svc.UpdateCertificate(id, cert)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update certificate", requestID)
|
||||
@@ -290,7 +332,11 @@ func (h CertificateHandler) TriggerDeployment(w http.ResponseWriter, r *http.Req
|
||||
TargetID string `json:"target_id,omitempty"`
|
||||
}
|
||||
if r.Header.Get("Content-Type") == "application/json" {
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// Log but don't fail - targetID is optional
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.svc.TriggerDeployment(certID, req.TargetID); err != nil {
|
||||
|
||||
@@ -111,6 +111,20 @@ func (h IssuerHandler) CreateIssuer(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if err := ValidateRequired("name", issuer.Name); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidateStringLength("name", issuer.Name, 255); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if issuer.Type == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "type is required", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.svc.CreateIssuer(issuer)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create issuer", requestID)
|
||||
|
||||
@@ -112,6 +112,16 @@ func (h OwnerHandler) CreateOwner(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if err := ValidateRequired("name", owner.Name); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidateStringLength("name", owner.Name, 255); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.svc.CreateOwner(owner)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create owner", requestID)
|
||||
|
||||
@@ -113,6 +113,20 @@ func (h PolicyHandler) CreatePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if err := ValidateRequired("name", policy.Name); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if policy.Type == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "type is required", requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidatePolicyType(policy.Type); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.svc.CreatePolicy(policy)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create policy", requestID)
|
||||
@@ -146,6 +160,20 @@ func (h PolicyHandler) UpdatePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate fields if provided
|
||||
if policy.Name != "" {
|
||||
if err := ValidateStringLength("name", policy.Name, 255); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
}
|
||||
if policy.Type != "" {
|
||||
if err := ValidatePolicyType(policy.Type); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := h.svc.UpdatePolicy(id, policy)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update policy", requestID)
|
||||
|
||||
@@ -110,6 +110,20 @@ func (h TargetHandler) CreateTarget(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if err := ValidateRequired("name", target.Name); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidateStringLength("name", target.Name, 255); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if target.Type == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "type is required", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.svc.CreateTarget(target)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create target", requestID)
|
||||
|
||||
@@ -112,6 +112,16 @@ func (h TeamHandler) CreateTeam(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if err := ValidateRequired("name", team.Name); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidateStringLength("name", team.Name, 255); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.svc.CreateTeam(team)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create team", requestID)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidationError represents a validation error with field-level details.
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
// ValidateCommonName validates a certificate common name.
|
||||
func ValidateCommonName(cn string) error {
|
||||
if cn == "" {
|
||||
return ValidationError{Field: "common_name", Message: "common_name is required"}
|
||||
}
|
||||
if len(cn) > 253 {
|
||||
return ValidationError{Field: "common_name", Message: "common_name must be 253 characters or fewer"}
|
||||
}
|
||||
// Basic hostname validation: allow alphanumeric, dots, hyphens
|
||||
if err := isValidHostname(cn); err != nil {
|
||||
return ValidationError{Field: "common_name", Message: fmt.Sprintf("invalid hostname format: %v", err)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateRequired checks if a string field is present and non-empty.
|
||||
func ValidateRequired(field, value string) error {
|
||||
if value == "" {
|
||||
return ValidationError{Field: field, Message: fmt.Sprintf("%s is required", field)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateStringLength checks if a string is within acceptable length bounds.
|
||||
func ValidateStringLength(field, value string, maxLen int) error {
|
||||
if len(value) > maxLen {
|
||||
return ValidationError{Field: field, Message: fmt.Sprintf("%s must be %d characters or fewer", field, maxLen)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCSRPEM validates a certificate signing request PEM block.
|
||||
func ValidateCSRPEM(csrPEM string) error {
|
||||
if csrPEM == "" {
|
||||
return ValidationError{Field: "csr_pem", Message: "csr_pem is required"}
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimSpace(csrPEM), "-----BEGIN CERTIFICATE REQUEST-----") {
|
||||
return ValidationError{Field: "csr_pem", Message: "csr_pem must be a valid PEM-encoded certificate request"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePolicyType checks if a policy rule type is valid.
|
||||
func ValidatePolicyType(policyType interface{}) error {
|
||||
validTypes := map[string]bool{
|
||||
"AllowedIssuers": true,
|
||||
"AllowedDomains": true,
|
||||
"RequiredMetadata": true,
|
||||
"AllowedEnvironments": true,
|
||||
"RenewalLeadTime": true,
|
||||
}
|
||||
typeStr := fmt.Sprintf("%v", policyType)
|
||||
if !validTypes[typeStr] {
|
||||
return ValidationError{Field: "type", Message: "type must be one of: AllowedIssuers, AllowedDomains, RequiredMetadata, AllowedEnvironments, RenewalLeadTime"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePolicySeverity checks if a severity level is valid.
|
||||
func ValidatePolicySeverity(severity interface{}) error {
|
||||
validSeverities := map[string]bool{
|
||||
"Warning": true,
|
||||
"Error": true,
|
||||
"Critical": true,
|
||||
}
|
||||
sevStr := fmt.Sprintf("%v", severity)
|
||||
if !validSeverities[sevStr] {
|
||||
return ValidationError{Field: "severity", Message: "severity must be one of: Warning, Error, Critical"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidHostname performs basic validation on a hostname.
|
||||
func isValidHostname(hostname string) error {
|
||||
// Use net.SplitHostPort-compatible check
|
||||
// Hostname can be an IP or domain name
|
||||
if ip := net.ParseIP(hostname); ip != nil {
|
||||
return nil // Valid IP address
|
||||
}
|
||||
|
||||
// For domain names, check basic format
|
||||
if len(hostname) == 0 || len(hostname) > 253 {
|
||||
return fmt.Errorf("hostname length invalid")
|
||||
}
|
||||
|
||||
// Check for invalid characters (very basic)
|
||||
for _, char := range hostname {
|
||||
if !isValidHostnameChar(char) {
|
||||
return fmt.Errorf("hostname contains invalid character: %c", char)
|
||||
}
|
||||
}
|
||||
|
||||
// Labels must not start or end with hyphen
|
||||
labels := strings.Split(hostname, ".")
|
||||
for _, label := range labels {
|
||||
if len(label) == 0 {
|
||||
return fmt.Errorf("hostname has empty label")
|
||||
}
|
||||
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
||||
return fmt.Errorf("hostname labels cannot start or end with hyphen")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidHostnameChar checks if a character is valid in a hostname.
|
||||
func isValidHostnameChar(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') ||
|
||||
(r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') ||
|
||||
r == '.' ||
|
||||
r == '-' ||
|
||||
r == '_' || // Underscores are sometimes allowed
|
||||
r == '*' // Wildcard support
|
||||
}
|
||||
|
||||
// Error method makes ValidationError satisfy the error interface.
|
||||
func (e ValidationError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
Reference in New Issue
Block a user