diff --git a/internal/api/server.go b/internal/api/server.go index d6e2fb83c..b46fb4216 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -50,6 +50,17 @@ import ( const oauthCallbackSuccessHTML = `Authentication successful

Authentication successful!

You can close this window.

This window will close automatically in 5 seconds.

` +var corsExposedResponseHeaders = []string{ + "X-CPA-VERSION", + "X-CPA-COMMIT", + "X-CPA-BUILD-DATE", + "X-CPA-SUPPORT-PLUGIN", + "X-CPA-HOME-VERSION", + "X-CPA-HOME-BUILD-DATE", + "X-SERVER-VERSION", + "X-SERVER-BUILD-DATE", +} + type serverOptionConfig struct { extraMiddleware []gin.HandlerFunc engineConfigurator func(*gin.Engine) @@ -1466,6 +1477,7 @@ func corsMiddleware() gin.HandlerFunc { c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "*") + c.Header("Access-Control-Expose-Headers", strings.Join(corsExposedResponseHeaders, ", ")) if c.Request.Method == "OPTIONS" { c.AbortWithStatus(http.StatusNoContent) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 5669c07ba..b3b4eaa23 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -92,6 +92,36 @@ func TestHealthz(t *testing.T) { }) } +func TestManagementResponseExposesPluginSupportHeaderForCORS(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "test-management-key") + + server := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/v0/management/config", nil) + req.Header.Set("Origin", "http://127.0.0.1:5173") + rr := httptest.NewRecorder() + server.engine.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusUnauthorized, rr.Body.String()) + } + if got := rr.Header().Get("X-CPA-SUPPORT-PLUGIN"); got != pluginhost.SupportPluginHeaderValue() { + t.Fatalf("X-CPA-SUPPORT-PLUGIN = %q, want %q", got, pluginhost.SupportPluginHeaderValue()) + } + + exposedHeaders := make(map[string]struct{}) + for _, headerName := range strings.Split(rr.Header().Get("Access-Control-Expose-Headers"), ",") { + headerName = strings.ToLower(strings.TrimSpace(headerName)) + if headerName != "" { + exposedHeaders[headerName] = struct{}{} + } + } + for _, headerName := range corsExposedResponseHeaders { + if _, ok := exposedHeaders[strings.ToLower(headerName)]; !ok { + t.Fatalf("Access-Control-Expose-Headers missing %s: %q", headerName, rr.Header().Get("Access-Control-Expose-Headers")) + } + } +} + func TestNewServerWithPluginHostInjectsHandlerInterceptors(t *testing.T) { host := pluginhost.New() server := newTestServerWithOptions(t, WithPluginHost(host))