Files
Charon/docs/plans/current_spec.md
GitHub Actions 72537c3bb4 feat: add security header profiles to bulk apply
Add support for bulk applying or removing security header profiles from multiple proxy hosts simultaneously via the Bulk Apply modal.

Features:
- New bulk endpoint: PUT /api/v1/proxy-hosts/bulk-update-security-headers
- Transaction-safe updates with single Caddy config reload
- Grouped profile selection (System/Custom profiles)
- Partial failure handling with detailed error reporting
- Support for profile removal via "None" option
- Full i18n support (en, de, es, fr, zh)

Backend:
- Add BulkUpdateSecurityHeaders handler with validation
- Add DB() getter to ProxyHostService
- 9 unit tests, 82.3% coverage

Frontend:
- Extend Bulk Apply modal with security header section
- Add bulkUpdateSecurityHeaders API function
- Add useBulkUpdateSecurityHeaders mutation hook
- 8 unit tests, 87.24% coverage

Testing:
- All tests passing (Backend + Frontend)
- Zero TypeScript errors
- Zero security vulnerabilities (Trivy + govulncheck)
- Pre-commit hooks passing
- No regressions

Docs:
- Update CHANGELOG.md
- Update docs/features.md with bulk workflow
2025-12-20 15:19:06 +00:00

23 KiB

Implementation Plan: Add HTTP Headers to Bulk Apply Feature

Overview

Feature Description

Extend the existing Bulk Apply feature on the Proxy Hosts page to allow users to assign Security Header Profiles to multiple proxy hosts simultaneously. This enhancement enables administrators to efficiently apply consistent security header configurations across their infrastructure without editing each host individually.

User Benefit

  • Time Savings: Apply security header profiles to 10, 50, or 100+ hosts in a single operation
  • Consistency: Ensure uniform security posture across all proxy hosts
  • Compliance: Quickly remediate security gaps by bulk-applying strict security profiles
  • Workflow Efficiency: Integrates seamlessly with existing Bulk Apply modal (Force SSL, HTTP/2, HSTS, etc.)

Scope of Changes

Area Scope
Frontend Modify 4-5 files (ProxyHosts page, helpers, API, translations)
Backend Modify 2 files (handler, possibly add new endpoint)
Database No schema changes required (uses existing security_header_profile_id field)
Tests Add unit tests for frontend and backend

A. Current Implementation Analysis

Existing Bulk Apply Architecture

The Bulk Apply feature currently supports these boolean settings:

  • ssl_forced - Force SSL
  • http2_support - HTTP/2 Support
  • hsts_enabled - HSTS Enabled
  • hsts_subdomains - HSTS Subdomains
  • block_exploits - Block Exploits
  • websocket_support - Websockets Support
  • enable_standard_headers - Standard Proxy Headers

Key Files:

File Purpose
frontend/src/pages/ProxyHosts.tsx Main page with Bulk Apply modal (L60-67 defines bulkApplySettings state)
frontend/src/utils/proxyHostsHelpers.ts Helper functions: formatSettingLabel(), settingHelpText(), settingKeyToField(), applyBulkSettingsToHosts()
frontend/src/api/proxyHosts.ts API client with updateProxyHost() for individual updates
frontend/src/hooks/useProxyHosts.ts React Query hook with updateHost() mutation

