API / plans /security-hardening.md
sshinmen's picture
Update HF Spaces deployment with latest changes
2e6b65c
|
Raw
History Blame Contribute Delete
14.4 kB

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