Skip to content

Commit fae8ae3

Browse files
jonradoffclaude
andcommitted
Security hardening and inline theme version history
Security improvements: - Fix session security: use store's secure options instead of overriding - Consolidate IP extraction: auth manager now uses middleware.GetClientIP with proper trusted proxy configuration - Add CSP headers for public pages with permissive policy for CMS content UI improvements: - Display theme version history inline on /cm/theme page - Remove separate Version History button (now shown below Save Theme) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 093b7f7 commit fae8ae3

File tree

5 files changed

+89
-42
lines changed

5 files changed

+89
-42
lines changed

build.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"version": "1.0"
2+
"version": "1.1"
33
}

internal/auth/auth.go

Lines changed: 24 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"strings"
88

99
"lightcms/internal/database"
10+
"lightcms/internal/middleware"
1011

1112
"github.com/gorilla/sessions"
1213
"golang.org/x/crypto/bcrypt"
@@ -19,14 +20,25 @@ const (
1920
)
2021

2122
type Manager struct {
22-
db *database.DB
23-
store *sessions.CookieStore
23+
db *database.DB
24+
store *sessions.CookieStore
25+
proxyConfig *middleware.TrustedProxyConfig
2426
}
2527

2628
func NewManager(store *sessions.CookieStore, db *database.DB) *Manager {
2729
return &Manager{
28-
db: db,
29-
store: store,
30+
db: db,
31+
store: store,
32+
proxyConfig: middleware.DefaultCloudConfig(),
33+
}
34+
}
35+
36+
// NewManagerWithProxyConfig creates a new auth manager with custom proxy configuration
37+
func NewManagerWithProxyConfig(store *sessions.CookieStore, db *database.DB, proxyConfig *middleware.TrustedProxyConfig) *Manager {
38+
return &Manager{
39+
db: db,
40+
store: store,
41+
proxyConfig: proxyConfig,
3042
}
3143
}
3244