How Bulk Apply Currently Works

  1. User selects multiple hosts using checkboxes in the DataTable
  2. User clicks "Bulk Apply" button → opens modal
  3. Modal shows all available settings with checkboxes (apply/don't apply) and toggles (on/off)
  4. User clicks "Apply" → applyBulkSettingsToHosts() iterates over selected hosts
  5. For each host, it calls updateHost(uuid, mergedData) which triggers PUT /api/v1/proxy-hosts/{uuid}
  6. Backend updates the host and applies Caddy config

Existing Security Header Profile Implementation

Frontend:

File Purpose
frontend/src/api/securityHeaders.ts API client for security header profiles
frontend/src/hooks/useSecurityHeaders.ts React Query hooks including useSecurityHeaderProfiles()
frontend/src/components/ProxyHostForm.tsx Individual host form with Security Header Profile dropdown (L550-620)

Backend:

File Purpose
backend/internal/models/proxy_host.go SecurityHeaderProfileID *uint field (L38)
backend/internal/api/handlers/proxy_host_handler.go Update() handler parses security_header_profile_id (L253-286)
backend/internal/models/security_header_profile.go Profile model with all header configurations

B. Frontend Changes

B.1. Modify ProxyHosts.tsx

File: frontend/src/pages/ProxyHosts.tsx

B.1.1. Add Security Header Profile Selection State

Location: After bulkApplySettings state definition (around L67)

// Existing state (L60-67)
const [bulkApplySettings, setBulkApplySettings] = useState<Record<string, { apply: boolean; value: boolean }>>({
  ssl_forced: { apply: false, value: true },
  http2_support: { apply: false, value: true },
  hsts_enabled: { apply: false, value: true },
  hsts_subdomains: { apply: false, value: true },
  block_exploits: { apply: false, value: true },
  websocket_support: { apply: false, value: true },
  enable_standard_headers: { apply: false, value: true },
})

// NEW: Add security header profile selection state
const [bulkSecurityHeaderProfile, setBulkSecurityHeaderProfile] = useState<{
  apply: boolean;
  profileId: number | null;
}>({ apply: false, profileId: null })

B.1.2. Import Security Header Profiles Hook

Location: At top of file with other imports

import { useSecurityHeaderProfiles } from '../hooks/useSecurityHeaders'

B.1.3. Add Hook Usage

Location: After other hook calls (around L43)

const { data: securityProfiles } = useSecurityHeaderProfiles()

B.1.4. Modify Bulk Apply Modal UI

Location: Inside the Bulk Apply Dialog content (around L645-690)

Add a new section after the existing toggle settings but before the progress indicator:

{/* Security Header Profile Section - NEW */}
<div className="border-t border-border pt-3 mt-3">
  <div className="flex items-center justify-between gap-3 p-3 bg-surface-subtle rounded-lg">
    <div className="flex items-center gap-3">
      <Checkbox
        checked={bulkSecurityHeaderProfile.apply}
        onCheckedChange={(checked) => setBulkSecurityHeaderProfile(prev => ({
          ...prev,
          apply: !!checked
        }))}
      />
      <div>
        <div className="text-sm font-medium text-content-primary">
          {t('proxyHosts.bulkApplySecurityHeaders')}
        </div>
        <div className="text-xs text-content-muted">
          {t('proxyHosts.bulkApplySecurityHeadersHelp')}
        </div>
      </div>
    </div>
  </div>

  {bulkSecurityHeaderProfile.apply && (
    <div className="mt-3 p-3 bg-surface-subtle rounded-lg">
      <select
        value={bulkSecurityHeaderProfile.profileId ?? 0}
        onChange={(e) => setBulkSecurityHeaderProfile(prev => ({
          ...prev,
          profileId: e.target.value === "0" ? null : parseInt(e.target.value)
        }))}
        className="w-full bg-surface-muted border border-border rounded-lg px-4 py-2 text-content-primary focus:outline-none focus:ring-2 focus:ring-brand-500"
      >
        <option value={0}>{t('proxyHosts.noSecurityProfile')}</option>
        <optgroup label={t('securityHeaders.systemProfiles')}>
          {securityProfiles
            ?.filter(p => p.is_preset)
            .sort((a, b) => a.security_score - b.security_score)
            .map(profile => (
              <option key={profile.id} value={profile.id}>
                {profile.name} ({t('common.score')}: {profile.security_score}/100)
              </option>
            ))}
        </optgroup>
        {(securityProfiles?.filter(p => !p.is_preset) || []).length > 0 && (
          <optgroup label={t('securityHeaders.customProfiles')}>
            {securityProfiles
              ?.filter(p => !p.is_preset)
              .map(profile => (
                <option key={profile.id} value={profile.id}>
                  {profile.name} ({t('common.score')}: {profile.security_score}/100)
                </option>
              ))}
          </optgroup>
        )}
      </select>

      {bulkSecurityHeaderProfile.profileId && (() => {
        const selected = securityProfiles?.find(p => p.id === bulkSecurityHeaderProfile.profileId)
        if (!selected) return null
        return (
          <div className="mt-2 text-xs text-content-muted">
            {selected.description}
          </div>
        )
      })()}
    </div>
  )}
</div>

B.1.5. Update Apply Button Logic

Location: In the DialogFooter onClick handler (around L700-720)

Modify the apply handler to include security header profile:

onClick={async () => {
  const keysToApply = Object.keys(bulkApplySettings).filter(k => bulkApplySettings[k].apply)
  const hostUUIDs = Array.from(selectedHosts)

  // Apply boolean settings
  if (keysToApply.length > 0) {
    const result = await applyBulkSettingsToHosts({
      hosts,
      hostUUIDs,
      keysToApply,
      bulkApplySettings,
      updateHost,
      setApplyProgress
    })

    if (result.errors > 0) {
      toast.error(t('notifications.updateFailed'))
    }
  }

  // Apply security header profile if selected
  if (bulkSecurityHeaderProfile.apply) {
    let profileErrors = 0
    for (const uuid of hostUUIDs) {
      try {
        await updateHost(uuid, {
          security_header_profile_id: bulkSecurityHeaderProfile.profileId
        })
      } catch {
        profileErrors++
      }
    }

    if (profileErrors > 0) {
      toast.error(t('notifications.updateFailed'))
    }
  }

  // Only show success if at least something was applied
  if (keysToApply.length > 0 || bulkSecurityHeaderProfile.apply) {
    toast.success(t('notifications.updateSuccess'))
  }

  setSelectedHosts(new Set())
  setShowBulkApplyModal(false)
  setBulkSecurityHeaderProfile({ apply: false, profileId: null })
}}

B.1.6. Update Apply Button Disabled State

Location: Same DialogFooter Button (around L725)

disabled={
  applyProgress !== null ||
  (Object.values(bulkApplySettings).every(s => !s.apply) && !bulkSecurityHeaderProfile.apply)
}

B.2. Update Translation Files

Files to Update:

  1. frontend/src/locales/en/translation.json
  2. frontend/src/locales/de/translation.json
  3. frontend/src/locales/es/translation.json
  4. frontend/src/locales/fr/translation.json
  5. frontend/src/locales/zh/translation.json

New Translation Keys (add to proxyHosts section)

English (en/translation.json):

{
  "proxyHosts": {
    "bulkApplySecurityHeaders": "Security Header Profile",
    "bulkApplySecurityHeadersHelp": "Apply a security header profile to all selected hosts",
    "noSecurityProfile": "None (Remove Profile)"
  }
}

Also add to common section:

{
  "common": {
    "score": "Score"
  }
}

B.3. Optional: Optimize with Bulk API Endpoint

For better performance with large numbers of hosts, consider adding a dedicated bulk update endpoint. This would reduce N API calls to 1.

New API Function in frontend/src/api/proxyHosts.ts:

export interface BulkUpdateSecurityHeadersRequest {
  host_uuids: string[];
  security_header_profile_id: number | null;
}

export interface BulkUpdateSecurityHeadersResponse {
  updated: number;
  errors: { uuid: string; error: string }[];
}

export const bulkUpdateSecurityHeaders = async (
  hostUUIDs: string[],
  securityHeaderProfileId: number | null
): Promise<BulkUpdateSecurityHeadersResponse> => {
  const { data } = await client.put<BulkUpdateSecurityHeadersResponse>(
    '/proxy-hosts/bulk-update-security-headers',
    {
      host_uuids: hostUUIDs,
      security_header_profile_id: securityHeaderProfileId,
    }
  );
  return data;
};

C. Backend Changes

C.1. Current Update Handler Analysis

The existing Update() handler in proxy_host_handler.go already handles security_header_profile_id updates (L253-286). The frontend can use individual updateHost() calls for each selected host.

However, for optimal performance, adding a dedicated bulk endpoint is recommended.

File: backend/internal/api/handlers/proxy_host_handler.go

C.2.1. Register New Route

Location: In RegisterRoutes() function (around L62)

router.PUT("/proxy-hosts/bulk-update-security-headers", h.BulkUpdateSecurityHeaders)

C.2.2. Add Handler Function

Location: After BulkUpdateACL() function (around L540)

// BulkUpdateSecurityHeadersRequest represents the request body for bulk security header updates
type BulkUpdateSecurityHeadersRequest struct {
    HostUUIDs               []string `json:"host_uuids" binding:"required"`
    SecurityHeaderProfileID *uint    `json:"security_header_profile_id"` // nil means remove profile
}

// BulkUpdateSecurityHeaders applies or removes a security header profile to multiple proxy hosts.
func (h *ProxyHostHandler) BulkUpdateSecurityHeaders(c *gin.Context) {
    var req BulkUpdateSecurityHeadersRequest

    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    if len(req.HostUUIDs) == 0 {
        c.JSON(http.StatusBadRequest, gin.H{"error": "host_uuids cannot be empty"})
        return
    }

    // Validate profile exists if provided
    if req.SecurityHeaderProfileID != nil {
        var profile models.SecurityHeaderProfile
        if err := h.service.DB().First(&profile, *req.SecurityHeaderProfileID).Error; err != nil {
            if err == gorm.ErrRecordNotFound {
                c.JSON(http.StatusBadRequest, gin.H{"error": "security header profile not found"})
                return
            }
            c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
            return
        }
    }

    updated := 0
    errors := []map[string]string{}

    for _, hostUUID := range req.HostUUIDs {
        host, err := h.service.GetByUUID(hostUUID)
        if err != nil {
            errors = append(errors, map[string]string{
                "uuid":  hostUUID,
                "error": "proxy host not found",
            })
            continue
        }

        host.SecurityHeaderProfileID = req.SecurityHeaderProfileID
        if err := h.service.Update(host); err != nil {
            errors = append(errors, map[string]string{
                "uuid":  hostUUID,
                "error": err.Error(),
            })
            continue
        }

        updated++
    }

    // Apply Caddy config once for all updates
    if updated > 0 && h.caddyManager != nil {
        if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{
                "error":   "Failed to apply configuration: " + err.Error(),
                "updated": updated,
                "errors":  errors,
            })
            return
        }
    }

    c.JSON(http.StatusOK, gin.H{
        "updated": updated,
        "errors":  errors,
    })
}

