Security Hardening Plan
Current State Analysis
Issues Identified:
- No input validation middleware
- API keys in plain text
- No rate limiting per key
- No audit logging
- Secrets in configuration files
- No CSP headers for management UI
- Missing security headers
- No SAST/DAST in CI
Phase 1: Input Validation & Sanitization (Week 1)
1.1 Validation Middleware
File: internal/api/middleware/validation.go
package middleware
import (
"bytes"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
var validate = validator.New()
// RequestValidator validates incoming requests
type RequestValidator struct {
maxBodySize int64
}
func NewRequestValidator(maxBodySize int64) *RequestValidator {
return &RequestValidator{maxBodySize: maxBodySize}
}
func (v *RequestValidator) Validate() gin.HandlerFunc {
return func(c *gin.Context) {
// Check content type for POST/PUT/PATCH
if c.Request.Method != "GET" && c.Request.Method != "DELETE" {
contentType := c.ContentType()
if contentType != "application/json" {
c.AbortWithStatusJSON(http.StatusUnsupportedMediaType, gin.H{
"error": "Content-Type must be application/json",
})
return
}
}
// Limit body size
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, v.maxBodySize)
c.Next()
}
}
// SanitizeHeaders removes sensitive headers from logging
type SanitizedHeader string
const (
Authorization SanitizedHeader = "Authorization"
Cookie SanitizedHeader = "Cookie"
SetCookie SanitizedHeader = "Set-Cookie"
)
func (v *RequestValidator) SanitizeHeaders(c *gin.Context) {
// Create copy of headers with sensitive data redacted
headers := make(http.Header)
for k, values := range c.Request.Header {
if strings.EqualFold(k, "Authorization") ||
strings.EqualFold(k, "Cookie") ||
strings.EqualFold(k, "X-API-Key") {
headers.Set(k, "[REDACTED]")
} else {
headers[k] = values
}
}
c.Set("sanitized_headers", headers)
}
1.2 SQL Injection Prevention
File: internal/infrastructure/persistence/sanitize.go
package persistence
import (
"regexp"
"strings"
)
var sqlInjectionPattern = regexp.MustCompile(`(?i)(union|select|insert|delete|update|drop|create|alter|exec|execute|;|--|/\*|\*/)`)
// SanitizeIdentifier sanitizes database identifiers (table names, column names)
func SanitizeIdentifier(identifier string) string {
// Remove any characters that aren't alphanumeric or underscore
clean := regexp.MustCompile(`[^a-zA-Z0-9_]`).ReplaceAllString(identifier, "")
// Check for SQL keywords
if sqlInjectionPattern.MatchString(clean) {
return ""
}
return clean
}
// ValidateOrderBy validates ORDER BY clause parameters
func ValidateOrderBy(field, direction string, allowedFields []string) (string, error) {
// Validate field is in allowed list
validField := false
for _, f := range allowedFields {
if strings.EqualFold(f, field) {
validField = true
break
}
}
if !validField {
return "", fmt.Errorf("invalid sort field: %s", field)
}
// Validate direction
direction = strings.ToUpper(direction)
if direction != "ASC" && direction != "DESC" {
direction = "ASC"
}
return fmt.Sprintf("%s %s", SanitizeIdentifier(field), direction), nil
}
Phase 2: Rate Limiting (Skipped)
This phase has been skipped as per user request (single user environment).
Phase 3: Secrets Management (Week 2)
3.1 Secrets Provider Interface
File: internal/infrastructure/secrets/provider.go
package secrets
import (
"context"
"fmt"
)
// Provider interface for secrets management
type Provider interface {
Get(ctx context.Context, key string) (string, error)
GetJSON(ctx context.Context, key string, v interface{}) error
Close() error
}
// Config for secrets provider
type Config struct {
Type string // vault, aws, gcp, azure, env
Vault *VaultConfig
AWS *AWSConfig
GCP *GCPConfig
Azure *AzureConfig
}
type VaultConfig struct {
Address string
Token string
Path string
}
type AWSConfig struct {
Region string
ARN string
}
3.2 HashiCorp Vault Implementation
File: internal/infrastructure/secrets/vault.go
package secrets
import (
"context"
"encoding/json"
"fmt"
vault "github.com/hashicorp/vault/api"
)
type VaultProvider struct {
client *vault.Client
path string
}
func NewVaultProvider(cfg *VaultConfig) (*VaultProvider, error) {
config := vault.DefaultConfig()
config.Address = cfg.Address
client, err := vault.NewClient(config)
if err != nil {
return nil, fmt.Errorf("create vault client: %w", err)
}
client.SetToken(cfg.Token)
return &VaultProvider{
client: client,
path: cfg.Path,
}, nil
}
func (v *VaultProvider) Get(ctx context.Context, key string) (string, error) {
secret, err := v.client.KVv2(v.path).Get(ctx, key)
if err != nil {
return "", fmt.Errorf("get secret %s: %w", key, err)
}
value, ok := secret.Data["value"].(string)
if !ok {
return "", fmt.Errorf("secret %s has no string value", key)
}
return value, nil
}
func (v *VaultProvider) GetJSON(ctx context.Context, key string, v interface{}) error {
secret, err := v.client.KVv2(v.path).Get(ctx, key)
if err != nil {
return err
}
data, err := json.Marshal(secret.Data)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
func (v *VaultProvider) Close() error {
return nil
}
3.3 Environment Variable Provider (Default)
File: internal/infrastructure/secrets/env.go
package secrets
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
)
// EnvProvider reads secrets from environment variables
type EnvProvider struct {
prefix string
}
func NewEnvProvider(prefix string) *EnvProvider {
return &EnvProvider{prefix: prefix}
}
func (e *EnvProvider) Get(ctx context.Context, key string) (string, error) {
envKey := e.prefix + strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
value := os.Getenv(envKey)
if value == "" {
return "", fmt.Errorf("environment variable %s not set", envKey)
}
return value, nil
}
func (e *EnvProvider) GetJSON(ctx context.Context, key string, v interface{}) error {
value, err := e.Get(ctx, key)
if err != nil {
return err
}
return json.Unmarshal([]byte(value), v)
}
func (e *EnvProvider) Close() error {
return nil
}
Phase 4: Security Headers (Week 2)
4.1 Security Headers Middleware
File: internal/api/middleware/security.go
package middleware
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
// Prevent clickjacking
c.Header("X-Frame-Options", "DENY")
// Prevent MIME type sniffing
c.Header("X-Content-Type-Options", "nosniff")
// XSS Protection
c.Header("X-XSS-Protection", "1; mode=block")
// Referrer Policy
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// Permissions Policy
c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
// HSTS (HTTPS only)
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
// CSP for API (restrictive)
if !strings.HasPrefix(c.Request.URL.Path, "/management") {
c.Header("Content-Security-Policy", "default-src 'none'")
}
c.Next()
}
}
// CSP for Management UI
func ManagementCSP() gin.HandlerFunc {
return func(c *gin.Context) {
csp := strings.Join([]string{
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
}, "; ")
c.Header("Content-Security-Policy", csp)
c.Next()
}
}
// CORS configuration
func CORS(allowedOrigins []string) gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")
// Check if origin is allowed
allowed := false
for _, o := range allowedOrigins {
if o == "*" || o == origin {
allowed = true
break
}
}
if allowed {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Max-Age", "86400")
}
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
Phase 5: Audit Logging (Week 3)
5.1 Audit Logger
File: internal/infrastructure/audit/logger.go
package audit
import (
"context"
"encoding/json"
"time"
)
// EventType represents types of audit events
type EventType string
const (
EventAuthCreated EventType = "auth.created"
EventAuthDeleted EventType = "auth.deleted"
EventAuthRefreshed EventType = "auth.refreshed"
EventConfigChanged EventType = "config.changed"
EventAPICall EventType = "api.call"
EventLoginSuccess EventType = "login.success"
EventLoginFailure EventType = "login.failure"
EventTokenRevoked EventType = "token.revoked"
)
// Severity level
type Severity string
const (
SeverityInfo Severity = "info"
SeverityWarning Severity = "warning"
SeverityCritical Severity = "critical"
)
// Event represents an audit log entry
type Event struct {
Timestamp time.Time `json:"timestamp"`
Type EventType `json:"type"`
Severity Severity `json:"severity"`
Actor Actor `json:"actor"`
Resource Resource `json:"resource"`
Action string `json:"action"`
Result string `json:"result"` // success, failure
Details map[string]interface{} `json:"details,omitempty"`
RequestID string `json:"request_id"`
IP string `json:"ip"`
UserAgent string `json:"user_agent,omitempty"`
}
type Actor struct {
ID string `json:"id"`
Type string `json:"type"` // user, api_key, system
}
type Resource struct {
Type string `json:"type"`
ID string `json:"id"`
}
// Logger interface for audit events
type Logger interface {
Log(ctx context.Context, event Event) error
}
// FileLogger writes audit events to file
type FileLogger struct {
writer io.Writer
mu sync.Mutex
}
func NewFileLogger(path string) (*FileLogger, error) {
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return nil, err
}
return &FileLogger{writer: file}, nil
}
func (l *FileLogger) Log(ctx context.Context, event Event) error {
event.Timestamp = time.Now().UTC()
if reqID := ctx.Value("request_id"); reqID != nil {
event.RequestID = reqID.(string)
}
data, err := json.Marshal(event)
if err != nil {
return err
}
l.mu.Lock()
defer l.mu.Unlock()
_, err = fmt.Fprintf(l.writer, "%s\n", data)
return err
}
// Audit middleware
func Middleware(logger Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
// Log API calls
if c.Request.URL.Path != "/health" && c.Request.URL.Path != "/metrics" {
severity := SeverityInfo
result := "success"
if c.Writer.Status() >= 500 {
severity = SeverityCritical
result = "failure"
} else if c.Writer.Status() >= 400 {
severity = SeverityWarning
result = "failure"
}
logger.Log(c.Request.Context(), Event{
Type: EventAPICall,
Severity: severity,
Actor: Actor{
ID: c.GetHeader("X-API-Key"),
Type: "api_key",
},
Resource: Resource{
Type: "endpoint",
ID: c.Request.Method + " " + c.FullPath(),
},
Action: c.Request.Method,
Result: result,
Details: map[string]interface{}{
"status_code": c.Writer.Status(),
"duration_ms": time.Since(start).Milliseconds(),
},
IP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
})
}
}
}
Phase 6: SAST/DAST Integration (Week 3)
6.1 GitHub Actions Security Workflow
File: .github/workflows/security.yml
name: Security
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
schedule:
- cron: '0 0 * * 0' # Weekly
jobs:
gosec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Gosec
uses: securego/gosec@master
with:
args: '-fmt sarif -out results.sarif ./...'
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: results.sarif
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
run: govulncheck ./...
trivy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t cliproxy:test .
- name: Run Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: 'cliproxy:test'
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: trivy-results.sarif
dependency-review:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: actions/dependency-review-action@v3
Success Metrics
- All secrets externalized from config files
- Zero high/critical vulnerabilities in SAST scans
- Rate limiting on all public endpoints
- Complete audit trail for auth events
- Security headers on all responses
- Input validation on all user inputs
- Dependency vulnerabilities tracked and remediated
Security Checklist
- API keys are hashed in storage
- TLS 1.3 required for all connections
- No secrets in logs
- SQL injection prevention in all queries
- XSS prevention in management UI
- CSRF tokens for state-changing operations
- Request size limits enforced
- IP allowlisting for management endpoints