mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 11:46:28 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user