C.2.3. Update ProxyHostService (if needed)

File: backend/internal/services/proxyhost_service.go

If h.service.DB() is not exposed, add a getter:

func (s *ProxyHostService) DB() *gorm.DB {
    return s.db
}

D. Testing Requirements

D.1. Frontend Unit Tests

File to Create: frontend/src/pages/__tests__/ProxyHosts.bulkApplyHeaders.test.tsx

import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// ... test setup

describe('ProxyHosts Bulk Apply Security Headers', () => {
  it('should show security header profile option in bulk apply modal', async () => {
    // Render component with selected hosts
    // Open bulk apply modal
    // Verify security header section is visible
  })

  it('should enable profile selection when checkbox is checked', async () => {
    // Check the "Security Header Profile" checkbox
    // Verify dropdown becomes visible
  })

  it('should list all available profiles in dropdown', async () => {
    // Mock security profiles data
    // Verify preset and custom profiles are grouped
  })

  it('should apply security header profile to selected hosts', async () => {
    // Select hosts
    // Open modal
    // Enable security header option
    // Select a profile
    // Click Apply
    // Verify API calls made for each host
  })

  it('should remove security header profile when "None" selected', async () => {
    // Select hosts with existing profiles
    // Select "None" option
    // Verify null is sent to API
  })

  it('should disable Apply button when no options selected', async () => {
    // Ensure all checkboxes are unchecked
    // Verify Apply button is disabled
  })
})

