From d800f94df440c285fbe6c3a4bc266da05a6d7753 Mon Sep 17 00:00:00 2001 From: Pulse Monitor Date: Sun, 24 Aug 2025 14:59:58 +0000 Subject: [PATCH] feat: add iframe embedding support for dashboard integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses #222 - Allow Pulse to be embedded in iframes (e.g., Homepage dashboard) - Add AllowEmbedding and AllowedEmbedOrigins settings to SystemSettings - Update security headers to respect embedding configuration - When disabled: X-Frame-Options: DENY, frame-ancestors 'none' - When enabled (same-origin): X-Frame-Options: SAMEORIGIN, frame-ancestors 'self' - When enabled with origins: Adds specified origins to frame-ancestors - Add UI controls in Settings → System → Network Settings - Properly handle CSP frame-ancestors directive for cross-origin embedding Users can now enable iframe embedding and specify allowed origins for embedding Pulse in Homepage or other dashboard applications. --- VERSION | 2 +- .../src/components/Settings/Settings.tsx | 56 ++++++++++++++++- frontend-modern/src/types/config.ts | 2 + internal/api/router.go | 14 ++++- internal/api/security.go | 62 ++++++++++++++++--- internal/api/system_settings.go | 7 +++ internal/config/persistence.go | 2 + 7 files changed, 130 insertions(+), 15 deletions(-) diff --git a/VERSION b/VERSION index 81dd1e5cc..70bc9a9f6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.7.6 \ No newline at end of file +4.7.5 diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 0e51cc9ff..d516b341c 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -103,6 +103,10 @@ const Settings: Component = () => { const [envOverrides, setEnvOverrides] = createSignal>({}); // Connection timeout removed - backend-only setting + // Iframe embedding settings + const [allowEmbedding, setAllowEmbedding] = createSignal(false); + const [allowedEmbedOrigins, setAllowedEmbedOrigins] = createSignal(''); + // Update settings const [versionInfo, setVersionInfo] = createSignal(null); const [updateInfo, setUpdateInfo] = createSignal(null); @@ -360,6 +364,9 @@ const Settings: Component = () => { // Backend defaults to true, so we should respect that setDiscoveryEnabled(systemSettings.discoveryEnabled ?? true); // Default to true if undefined setDiscoverySubnet(systemSettings.discoverySubnet || 'auto'); + // Load embedding settings + setAllowEmbedding(systemSettings.allowEmbedding ?? false); + setAllowedEmbedOrigins(systemSettings.allowedEmbedOrigins || ''); // Load auto-update settings setAutoUpdateEnabled(systemSettings.autoUpdateEnabled || false); setAutoUpdateCheckInterval(systemSettings.autoUpdateCheckInterval || 24); @@ -406,7 +413,9 @@ const Settings: Component = () => { updateChannel: updateChannel(), autoUpdateEnabled: autoUpdateEnabled(), autoUpdateCheckInterval: autoUpdateCheckInterval(), - autoUpdateTime: autoUpdateTime() + autoUpdateTime: autoUpdateTime(), + allowEmbedding: allowEmbedding(), + allowedEmbedOrigins: allowedEmbedOrigins() }); } @@ -1301,6 +1310,51 @@ const Settings: Component = () => { + {/* Iframe Embedding Settings */} +
+ +

Allow Pulse to be embedded in iframes (e.g., Homepage dashboard)

+ +
+
+ { + setAllowEmbedding(e.currentTarget.checked); + setHasUnsavedChanges(true); + }} + class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" + /> + +
+ + +
+ +

Comma-separated list of origins that can embed Pulse (leave empty for same-origin only)

+ { + setAllowedEmbedOrigins(e.currentTarget.value); + setHasUnsavedChanges(true); + }} + placeholder="https://my.domain, https://dashboard.example.com" + class="w-full px-3 py-1.5 text-sm border rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800" + /> +

+ Example: If Pulse is at pulse.my.domain and your dashboard is at my.domain, + add https://my.domain here. +

+
+
+
+
+

