feat: add iframe embedding support for dashboard integration

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.
This commit is contained in:
Pulse Monitor
2025-08-24 14:59:58 +00:00
parent f297dcf5d7
commit d800f94df4
7 changed files with 130 additions and 15 deletions
+1 -1
View File
@@ -1 +1 @@
4.7.6
4.7.5
@@ -103,6 +103,10 @@ const Settings: Component = () => {
const [envOverrides, setEnvOverrides] = createSignal<Record<string, boolean>>({});
// Connection timeout removed - backend-only setting
// Iframe embedding settings
const [allowEmbedding, setAllowEmbedding] = createSignal(false);
const [allowedEmbedOrigins, setAllowedEmbedOrigins] = createSignal('');
// Update settings
const [versionInfo, setVersionInfo] = createSignal<VersionInfo | null>(null);
const [updateInfo, setUpdateInfo] = createSignal<UpdateInfo | null>(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 = () => {
</div>
</div>
{/* Iframe Embedding Settings */}
<div class="mt-4">
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">Iframe Embedding</label>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-2">Allow Pulse to be embedded in iframes (e.g., Homepage dashboard)</p>
<div class="space-y-3">
<div class="flex items-center gap-2">
<input
type="checkbox"
id="allowEmbedding"
checked={allowEmbedding()}
onChange={(e) => {
setAllowEmbedding(e.currentTarget.checked);
setHasUnsavedChanges(true);
}}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
/>
<label for="allowEmbedding" class="text-sm text-gray-700 dark:text-gray-300">
Allow iframe embedding
</label>
</div>
<Show when={allowEmbedding()}>
<div>
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">Allowed Embed Origins (optional)</label>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Comma-separated list of origins that can embed Pulse (leave empty for same-origin only)</p>
<input
type="text"
value={allowedEmbedOrigins()}
onChange={(e) => {
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"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Example: If Pulse is at <code>pulse.my.domain</code> and your dashboard is at <code>my.domain</code>,
add <code>https://my.domain</code> here.
</p>
</div>
</Show>
</div>
</div>
<div class="mt-3 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p class="text-xs text-amber-800 dark:text-amber-200 mb-2">
<strong>Port Configuration:</strong> Use <code class="font-mono bg-amber-100 dark:bg-amber-800 px-1 rounded">systemctl edit pulse</code>
+2
View File
@@ -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
}
/**
+11 -3
View File
@@ -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)
}
+52 -10
View File
@@ -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")
+7
View File
@@ -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 {
+2
View File
@@ -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
}