D.2. Backend Unit Tests

File to Create: backend/internal/api/handlers/proxy_host_handler_security_headers_test.go

package handlers

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestProxyHostHandler_BulkUpdateSecurityHeaders_Success(t *testing.T) {
    // Setup test database with hosts and profiles
    // Create request with valid host UUIDs and profile ID
    // Assert 200 response
    // Assert all hosts updated
    // Assert Caddy config applied
}

func TestProxyHostHandler_BulkUpdateSecurityHeaders_RemoveProfile(t *testing.T) {
    // Create hosts with existing profiles
    // Send null security_header_profile_id
    // Assert profiles removed
}

func TestProxyHostHandler_BulkUpdateSecurityHeaders_InvalidProfile(t *testing.T) {
    // Send non-existent profile ID
    // Assert 400 error
}

func TestProxyHostHandler_BulkUpdateSecurityHeaders_EmptyUUIDs(t *testing.T) {
    // Send empty host_uuids array
    // Assert 400 error
}

func TestProxyHostHandler_BulkUpdateSecurityHeaders_PartialFailure(t *testing.T) {
    // Include some invalid UUIDs
    // Assert partial success response
    // Assert error details for failed hosts
}

D.3. Integration Test Scenarios

File: scripts/integration/bulk_security_headers_test.sh

#!/bin/bash
# Test bulk apply security headers feature

# 1. Create 3 test proxy hosts
# 2. Create a security header profile
# 3. Bulk apply profile to all hosts
# 4. Verify all hosts have profile assigned
# 5. Bulk remove profile (set to null)
# 6. Verify all hosts have no profile
# 7. Cleanup test data

E. Implementation Phases

Phase 1: Core UI Changes (Frontend Only)

Duration: 2-3 hours

Tasks:

  1. Add bulkSecurityHeaderProfile state to ProxyHosts.tsx
  2. Import and use useSecurityHeaderProfiles hook
  3. Add Security Header Profile section to Bulk Apply modal UI
  4. Update Apply button handler to include profile updates
  5. Update Apply button disabled state logic

