fix(ai): allow OpenCode UI to be embedded in iframe

The OpenCode reverse proxy now properly modifies response headers to
allow iframe embedding within Pulse's AI panel:

- ai_handler.go: Add ModifyResponse to strip X-Frame-Options and modify
  CSP frame-ancestors from OpenCode's responses
- security.go: Skip frame-related security headers for /opencode/ paths
  since the proxy manages its own headers

This fixes the "refused to connect" error when opening the AI sidebar.
This commit is contained in:
rcourtman
2026-01-14 10:28:30 +00:00
parent 668f00a905
commit 875d244b66
2 changed files with 39 additions and 3 deletions

View File

@@ -421,6 +421,30 @@ func (h *AIHandler) HandleOpenCodeUI(w http.ResponseWriter, r *http.Request) {
req.Host = target.Host
}
// Modify response to allow embedding in iframe
// OpenCode sets X-Frame-Options: DENY and CSP frame-ancestors 'none'
// which prevents embedding - we need to remove these for the Pulse panel
proxy.ModifyResponse = func(resp *http.Response) error {
log.Debug().Msg("ModifyResponse called")
// Remove X-Frame-Options to allow iframe embedding
resp.Header.Del("X-Frame-Options")
// Handle multiple CSP headers - get all values, modify, and set back
cspHeaders := resp.Header.Values("Content-Security-Policy")
if len(cspHeaders) > 0 {
// Delete all existing CSP headers
resp.Header.Del("Content-Security-Policy")
// Add back modified versions
for _, csp := range cspHeaders {
// Replace frame-ancestors 'none' with 'self' to allow embedding
modified := strings.ReplaceAll(csp, "frame-ancestors 'none'", "frame-ancestors 'self'")
resp.Header.Add("Content-Security-Policy", modified)
}
}
return nil
}
// Handle WebSocket upgrades
if r.Header.Get("Upgrade") == "websocket" {
proxy.ServeHTTP(w, r)

View File

@@ -409,8 +409,14 @@ func ResetLockout(identifier string) {
// 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) {
// Skip frame-related headers for /opencode/ paths - these are managed by the proxy
// The OpenCode proxy modifies headers to allow embedding within Pulse's AI panel
isOpenCodePath := strings.HasPrefix(r.URL.Path, "/opencode")
// Configure clickjacking protection based on embedding settings
if allowEmbedding {
if isOpenCodePath {
// OpenCode proxy manages its own iframe headers
} else if allowEmbedding {
// When embedding is allowed, don't set X-Frame-Options header
// This allows embedding from any origin
// Security note: User explicitly enabled this for iframe embedding
@@ -436,7 +442,10 @@ func SecurityHeadersWithConfig(next http.Handler, allowEmbedding bool, allowedOr
}
// Add frame-ancestors based on embedding settings
if allowEmbedding {
// Skip for /opencode/ paths - the proxy manages its own CSP
if isOpenCodePath {
// OpenCode proxy manages its own CSP headers
} else if allowEmbedding {
if allowedOrigins != "" {
// Parse comma-separated origins and add them to frame-ancestors
origins := strings.Split(allowedOrigins, ",")
@@ -457,7 +466,10 @@ func SecurityHeadersWithConfig(next http.Handler, allowEmbedding bool, allowedOr
cspDirectives = append(cspDirectives, "frame-ancestors 'none'")
}
w.Header().Set("Content-Security-Policy", strings.Join(cspDirectives, "; "))
// Only set CSP for non-OpenCode paths (OpenCode proxy manages its own headers)
if !isOpenCodePath {
w.Header().Set("Content-Security-Policy", strings.Join(cspDirectives, "; "))
}
// Referrer Policy
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")