Port Configuration: Use systemctl edit pulse diff --git a/frontend-modern/src/types/config.ts b/frontend-modern/src/types/config.ts index ef88ad706..cb609bf3f 100644 --- a/frontend-modern/src/types/config.ts +++ b/frontend-modern/src/types/config.ts @@ -39,6 +39,8 @@ export interface SystemConfig { theme?: string; // Theme preference: 'light' | 'dark' | undefined (system default) discoveryEnabled?: boolean; // Enable/disable network discovery discoverySubnet?: string; // Subnet to scan for discovery (default: 'auto') + allowEmbedding?: boolean; // Allow iframe embedding + allowedEmbedOrigins?: string; // Comma-separated list of allowed origins for embedding } /** diff --git a/internal/api/router.go b/internal/api/router.go index bd0137f90..635e6e367 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -693,8 +693,16 @@ func (r *Router) setupRoutes() { // ServeHTTP implements http.Handler func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { - // Apply security headers first - SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // Load system settings to get embedding configuration + var allowEmbedding bool + var allowedEmbedOrigins string + if systemSettings, err := r.persistence.LoadSystemSettings(); err == nil && systemSettings != nil { + allowEmbedding = systemSettings.AllowEmbedding + allowedEmbedOrigins = systemSettings.AllowedEmbedOrigins + } + + // Apply security headers with embedding configuration + SecurityHeadersWithConfig(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { // Add CORS headers if configured if r.config.AllowedOrigins != "" { w.Header().Set("Access-Control-Allow-Origin", r.config.AllowedOrigins) @@ -904,7 +912,7 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { Str("path", req.URL.Path). Dur("duration", time.Since(start)). Msg("Request handled") - })).ServeHTTP(w, req) + }), allowEmbedding, allowedEmbedOrigins).ServeHTTP(w, req) } diff --git a/internal/api/security.go b/internal/api/security.go index 1b222ee24..32b1cf5d3 100644 --- a/internal/api/security.go +++ b/internal/api/security.go @@ -265,9 +265,26 @@ func IsLockedOut(identifier string) bool { // Security Headers Middleware func SecurityHeaders(next http.Handler) http.Handler { + return SecurityHeadersWithConfig(next, false, "") +} + +// SecurityHeadersWithConfig applies security headers with embedding configuration +func SecurityHeadersWithConfig(next http.Handler, allowEmbedding bool, allowedOrigins string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Prevent clickjacking - w.Header().Set("X-Frame-Options", "DENY") + // Configure clickjacking protection based on embedding settings + if allowEmbedding { + if allowedOrigins != "" { + // Use ALLOW-FROM for specific origins (legacy browsers) + // Note: Most modern browsers ignore this in favor of CSP frame-ancestors + w.Header().Set("X-Frame-Options", "SAMEORIGIN") + } else { + // Allow same-origin embedding + w.Header().Set("X-Frame-Options", "SAMEORIGIN") + } + } else { + // Deny all embedding + w.Header().Set("X-Frame-Options", "DENY") + } // Prevent MIME type sniffing w.Header().Set("X-Content-Type-Options", "nosniff") @@ -275,14 +292,39 @@ func SecurityHeaders(next http.Handler) http.Handler { // Enable XSS protection w.Header().Set("X-XSS-Protection", "1; mode=block") - // Content Security Policy - w.Header().Set("Content-Security-Policy", - "default-src 'self'; "+ - "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+ // Needed for React - "style-src 'self' 'unsafe-inline'; "+ // Needed for inline styles - "img-src 'self' data: blob:; "+ - "connect-src 'self' ws: wss:; "+ // WebSocket support - "font-src 'self' data:;") + // Build Content Security Policy + cspDirectives := []string{ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Needed for React + "style-src 'self' 'unsafe-inline'", // Needed for inline styles + "img-src 'self' data: blob:", + "connect-src 'self' ws: wss:", // WebSocket support + "font-src 'self' data:", + } + + // Add frame-ancestors based on embedding settings + if allowEmbedding { + if allowedOrigins != "" { + // Parse comma-separated origins and add them to frame-ancestors + origins := strings.Split(allowedOrigins, ",") + frameAncestors := "frame-ancestors 'self'" + for _, origin := range origins { + origin = strings.TrimSpace(origin) + if origin != "" { + frameAncestors += " " + origin + } + } + cspDirectives = append(cspDirectives, frameAncestors) + } else { + // Allow same-origin embedding + cspDirectives = append(cspDirectives, "frame-ancestors 'self'") + } + } else { + // Deny all embedding + cspDirectives = append(cspDirectives, "frame-ancestors 'none'") + } + + w.Header().Set("Content-Security-Policy", strings.Join(cspDirectives, "; ")) // Referrer Policy w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") diff --git a/internal/api/system_settings.go b/internal/api/system_settings.go index 32fedc21c..8b461fbd2 100644 --- a/internal/api/system_settings.go +++ b/internal/api/system_settings.go @@ -151,6 +151,10 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter if updates.DiscoverySubnet != "" { settings.DiscoverySubnet = updates.DiscoverySubnet } + // Allow clearing of AllowedEmbedOrigins by setting to empty string + if _, ok := rawRequest["allowedEmbedOrigins"]; ok { + settings.AllowedEmbedOrigins = updates.AllowedEmbedOrigins + } // Boolean fields need special handling since false is a valid value if _, ok := rawRequest["autoUpdateEnabled"]; ok { @@ -159,6 +163,9 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter if _, ok := rawRequest["discoveryEnabled"]; ok { settings.DiscoveryEnabled = updates.DiscoveryEnabled } + if _, ok := rawRequest["allowEmbedding"]; ok { + settings.AllowEmbedding = updates.AllowEmbedding + } // Update the config if settings.PollingInterval > 0 { diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 9f2ddd5d6..c1321b02e 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -414,6 +414,8 @@ type SystemSettings struct { DiscoveryEnabled bool `json:"discoveryEnabled"` DiscoverySubnet string `json:"discoverySubnet,omitempty"` Theme string `json:"theme,omitempty"` // User theme preference: "light", "dark", or empty for system default + AllowEmbedding bool `json:"allowEmbedding"` // Allow iframe embedding + AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding // APIToken removed - now handled via .env file only }