Dependencies: None

Deliverable: Working bulk apply with security headers using individual API calls


Phase 2: Translation Updates

Duration: 30 minutes

Tasks:

  1. Add translation keys to en/translation.json
  2. Add translation keys to de/translation.json
  3. Add translation keys to es/translation.json
  4. Add translation keys to fr/translation.json
  5. Add translation keys to zh/translation.json

Dependencies: Phase 1

Deliverable: Localized UI strings


Phase 3: Backend Bulk Endpoint (Optional Optimization)

Duration: 1-2 hours

Tasks:

  1. Add BulkUpdateSecurityHeaders handler function
  2. Register new route in RegisterRoutes()
  3. Add DB() getter to ProxyHostService if needed
  4. Update frontend to use new bulk endpoint

Dependencies: Phase 1

Deliverable: Optimized bulk update with single API call


Phase 4: Testing

Duration: 2-3 hours

Tasks:

  1. Write frontend unit tests
  2. Write backend unit tests
  3. Create integration test script
  4. Manual QA testing

Dependencies: Phases 1-3

Deliverable: Full test coverage


Phase 5: Documentation

Duration: 30 minutes

Tasks:

  1. Update CHANGELOG.md
  2. Update docs/features.md if needed
  3. Add release notes

Dependencies: Phases 1-4

Deliverable: Updated documentation


F. Configuration Files Review

F.1. .gitignore

Status: No changes needed

Current .gitignore already covers all relevant patterns for new test files and build artifacts.

F.2. codecov.yml

Status: ⚠️ File not found in repository

If code coverage tracking is needed, create codecov.yml with:

coverage:
  status:
    project:
      default:
        target: 85%
    patch:
      default:
        target: 80%

F.3. .dockerignore

Status: No changes needed

Current .dockerignore already excludes test files, coverage artifacts, and documentation.

F.4. Dockerfile

Status: No changes needed

No changes to build process required for this feature.


G. Risk Assessment

Risk Likelihood Impact Mitigation
Performance with many hosts Medium Low Phase 3 adds bulk endpoint
State desync after partial failure Low Medium Show clear error messages per host
Mobile app compatibility warnings Low Low Reuse existing warning component from ProxyHostForm
Translation missing Medium Low Fallback to English

H. Success Criteria

  1. User can select Security Header Profile in Bulk Apply modal
  2. Profile can be applied to multiple hosts in single operation
  3. Profile can be removed (set to None) via bulk apply
  4. UI shows preset and custom profiles grouped separately
  5. Progress indicator shows during bulk operation
  6. Error handling for partial failures
  7. All translations in place
  8. Unit test coverage ≥80%
  9. Integration tests pass

I. Files Summary

Files to Modify

File Changes
frontend/src/pages/ProxyHosts.tsx Add state, hook, modal UI, apply logic
frontend/src/locales/en/translation.json Add 3 new keys
frontend/src/locales/de/translation.json Add 3 new keys
frontend/src/locales/es/translation.json Add 3 new keys
frontend/src/locales/fr/translation.json Add 3 new keys
frontend/src/locales/zh/translation.json Add 3 new keys
backend/internal/api/handlers/proxy_host_handler.go Add bulk endpoint (optional)

Files to Create

File Purpose
frontend/src/pages/__tests__/ProxyHosts.bulkApplyHeaders.test.tsx Frontend tests
backend/internal/api/handlers/proxy_host_handler_security_headers_test.go Backend tests
scripts/integration/bulk_security_headers_test.sh Integration tests

Files Unchanged (No Action Needed)

File Reason
.gitignore Already covers new file patterns
.dockerignore Already excludes test/docs files
Dockerfile No build changes needed
frontend/src/api/proxyHosts.ts Uses existing updateProxyHost()
frontend/src/hooks/useProxyHosts.ts Uses existing updateHost()
frontend/src/utils/proxyHostsHelpers.ts No changes needed

J. Conclusion

This implementation plan provides a complete roadmap for adding HTTP Security Headers to the Bulk Apply feature. The phased approach allows for incremental delivery:

  1. Phase 1 delivers a working feature using existing API infrastructure
  2. Phase 2 completes localization
  3. Phase 3 optimizes performance for large-scale operations
  4. Phases 4-5 ensure quality and documentation

The feature integrates naturally with the existing Bulk Apply modal pattern and reuses the Security Header Profile infrastructure already built for individual host editing.