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
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 SSLhttp2_support- HTTP/2 Supporthsts_enabled- HSTS Enabledhsts_subdomains- HSTS Subdomainsblock_exploits- Block Exploitswebsocket_support- Websockets Supportenable_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
- User selects multiple hosts using checkboxes in the DataTable
- User clicks "Bulk Apply" button → opens modal
- Modal shows all available settings with checkboxes (apply/don't apply) and toggles (on/off)
- User clicks "Apply" →
applyBulkSettingsToHosts()iterates over selected hosts - For each host, it calls
updateHost(uuid, mergedData)which triggersPUT /api/v1/proxy-hosts/{uuid} - 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:
frontend/src/locales/en/translation.jsonfrontend/src/locales/de/translation.jsonfrontend/src/locales/es/translation.jsonfrontend/src/locales/fr/translation.jsonfrontend/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.
C.2. Add Bulk Update Security Headers Endpoint (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:
- Add
bulkSecurityHeaderProfilestate to ProxyHosts.tsx - Import and use
useSecurityHeaderProfileshook - Add Security Header Profile section to Bulk Apply modal UI
- Update Apply button handler to include profile updates
- 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:
- Add translation keys to
en/translation.json - Add translation keys to
de/translation.json - Add translation keys to
es/translation.json - Add translation keys to
fr/translation.json - 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:
- Add
BulkUpdateSecurityHeadershandler function - Register new route in
RegisterRoutes() - Add
DB()getter to ProxyHostService if needed - 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:
- Write frontend unit tests
- Write backend unit tests
- Create integration test script
- Manual QA testing
Dependencies: Phases 1-3
Deliverable: Full test coverage
Phase 5: Documentation
Duration: 30 minutes
Tasks:
- Update CHANGELOG.md
- Update docs/features.md if needed
- 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
- ✅ User can select Security Header Profile in Bulk Apply modal
- ✅ Profile can be applied to multiple hosts in single operation
- ✅ Profile can be removed (set to None) via bulk apply
- ✅ UI shows preset and custom profiles grouped separately
- ✅ Progress indicator shows during bulk operation
- ✅ Error handling for partial failures
- ✅ All translations in place
- ✅ Unit test coverage ≥80%
- ✅ 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:
- Phase 1 delivers a working feature using existing API infrastructure
- Phase 2 completes localization
- Phase 3 optimizes performance for large-scale operations
- 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.