Refactor security headers workflow and improve user feedback
- Removed the Badge component displaying preset type in SecurityHeaders.tsx for a cleaner UI. - Added detailed analysis for the "Apply Preset" workflow, highlighting user confusion and root causes. - Proposed fixes to enhance user experience, including clearer toast messages, loading indicators, and better naming for profile sections. - Documented the complete workflow trace for applying security header presets, emphasizing the need for per-host assignment.
This commit is contained in:
+829
-1153
File diff suppressed because it is too large
Load Diff
@@ -1,124 +0,0 @@
|
||||
Proxy TLS & IP Login Recovery Plan
|
||||
==================================
|
||||
|
||||
Context
|
||||
|
||||
- Proxy hosts return ERR_SSL_PROTOCOL_ERROR after container build succeeds; TLS handshake likely broken in generated Caddy config or certificate provisioning.
|
||||
- Charon login fails with “invalid credentials” when UI is accessed via raw IP/port; likely cookie or header handling across HTTP/non-SNI scenarios.
|
||||
- Security scans can wait until connectivity and login paths are stable.
|
||||
|
||||
Goals
|
||||
|
||||
- Restore HTTPS/HTTP reachability for proxy hosts and admin UI without TLS protocol errors.
|
||||
- Make login succeed when using IP:port access while preserving secure defaults for domain-based HTTPS.
|
||||
- Keep changes minimal per request; batch verification runs.
|
||||
|
||||
Phase 1 — Fast Repro & Evidence (single command batch)
|
||||
|
||||
- Build is running remotely; use the deployed host [http://100.98.12.109:8080](http://100.98.12.109:8080) (not localhost) for repro. If HTTPS is exposed, also probe [https://100.98.12.109](https://100.98.12.109).
|
||||
- Capture logs remotely: docker logs (Caddy + Charon) to logs/build/proxy-ssl.log and logs/build/login-ip.log on the remote node.
|
||||
- From the remote container, fetch live Caddy config: curl [http://127.0.0.1:2019/config](http://127.0.0.1:2019/config) > logs/build/caddy-live.json.
|
||||
- Snapshot TLS handshake from a reachable vantage point: openssl s_client -connect 100.98.12.109:443 -servername {first-proxy-domain} -tls1_2 to capture protocol/alert.
|
||||
|
||||
Phase 2 — Diagnose ERR_SSL_PROTOCOL_ERROR in Caddy pipeline
|
||||
|
||||
- Inspect generation path: [backend/internal/caddy/manager.go](backend/internal/caddy/manager.go) ApplyConfig → GenerateConfig; ensure ACME email/provider/flags are loaded from settings.
|
||||
- Review server wiring: [backend/internal/caddy/config.go](backend/internal/caddy/config.go) sets servers to listen on :80/:443 with AutoHTTPS enabled. Check whether hosts with IP literals are being treated like domain names (Caddy cannot issue ACME for IP; may yield protocol alerts).
|
||||
- Inspect per-host TLS inputs: models.ProxyHost.CertificateID/Certificate.Provider (custom vs ACME), DomainNames normalization, and AdvancedConfig WAF handlers that might inject broken handlers.
|
||||
- Validate stored config at runtime: data/caddy/caddy.json (if persisted) vs live admin API to see if TLS automation policies or certificates are missing.
|
||||
- Verify entrypoint sequencing: [docker-entrypoint.sh](docker-entrypoint.sh) seeds empty Caddy config then relies on charon to push config; ensure ApplyConfig runs before first request.
|
||||
|
||||
Phase 3 — Plan fixes for TLS/HTTPS reachability
|
||||
|
||||
- Add IP-aware TLS handling in [backend/internal/caddy/config.go](backend/internal/caddy/config.go): detect hosts whose DomainNames are IPs; for those, set explicit HTTP listener only or `tls internal` to avoid failed ACME, and skip AutoHTTPS redirect for IP-only sites.
|
||||
- Add guardrails/tests: extend [backend/internal/caddy/config_test.go](backend/internal/caddy/config_test.go) with a table case for IP hosts (expects HTTP route present, no AutoHTTPS redirect, optional internal TLS when requested).
|
||||
- If admin UI also rides on :443, consider a fallback self-signed cert for bare IP by injecting a static certificate loader (same file) or disabling redirect when no hostname SNI is present.
|
||||
- Re-apply config through [backend/internal/caddy/manager.go](backend/internal/caddy/manager.go) and confirm via admin API; ensure rollback still works if validation fails.
|
||||
|
||||
Phase 4 — Diagnose login failures on IP:port
|
||||
|
||||
- Backend cookie issuance: [backend/internal/api/handlers/auth_handler.go](backend/internal/api/handlers/auth_handler.go) `setSecureCookie` forces `Secure` when CHARON_ENV=production; on HTTP/IP this prevents cookie storage → follow-up /auth/me returns 401, surfaced as “Login failed/invalid credentials”.
|
||||
- Request-aware secure flag: derive `Secure` from request scheme or `X-Forwarded-Proto`, and relax SameSite to Lax for forward_auth flows; keep Strict for HTTPS hostnames.
|
||||
- Auth flow: [backend/internal/services/auth_service.go](backend/internal/services/auth_service.go) handles credentials; [backend/internal/api/middleware/auth.go](backend/internal/api/middleware/auth.go) accepts cookie/Authorization/query token. Ensure fallback to Authorization header using login response token when cookie is absent (IP/HTTP).
|
||||
- Frontend: [frontend/src/api/client.ts](frontend/src/api/client.ts) uses withCredentials; [frontend/src/pages/Login.tsx](frontend/src/pages/Login.tsx) currently ignores returned token. Add optional storage/Authorization injection when cookie not set (feature-flagged), and surface clearer error when /auth/me fails post-login.
|
||||
- Security headers: review [backend/internal/api/middleware/security_headers.go](backend/internal/api/middleware/security_headers.go) (HSTS/CSP) to ensure HTTP over IP is not force-upgraded to HTTPS unexpectedly during troubleshooting.
|
||||
|
||||
Phase 5 — Validation & Regression
|
||||
|
||||
- Unit tests: add table-driven cases for setSecureCookie in auth handler (HTTP vs HTTPS, IP vs hostname) and AuthMiddleware behavior when token is supplied via header instead of cookie.
|
||||
- Caddy config tests: ensure IP host generation passes validation and does not emit duplicate routes or ghost hosts.
|
||||
- Frontend tests: extend [frontend/src/pages/__tests__/Login.test.tsx](frontend/src/pages/__tests__/Login.test.tsx) to cover the no-cookie fallback path.
|
||||
- Manual: rerun "Go: Build Backend", `npm run build`, task "Build & Run Local Docker", then verify login via IP:8080 and HTTPS domain, and re-run a narrow Caddy integration test if available (e.g., "Coraza: Run Integration Go Test").
|
||||
|
||||
Phase 6 — Hygiene (.gitignore / .dockerignore / .codecov.yml / Dockerfile)
|
||||
|
||||
- .gitignore: add frontend/.cache, frontend/.eslintcache, data/geoip/ (downloaded in Dockerfile), and backend/.vscode/ if it appears locally.
|
||||
- .dockerignore: mirror the new ignores (frontend/.cache, frontend/.eslintcache, data/geoip/) to keep context slim; keep docs exclusions as-is.
|
||||
- .codecov.yml: reconsider excluding backend/cmd/api/** if we touch startup or ApplyConfig wiring so coverage reflects new logic.
|
||||
- Dockerfile: after TLS/login fixes, assess adding a healthcheck or a post-start verification curl to :2019 and :8080; keep current multi-stage caching intact.
|
||||
|
||||
Exit Criteria
|
||||
|
||||
- Proxy hosts and admin UI respond over HTTP/HTTPS without ERR_SSL_PROTOCOL_ERROR; TLS handshake succeeds for domain hosts, HTTP works for IP-only access.
|
||||
- Login succeeds via IP:port and via domain/HTTPS; cookies or header-based fallback maintain session across /auth/me.
|
||||
- Updated ignore lists prevent new artifacts from leaking; coverage targets remain achievable after test additions.
|
||||
|
||||
Build Failure & Security Scan Battle Plan
|
||||
=========================================
|
||||
|
||||
Phasing principle: collapse the effort into the fewest high-signal requests by batching commands (backend + frontend + container + scans) and only re-running the narrowest slice after each fix. Keep evidence artifacts for every step.
|
||||
|
||||
Phase 1 — Reproduce and Capture the Failure (single pass)
|
||||
|
||||
- Run the workspace tasks in this order to get a complete signal stack: "Go: Build Backend", then "Frontend: Type Check", then `npm run build` inside frontend (captures Vite/React errors near [frontend/src/main.tsx](frontend/src/main.tsx) and `App`), then "Build & Run Local Docker" to surface multi-stage Dockerfile issues.
|
||||
- Preserve raw outputs to `logs/build/`: backend (`backend/build.log`), frontend (`frontend/build.log`), docker (`docker/build.log`). If a stage fails, stop and annotate the failing command, module, and package.
|
||||
- If Docker fails before build, try `docker build --progress=plain --no-cache` once to expose failing layer context (Caddy build, Golang, or npm). Keep the resulting layer logs.
|
||||
|
||||
Phase 2 — Backend Compilation & Test Rehab (one request)
|
||||
|
||||
- Inspect error stack for the Go layer; focus on imports and CGO flags in [backend/cmd/api/main.go](backend/cmd/api/main.go) and router bootstrap [backend/internal/server/server.go](backend/internal/server/server.go).
|
||||
- If module resolution fails, run "Go: Mod Tidy (Backend)" once, then re-run "Go: Build Backend"; avoid extra tidies to limit churn.
|
||||
- If CGO/SQLite headers are missing, verify `apk add --no-cache gcc musl-dev sqlite-dev` step in Dockerfile backend-builder stage; mirror locally via `apk add` or `sudo apt-get` equivalents depending on host env.
|
||||
- Run "Go: Test Backend" (or narrower `go test ./internal/...` if failure is localized) to ensure handlers (e.g., `routes.Register`, `handlers.CheckMountedImport`) still compile after fixes; capture coverage deltas if touched.
|
||||
|
||||
Phase 3 — Frontend Build & Type Discipline (one request)
|
||||
|
||||
- If type-check passes but build fails, inspect Vite config and rollup native skip flags in Dockerfile frontend-builder; cross-check `npm_config_rollup_skip_nodejs_native` and `ROLLUP_SKIP_NODEJS_NATIVE` envs.
|
||||
- Validate entry composition in [frontend/src/main.tsx](frontend/src/main.tsx) and any failing component stack (e.g., `ThemeProvider`, `App`). Run `npm run lint -- --fix` only after root cause is understood to avoid masking errors.
|
||||
- Re-run `npm run build` only after code fixes; stash bundle warnings for later size/security audits.
|
||||
|
||||
Phase 4 — Container Build Reliability (one request)
|
||||
|
||||
- Reproduce Docker failure with `--progress=plain`; pinpoint failing stage: `frontend-builder` (npm ci/build), `backend-builder` (xx-go build of `cmd/api`), or `caddy-builder` (xcaddy patch loop).
|
||||
- If failure is in Caddy patch block, test with a narrowed build arg (e.g., `--build-arg CADDY_VERSION=2.10.2`) and confirm the fallback path works. Consider pinning quic-go/expr/smallstep versions if Renovate lagged.
|
||||
- Verify entrypoint expectations in [docker-entrypoint.sh](docker-entrypoint.sh) align with built assets (`/app/frontend/dist`, `/app/charon`). Ensure symlink `cpmp` creation does not fail when `/app` is read-only.
|
||||
|
||||
Phase 5 — CodeQL Scan & Triage (single run, then focused reruns)
|
||||
|
||||
- Execute "Run CodeQL Scan (Local)" task once the code builds. Preserve SARIF to `codeql-agent-results/` and convert critical findings into issues.
|
||||
- Triage hotspots: server middleware (`RequestID`, `RequestLogger`, `Recovery`), auth handlers under `internal/api/handlers`, and config loader `internal/config`. Prioritize SQL injections, path traversal in `handlers.CheckMountedImport`, and logging of secrets.
|
||||
- After fixes, re-run only the affected language pack (Go or JS) to minimize cycle time; attach SARIF diff to the plan.
|
||||
|
||||
Phase 6 — Trivy Image Scan & Triage (single run)
|
||||
|
||||
- After a successful Docker build (`charon:local`), run "Run Trivy Scan (Local)". Persist report in `.trivy_logs/trivy-report.txt` (already ignored).
|
||||
- Bucket findings: base image vulns (alpine), Caddy plugins, CrowdSec bundle, Go binary CVEs. Cross-check with Dockerfile upgrade levers (`CADDY_VERSION`, `CROWDSEC_VERSION`, `golang:1.25.5-alpine`).
|
||||
- For OS-level CVEs, prefer `apk --no-cache upgrade` (already present) and version bumps; for Go deps, adjust go.mod and rebuild.
|
||||
|
||||
Phase 7 — Coverage & Quality Gates
|
||||
|
||||
- Ensure Codecov target (85%) still reachable; if exclusions are too broad (e.g., entire `backend/cmd/api`), reassess in [.codecov.yml](.codecov.yml) after fixes to keep new logic covered.
|
||||
- If new backend logic lands in handlers or middleware, add table-driven tests under `backend/internal/api/...` to keep coverage from regressing.
|
||||
|
||||
Phase 8 — Hygiene Checks (.gitignore, .dockerignore, Dockerfile, Codecov)
|
||||
|
||||
- .gitignore: consider adding `frontend/.cache/` and `backend/.vscode/` artifacts if they appear during debugging; keep `.trivy_logs/` already present.
|
||||
- .dockerignore: keep build context lean; add `frontend/.cache/`, `backend/.vscode/`; `codeql-results*.sarif` is already excluded. Ensure `docs/` exclusion is acceptable (only README/CONTRIBUTING/LICENSE kept) so Docker builds stay small.
|
||||
- .codecov.yml: exclusions already cover e2e/integration and configs; if we add security helpers, avoid excluding them to keep visibility. Review whether ignoring `backend/cmd/api/**` is desired; we may want to include it if main wiring changes.
|
||||
- Dockerfile: if builds fail due to xcaddy patch drift, add guard logs or split the patch block into a script under `scripts/` for clearer diffing. Consider caching npm and go modules via `--mount=type=cache` already present; avoid expanding build args further to limit attack surface.
|
||||
|
||||
Exit Criteria
|
||||
|
||||
- All four commands succeed in sequence: "Go: Build Backend", `npm run build`, `docker build` (local multi-stage), "Run CodeQL Scan (Local)", and "Run Trivy Scan (Local)" on `charon:local`.
|
||||
- Logs captured and linked; actionable items opened for any CodeQL/Trivy HIGH/CRITICAL.
|
||||
- No new untracked artifacts thanks to updated ignore lists.
|
||||
@@ -0,0 +1,500 @@
|
||||
# Security Headers "Apply Preset" Workflow Analysis
|
||||
|
||||
**Date**: December 18, 2025
|
||||
**Issue**: User confusion after applying security header preset - no feedback, unclear activation status
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The user applied a security header preset (e.g., "Basic Security") and experienced confusion because:
|
||||
|
||||
1. **No toast appeared** (actually it does, but message is ambiguous)
|
||||
2. **No loading indicator** (button state doesn't show progress)
|
||||
3. **Profile appeared in "Custom Profiles"** (unclear naming)
|
||||
4. **Uncertainty about activation** (doesn't know if headers are live)
|
||||
5. **Suggested renaming** section if headers are already active
|
||||
|
||||
**KEY FINDING**: Headers are **NOT ACTIVE** after applying preset. The preset creates a **new custom profile** that must be **manually assigned to each proxy host** to take effect.
|
||||
|
||||
**Root Cause**: UX does not communicate the multi-step workflow clearly.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Complete Workflow Trace
|
||||
|
||||
### Step 1: User Action
|
||||
|
||||
**Location**: [SecurityHeaders.tsx](../../frontend/src/pages/SecurityHeaders.tsx)
|
||||
**Trigger**: User clicks "Apply" button on a preset card (Basic/Strict/Paranoid)
|
||||
|
||||
```tsx
|
||||
<Button onClick={() => handleApplyPreset(profile.preset_type)}>
|
||||
<Play className="h-4 w-4 mr-1" /> Apply
|
||||
</Button>
|
||||
```
|
||||
|
||||
### Step 2: Frontend Handler
|
||||
|
||||
**Function**: `handleApplyPreset(presetType: string)`
|
||||
|
||||
```tsx
|
||||
const handleApplyPreset = (presetType: string) => {
|
||||
const name = `${presetType.charAt(0).toUpperCase() + presetType.slice(1)} Security Profile`;
|
||||
applyPresetMutation.mutate({ preset_type: presetType, name });
|
||||
};
|
||||
```
|
||||
|
||||
**What happens**:
|
||||
|
||||
- Constructs name: "Basic Security Profile", "Strict Security Profile", etc.
|
||||
- Calls mutation from React Query hook
|
||||
|
||||
### Step 3: React Query Hook
|
||||
|
||||
**Location**: [useSecurityHeaders.ts](../../frontend/src/hooks/useSecurityHeaders.ts#L63-L74)
|
||||
|
||||
```typescript
|
||||
export function useApplySecurityHeaderPreset() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: ApplyPresetRequest) => securityHeadersApi.applyPreset(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['securityHeaderProfiles'] });
|
||||
toast.success('Preset applied successfully');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Failed to apply preset: ${error.message}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**What happens**:
|
||||
|
||||
- ✅ **DOES** show toast: `'Preset applied successfully'`
|
||||
- ✅ **DOES** invalidate queries (triggers refetch of profile list)
|
||||
- ❌ **DOES NOT** show loading indicator during mutation
|
||||
|
||||
### Step 4: Backend Handler
|
||||
|
||||
**Location**: [security_headers_handler.go](../../backend/internal/api/handlers/security_headers_handler.go#L223-L240)
|
||||
|
||||
```go
|
||||
func (h *SecurityHeadersHandler) ApplyPreset(c *gin.Context) {
|
||||
var req struct {
|
||||
PresetType string `json:"preset_type" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
profile, err := h.service.ApplyPreset(req.PresetType, req.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"profile": profile})
|
||||
}
|
||||
```
|
||||
|
||||
**What happens**:
|
||||
|
||||
- Receives preset type and name
|
||||
- Delegates to service layer
|
||||
- Returns created profile
|
||||
- ❌ **DOES NOT** trigger `ApplyConfig()` (no Caddy reload)
|
||||
|
||||
### Step 5: Service Layer
|
||||
|
||||
**Location**: [security_headers_service.go](../../backend/internal/services/security_headers_service.go#L95-L120)
|
||||
|
||||
```go
|
||||
func (s *SecurityHeadersService) ApplyPreset(presetType, name string) (*models.SecurityHeaderProfile, error) {
|
||||
presets := s.GetPresets()
|
||||
|
||||
var selectedPreset *models.SecurityHeaderProfile
|
||||
for i := range presets {
|
||||
if presets[i].PresetType == presetType {
|
||||
selectedPreset = &presets[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if selectedPreset == nil {
|
||||
return nil, fmt.Errorf("preset type %s not found", presetType)
|
||||
}
|
||||
|
||||
// Create a copy with custom name and UUID
|
||||
newProfile := *selectedPreset
|
||||
newProfile.ID = 0 // Clear ID so GORM creates a new record
|
||||
newProfile.UUID = uuid.New().String()
|
||||
newProfile.Name = name
|
||||
newProfile.IsPreset = false // User-created profiles are not presets
|
||||
newProfile.PresetType = "" // Clear preset type for custom profiles
|
||||
|
||||
if err := s.db.Create(&newProfile).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to create profile from preset: %w", err)
|
||||
}
|
||||
|
||||
return &newProfile, nil
|
||||
}
|
||||
```
|
||||
|
||||
**What happens**:
|
||||
|
||||
- Finds the requested preset (basic/strict/paranoid)
|
||||
- Creates a **COPY** of the preset as a new custom profile
|
||||
- Saves to database
|
||||
- Returns the new profile
|
||||
- ❌ **Profile is NOT assigned to any hosts**
|
||||
- ❌ **Headers are NOT active yet**
|
||||
|
||||
### Step 6: Profile Appears in UI
|
||||
|
||||
**Location**: "Custom Profiles" section
|
||||
|
||||
**What user sees**:
|
||||
|
||||
- New card appears in "Custom Profiles" grid
|
||||
- Shows profile name, security score, timestamp
|
||||
- User can Edit/Clone/Delete
|
||||
- ⚠️ **No indication that profile needs to be assigned to hosts**
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Critical Understanding: Per-Host Assignment
|
||||
|
||||
### How Security Headers Work
|
||||
|
||||
Security headers in Charon are **PER-HOST**, not global:
|
||||
|
||||
```go
|
||||
// ProxyHost model
|
||||
type ProxyHost struct {
|
||||
// ...
|
||||
SecurityHeaderProfileID *uint `json:"security_header_profile_id"`
|
||||
SecurityHeaderProfile *SecurityHeaderProfile `json:"security_header_profile" gorm:"foreignKey:SecurityHeaderProfileID"`
|
||||
|
||||
SecurityHeadersEnabled bool `json:"security_headers_enabled" gorm:"default:true"`
|
||||
SecurityHeadersCustom string `json:"security_headers_custom" gorm:"type:text"`
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Key facts**:
|
||||
|
||||
1. Each proxy host can reference ONE profile via `SecurityHeaderProfileID`
|
||||
2. If no profile is assigned, host uses inline settings or defaults
|
||||
3. Creating a profile **DOES NOT** automatically assign it to any hosts
|
||||
4. Headers are applied when Caddy config is generated from ProxyHost data
|
||||
|
||||
### When Headers Become Active
|
||||
|
||||
**Location**: [config.go](../../backend/internal/caddy/config.go#L1143-L1160)
|
||||
|
||||
```go
|
||||
func buildSecurityHeadersHandler(host *models.ProxyHost) (Handler, error) {
|
||||
if host == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Use profile if configured
|
||||
var cfg *models.SecurityHeaderProfile
|
||||
if host.SecurityHeaderProfile != nil {
|
||||
cfg = host.SecurityHeaderProfile // ✅ Profile assigned to host
|
||||
} else if !host.SecurityHeadersEnabled {
|
||||
// No profile and headers disabled - skip
|
||||
return nil, nil
|
||||
} else {
|
||||
// Use default secure headers
|
||||
cfg = getDefaultSecurityHeaderProfile() // ⚠️ Fallback defaults
|
||||
}
|
||||
// ... builds headers from cfg ...
|
||||
}
|
||||
```
|
||||
|
||||
**Activation requires**:
|
||||
|
||||
1. User creates/edits a proxy host
|
||||
2. User selects the security header profile in the host form
|
||||
3. User saves the host
|
||||
4. `ProxyHostHandler.UpdateProxyHost()` calls `caddyManager.ApplyConfig()`
|
||||
5. Caddy reloads with new headers applied
|
||||
|
||||
---
|
||||
|
||||
## ❌ Current Behavior vs ✅ Expected Behavior
|
||||
|
||||
| Aspect | Current | Expected | Severity |
|
||||
|--------|---------|----------|----------|
|
||||
| **Toast notification** | ✅ Shows "Preset applied successfully" | ✅ Same (but could be clearer) | Low |
|
||||
| **Loading indicator** | ❌ None during mutation | ✅ Should show loading state on button | Medium |
|
||||
| **Profile location** | ✅ Appears in "Custom Profiles" | ⚠️ Should clarify activation needed | High |
|
||||
| **User confusion** | ❌ "Is it active?" "What's next?" | ✅ Clear next steps | **Critical** |
|
||||
| **Caddy reload** | ❌ Not triggered | ✅ Correct - only reload when assigned to host | Low |
|
||||
| **Section naming** | "Custom Profiles" | ⚠️ Misleading - implies active | Medium |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Root Cause of User Confusion
|
||||
|
||||
### Problem 1: Ambiguous Toast Message
|
||||
|
||||
**Current**: `"Preset applied successfully"`
|
||||
**User thinks**: "Applied to what? Is it protecting my sites now?"
|
||||
|
||||
### Problem 2: No Loading Indicator
|
||||
|
||||
Button shows no feedback during the async operation. User doesn't know:
|
||||
|
||||
- When request starts
|
||||
- When request completes
|
||||
- If anything happened at all
|
||||
|
||||
### Problem 3: "Custom Profiles" Name is Misleading
|
||||
|
||||
This section name implies:
|
||||
|
||||
- These are "your active profiles"
|
||||
- Headers are protecting something
|
||||
|
||||
**Reality**: These are **AVAILABLE** profiles, not **ACTIVE** profiles
|
||||
|
||||
### Problem 4: No Next Steps Guidance
|
||||
|
||||
After applying preset, UI doesn't tell user:
|
||||
|
||||
- ✅ Profile created
|
||||
- ⚠️ **Next**: Assign this profile to proxy hosts
|
||||
- 📍 **Where**: Edit any Proxy Host → Security Headers dropdown
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Recommended Fixes
|
||||
|
||||
### Fix 1: Improve Toast Messages ⭐ HIGH PRIORITY
|
||||
|
||||
**Change in**: [useSecurityHeaders.ts](../../frontend/src/hooks/useSecurityHeaders.ts)
|
||||
|
||||
```typescript
|
||||
// CURRENT
|
||||
toast.success('Preset applied successfully');
|
||||
|
||||
// RECOMMENDED
|
||||
toast.success('Profile created! Assign it to proxy hosts to activate headers.');
|
||||
```
|
||||
|
||||
**Better yet**, use a rich toast with action:
|
||||
|
||||
```typescript
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['securityHeaderProfiles'] });
|
||||
toast.success(
|
||||
<div>
|
||||
<strong>Profile created!</strong>
|
||||
<p className="text-sm">Assign it to proxy hosts to activate security headers.</p>
|
||||
</div>,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
### Fix 2: Add Loading State to Apply Button ⭐ HIGH PRIORITY
|
||||
|
||||
**Change in**: [SecurityHeaders.tsx](../../frontend/src/pages/SecurityHeaders.tsx)
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleApplyPreset(profile.preset_type)}
|
||||
disabled={applyPresetMutation.isPending} // ✅ Already exists!
|
||||
>
|
||||
{applyPresetMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Applying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 mr-1" /> Apply
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Issue**: Loading state needs to track WHICH preset is being applied (currently all buttons disable)
|
||||
|
||||
### Fix 3: Rename "Custom Profiles" Section ⭐ MEDIUM PRIORITY
|
||||
|
||||
**Options**:
|
||||
|
||||
| Name | Pros | Cons | Verdict |
|
||||
|------|------|------|---------|
|
||||
| "Available Profiles" | ✅ Accurate | ❌ Generic | ⭐⭐⭐ Good |
|
||||
| "Your Profiles" | ✅ User-centric | ❌ Still ambiguous | ⭐⭐ Okay |
|
||||
| "Saved Profiles" | ✅ Clear state | ❌ Wordy | ⭐⭐⭐ Good |
|
||||
| "Custom Profiles (Not Assigned)" | ✅ Very clear | ❌ Too long | ⭐⭐ Okay |
|
||||
|
||||
**Recommended**: **"Your Saved Profiles"**
|
||||
|
||||
- Clear that these are stored but not necessarily active
|
||||
- Differentiates from system presets
|
||||
- User-friendly tone
|
||||
|
||||
### Fix 4: Add Empty State Guidance ⭐ MEDIUM PRIORITY
|
||||
|
||||
After applying first preset, show a helpful alert:
|
||||
|
||||
```tsx
|
||||
{customProfiles.length === 1 && (
|
||||
<Alert variant="info" className="mb-4">
|
||||
<Info className="w-4 h-4" />
|
||||
<div>
|
||||
<p className="font-semibold">Next Step: Assign to Proxy Hosts</p>
|
||||
<p className="text-sm mt-1">
|
||||
Go to <Link to="/proxy-hosts">Proxy Hosts</Link>, edit a host,
|
||||
and select this profile under "Security Headers" to activate protection.
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
```
|
||||
|
||||
### Fix 5: Track Apply State Per-Preset ⭐ HIGH PRIORITY
|
||||
|
||||
**Problem**: `applyPresetMutation.isPending` is global - disables all buttons
|
||||
|
||||
**Solution**: Track which preset is being applied
|
||||
|
||||
```tsx
|
||||
const [applyingPreset, setApplyingPreset] = useState<string | null>(null);
|
||||
|
||||
const handleApplyPreset = (presetType: string) => {
|
||||
setApplyingPreset(presetType);
|
||||
const name = `${presetType.charAt(0).toUpperCase() + presetType.slice(1)} Security Profile`;
|
||||
applyPresetMutation.mutate(
|
||||
{ preset_type: presetType, name },
|
||||
{
|
||||
onSettled: () => setApplyingPreset(null),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// In button:
|
||||
disabled={applyingPreset !== null}
|
||||
// Show loading only for the specific button:
|
||||
{applyingPreset === profile.preset_type ? <Loader2 .../> : <Play .../>}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Implementation Checklist
|
||||
|
||||
### Phase 1: Immediate Fixes (High Priority)
|
||||
|
||||
- [ ] Fix toast message to clarify next steps
|
||||
- [ ] Add per-preset loading state tracking
|
||||
- [ ] Show loading spinner on Apply button for active preset
|
||||
- [ ] Disable all Apply buttons while any is loading
|
||||
|
||||
### Phase 2: UX Improvements (Medium Priority)
|
||||
|
||||
- [ ] Rename "Custom Profiles" to "Your Saved Profiles"
|
||||
- [ ] Add info alert after first profile creation
|
||||
- [ ] Link alert to Proxy Hosts page with guidance
|
||||
|
||||
### Phase 3: Advanced (Low Priority)
|
||||
|
||||
- [ ] Add tooltip to Apply button explaining what happens
|
||||
- [ ] Show usage count on profile cards ("Used by X hosts")
|
||||
- [ ] Add "Assign to Hosts" quick action after creation
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
Before marking as complete:
|
||||
|
||||
### 1. Apply Preset Flow
|
||||
|
||||
- [ ] Click "Apply" on Basic preset
|
||||
- [ ] Verify button shows loading spinner
|
||||
- [ ] Verify other Apply buttons are disabled
|
||||
- [ ] Verify toast appears with clear message
|
||||
- [ ] Verify new profile appears in "Your Saved Profiles"
|
||||
- [ ] Verify profile shows correct security score
|
||||
|
||||
### 2. Assignment Verification
|
||||
|
||||
- [ ] Navigate to Proxy Hosts
|
||||
- [ ] Edit a host
|
||||
- [ ] Verify new profile appears in Security Headers dropdown
|
||||
- [ ] Select profile and save
|
||||
- [ ] Verify Caddy reloads
|
||||
- [ ] Verify headers appear in HTTP response (curl -I)
|
||||
|
||||
### 3. Edge Cases
|
||||
|
||||
- [ ] Apply same preset twice (should create second copy)
|
||||
- [ ] Apply preset while offline (should show error toast)
|
||||
- [ ] Apply preset with very long name
|
||||
- [ ] Rapid-click Apply button (should debounce)
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Files
|
||||
|
||||
### Backend
|
||||
|
||||
- [security_headers_handler.go](../../backend/internal/api/handlers/security_headers_handler.go) - API endpoint
|
||||
- [security_headers_service.go](../../backend/internal/services/security_headers_service.go) - Business logic
|
||||
- [proxy_host.go](../../backend/internal/models/proxy_host.go) - Host-profile relationship
|
||||
- [config.go](../../backend/internal/caddy/config.go#L1143) - Header application logic
|
||||
|
||||
### Frontend
|
||||
|
||||
- [SecurityHeaders.tsx](../../frontend/src/pages/SecurityHeaders.tsx) - Main UI
|
||||
- [useSecurityHeaders.ts](../../frontend/src/hooks/useSecurityHeaders.ts) - React Query hooks
|
||||
- [securityHeaders.ts](../../frontend/src/api/securityHeaders.ts) - API client
|
||||
|
||||
---
|
||||
|
||||
## 📊 Summary
|
||||
|
||||
### What Actually Happens
|
||||
|
||||
1. User clicks "Apply" on preset
|
||||
2. Frontend creates a **new custom profile** by copying preset settings
|
||||
3. Profile is saved to database
|
||||
4. Profile appears in "Custom Profiles" list
|
||||
5. **Headers are NOT ACTIVE** until profile is assigned to a proxy host
|
||||
6. User must edit each proxy host and select the profile
|
||||
7. Only then does Caddy reload with new headers
|
||||
|
||||
### Why User is Confused
|
||||
|
||||
- ✅ Toast says "applied" but headers aren't active
|
||||
- ❌ No loading indicator during save
|
||||
- ❌ Section name "Custom Profiles" doesn't indicate activation needed
|
||||
- ❌ No guidance on next steps
|
||||
- ❌ User expects preset to "just work" globally
|
||||
|
||||
### Solution
|
||||
|
||||
Improve feedback and guidance to make the workflow explicit:
|
||||
|
||||
1. **Clear toast**: "Profile created! Assign to hosts to activate."
|
||||
2. **Loading state**: Show spinner on Apply button
|
||||
3. **Better naming**: "Your Saved Profiles" instead of "Custom Profiles"
|
||||
4. **Next steps**: Show alert linking to Proxy Hosts page
|
||||
|
||||
---
|
||||
|
||||
**Status**: Analysis Complete ✅
|
||||
**Next Action**: Implement Phase 1 fixes
|
||||
Reference in New Issue
Block a user