@@ -119,7 +131,7 @@ func (m *Manager) IsDefaultPassword(ctx context.Context) bool {
119131

120132
// CheckRateLimit checks if the IP is rate limited
121133
func (m *Manager) CheckRateLimit(ctx context.Context, r *http.Request) (bool, string) {
122-
ip := getClientIP(r)
134+
ip := m.getClientIP(r)
123135
locked, duration := m.db.IsLoginLocked(ctx, ip)
124136
if locked {
125137
minutes := int(duration.Minutes())
@@ -134,13 +146,13 @@ func (m *Manager) CheckRateLimit(ctx context.Context, r *http.Request) (bool, st
134146

135147
// RecordFailedLogin records a failed login attempt
136148
func (m *Manager) RecordFailedLogin(ctx context.Context, r *http.Request) {
137-
ip := getClientIP(r)
149+
ip := m.getClientIP(r)
138150
m.db.RecordFailedLogin(ctx, ip)
139151
}
140152

141153
// ClearRateLimit clears rate limiting on successful login
142154
func (m *Manager) ClearRateLimit(ctx context.Context, r *http.Request) {
143-
ip := getClientIP(r)
155+
ip := m.getClientIP(r)
144156
m.db.ClearLoginAttempts(ctx, ip)
145157
}
146158

@@ -149,13 +161,9 @@ func (m *Manager) Login(w http.ResponseWriter, r *http.Request) error {
149161
session, err := m.store.Get(r, sessionName)
150162
if err != nil {
151163
log.Printf("[LOGIN] store.Get error: %v, creating fresh session", err)
152-
// Create a completely new session, ignoring any existing cookie
164+
// Create a completely new session, inheriting options from store
153165
session = sessions.NewSession(m.store, sessionName)
154-
session.Options = &sessions.Options{
155-
Path: "/",
156-
MaxAge: 86400 * 7,
157-
HttpOnly: true,
158-
}
166+
session.Options = m.store.Options // Use store's secure options
159167
session.IsNew = true
160168
}
161169
session.Values["authenticated"] = true
@@ -250,29 +258,9 @@ func ValidatePasswordStrength(password string) error {
250258
return nil
251259
}
252260

253-
// getClientIP extracts the client IP address
254-
func getClientIP(r *http.Request) string {
255-
// Check X-Forwarded-For header (for proxies)
256-
forwarded := r.Header.Get("X-Forwarded-For")
257-
if forwarded != "" {
258-
// Take the first IP in the list
259-
parts := strings.Split(forwarded, ",")
260-
return strings.TrimSpace(parts[0])
261-
}
262-
263-
// Check X-Real-IP header
264-
realIP := r.Header.Get("X-Real-IP")
265-
if realIP != "" {
266-
return realIP
267-
}
268-
269-
// Fall back to RemoteAddr
270-
ip := r.RemoteAddr
271-
// Remove port if present
272-
if colonIdx := strings.LastIndex(ip, ":"); colonIdx != -1 {
273-
ip = ip[:colonIdx]
274-
}
275-
return ip
261+
// getClientIP extracts the client IP address using the configured proxy settings
262+
func (m *Manager) getClientIP(r *http.Request) string {
263+
return middleware.GetClientIP(r, m.proxyConfig)
276264
}
277265

278266
func formatDuration(minutes, seconds int) string {

internal/handlers/admin_templates.go

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3013,7 +3013,6 @@ var adminTemplates = map[string]string{
30133013
"theme": adminLayoutStart + `
30143014
<div class="page-header">
30153015
<h1>Theme Settings</h1>
3016-
<a href="/cm/theme/versions" class="btn btn-outline">Version History</a>
30173016
</div>
30183017
{{if .Error}}<div class="error-message">{{.Error}}</div>{{end}}
30193018
{{if .Success}}<div class="success-message">{{.Success}}</div>{{end}}
@@ -3119,6 +3118,45 @@ var adminTemplates = map[string]string{
31193118
</div>
31203119
</form>
31213120
3121+
{{if .Versions}}
3122+
<div class="form-card" style="margin-top: 2rem;">
3123+
<div class="form-section">
3124+
<h3>Version History</h3>
3125+
<p style="color: var(--text-muted); margin-bottom: 1rem;">Previous versions of theme settings are saved automatically when you update.</p>
3126+
<div class="table-container">
3127+
<table>
3128+
<thead>
3129+
<tr>
3130+
<th>Version</th>
3131+
<th>Site Name</th>
3132+
<th>Comment</th>
3133+
<th>Saved</th>
3134+
<th>Actions</th>
3135+
</tr>
3136+
</thead>
3137+
<tbody>
3138+
{{range .Versions}}
3139+
<tr>
3140+
<td>v{{.Version}}</td>
3141+
<td>{{.SiteName}}</td>
3142+
<td style="color: var(--text-muted); font-size: 0.9rem; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{.Comment}}">{{if .Comment}}{{.Comment}}{{else}}-{{end}}</td>
3143+
<td>{{.CreatedAt.Format "Jan 2, 2006 3:04 PM"}}</td>
3144+
<td class="actions">
3145+
<a href="/cm/theme/versions/{{.Version}}" class="btn btn-sm btn-outline">View Diff</a>
3146+
<form method="POST" action="/cm/theme/versions/{{.Version}}/revert" style="display:inline" onsubmit="return confirm('Revert to version {{.Version}}? This will create a new version with the old settings.')">
3147+
{{$.CSRFField}}
3148+
<button type="submit" class="btn btn-sm btn-secondary">Revert</button>
3149+
</form>
3150+
</td>
3151+
</tr>
3152+
{{end}}
3153+
</tbody>
3154+
</table>
3155+
</div>
3156+
</div>
3157+
</div>
3158+
{{end}}
3159+
31223160
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" rel="stylesheet">
31233161
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
31243162
<style>

internal/handlers/handlers.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2363,13 +2363,19 @@ func (h *Handler) ThemeSettings(w http.ResponseWriter, r *http.Request) {
23632363
return
23642364
}
23652365

2366-
settings, err := h.db.GetThemeSettings(r.Context())
2366+
ctx := r.Context()
2367+
settings, err := h.db.GetThemeSettings(ctx)
23672368
if err != nil {
23682369
http.Error(w, err.Error(), http.StatusInternalServerError)
23692370
return
23702371
}
23712372

2372-
h.renderAdmin(w, r, "theme", map[string]interface{}{"Settings": settings})
2373+
versions, _ := h.db.GetThemeVersions(ctx)
2374+
2375+
h.renderAdmin(w, r, "theme", map[string]interface{}{
2376+
"Settings": settings,
2377+
"Versions": versions,
2378+
})
23732379
}
23742380

23752381
func (h *Handler) UpdateTheme(w http.ResponseWriter, r *http.Request) {

internal/middleware/security.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
2121
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
2222
}
2323

24-
// Content Security Policy - restrictive but allows inline styles/scripts for admin
25-
// Note: This is a baseline CSP. For public pages, a stricter CSP could be used
24+
// Content Security Policy
2625
if strings.HasPrefix(r.URL.Path, "/cm") {
2726
// Admin pages - need inline scripts/styles for the UI
2827
w.Header().Set("Content-Security-Policy",
@@ -35,6 +34,22 @@ func SecurityHeaders(next http.Handler) http.Handler {
3534
"frame-ancestors 'none'; "+
3635
"base-uri 'self'; "+
3736
"form-action 'self'")
37+
} else if !strings.HasPrefix(r.URL.Path, "/static/") && !strings.HasPrefix(r.URL.Path, "/assets/") {
38+
// Public pages - more permissive for CMS content but still protective
39+
// Allows inline scripts/styles for admin-authored content (analytics, widgets, etc.)
40+
// Allows external resources for embeds, CDN images, etc.
41+
w.Header().Set("Content-Security-Policy",
42+
"default-src 'self'; "+
43+
"script-src 'self' 'unsafe-inline' https:; "+
44+
"style-src 'self' 'unsafe-inline' https:; "+
45+
"font-src 'self' https: data:; "+
46+
"img-src 'self' data: https:; "+
47+
"media-src 'self' https:; "+
48+
"frame-src https:; "+
49+
"connect-src 'self' https:; "+
50+
"frame-ancestors 'none'; "+
51+
"base-uri 'self'; "+
52+
"form-action 'self' https:")
3853
}
3954

4055
next.ServeHTTP(w, r)

0 commit comments

Comments
 (0)