From b77e2ba9fcd7b0717ba8b4704c4e986f932d8af8 Mon Sep 17 00:00:00 2001 From: Mike Johanson Date: Tue, 25 Aug 2026 13:13:07 -0700 Subject: [PATCH] feat(api): send Permissions-Policy header on all responses Console serves the bundled Angular UI itself, so it owns the response headers on that document. Extend the existing nosniff middleware to also send a Permissions-Policy denying the browser APIs the UI never uses. Postman collections check for it. The default keeps clipboard-write and fullscreen at self: the copy buttons and the KVM viewer need them, so a blanket deny-all policy would break both. HTTP_PERMISSIONS_POLICY / http.permissions_policy overrides the value, and an empty one sends no header. Deployments that host the UI elsewhere, or front Console with a gateway that sets its own policy, can own the header instead. --- .env.example | 5 ++ config/config.go | 29 ++++++-- config/config_test.go | 1 + .../console_mps_apis.postman_collection.json | 11 +++ .../console_rps_apis.postman_collection.json | 11 +++ internal/app/app.go | 16 ++++- internal/app/app_test.go | 68 +++++++++++++++++-- 7 files changed, 125 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 627b250ff..87b8d3119 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,11 @@ HTTP_ALLOWED_HEADERS=* # HTTP_ALLOWED_ORIGINS is "*" (CORS forbids credentials with a wildcard). # Same-origin deployments do not need this. HTTP_ALLOW_CREDENTIALS=false +# Permissions-Policy sent on every response. The default denies browser APIs +# the bundled UI never uses and keeps clipboard-write/fullscreen, which the +# copy buttons and the KVM viewer need. Leave empty to send no header, e.g. +# when the UI is hosted elsewhere or a gateway sets its own policy. +# HTTP_PERMISSIONS_POLICY= # TLS # Enable TLS in release if the app terminates TLS itself. If behind an API gateway or LB that provides TLS, set to false. diff --git a/config/config.go b/config/config.go index 9805236d3..c7d1293b7 100644 --- a/config/config.go +++ b/config/config.go @@ -29,6 +29,17 @@ const defaultHost = "localhost" // DefaultSessionCookieName names the HttpOnly cookie holding the session JWT. const DefaultSessionCookieName = "console_session" +// DefaultPermissionsPolicy denies the powerful browser APIs the bundled UI +// never uses, and pins the two it does use to the document's own origin: +// clipboard-write (copy activation URL / key) and fullscreen (KVM viewer). +// Tokens a browser does not recognize are ignored, so listing features with +// uneven support is safe. +const DefaultPermissionsPolicy = "accelerometer=(), autoplay=(), bluetooth=(), camera=(), " + + "display-capture=(), encrypted-media=(), geolocation=(), gyroscope=(), " + + "idle-detection=(), magnetometer=(), microphone=(), midi=(), payment=(), " + + "screen-wake-lock=(), serial=(), usb=(), xr-spatial-tracking=(), " + + "clipboard-write=(self), fullscreen=(self)" + // File modes for the config directory and file (owner-only for the file since // it can carry sensitive settings). const ( @@ -70,7 +81,10 @@ type ( AllowedHeaders []string `env-required:"true" yaml:"allowed_headers" env:"HTTP_ALLOWED_HEADERS"` AllowCredentials bool `yaml:"allow_credentials" env:"HTTP_ALLOW_CREDENTIALS"` WSCompression bool `yaml:"ws_compression" env:"WS_COMPRESSION"` - TLS TLS `yaml:"tls"` + // PermissionsPolicy is sent on every response. Empty disables the + // header so a gateway or the host serving the UI can own it instead. + PermissionsPolicy string `yaml:"permissions_policy" env:"HTTP_PERMISSIONS_POLICY"` + TLS TLS `yaml:"tls"` } // TLS -. @@ -190,12 +204,13 @@ func defaultConfig() *Config { DisableCIRA: true, }, HTTP: HTTP{ - Host: "", - Port: "8181", - AllowedOrigins: []string{"*"}, - AllowedHeaders: []string{"*"}, - AllowCredentials: false, - WSCompression: true, + Host: "", + Port: "8181", + AllowedOrigins: []string{"*"}, + AllowedHeaders: []string{"*"}, + AllowCredentials: false, + WSCompression: true, + PermissionsPolicy: DefaultPermissionsPolicy, TLS: TLS{ Enabled: true, CertFile: "", diff --git a/config/config_test.go b/config/config_test.go index 2bf87c7c0..16c5687b7 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -39,6 +39,7 @@ func TestNewConfig_Defaults(t *testing.T) { //nolint:paralleltest // cannot have assert.Equal(t, "", cfg.Host) assert.Equal(t, "8181", cfg.Port) assert.Equal(t, []string{"*"}, cfg.AllowedOrigins) + assert.Equal(t, DefaultPermissionsPolicy, cfg.PermissionsPolicy) assert.Equal(t, []string{"*"}, cfg.AllowedHeaders) assert.Equal(t, true, cfg.TLS.Enabled) diff --git a/integration-test/collections/console_mps_apis.postman_collection.json b/integration-test/collections/console_mps_apis.postman_collection.json index 5943679a0..c5380f427 100644 --- a/integration-test/collections/console_mps_apis.postman_collection.json +++ b/integration-test/collections/console_mps_apis.postman_collection.json @@ -2692,6 +2692,17 @@ " pm.expect(pm.response.headers.get(\"X-Content-Type-Options\")).to.eql(\"nosniff\");", "});", "", + "// Permissions-Policy is configurable via HTTP_PERMISSIONS_POLICY;", + "// this asserts the shipped default.", + "pm.test(\"Response includes a Permissions-Policy\", function () {", + " var policy = pm.response.headers.get(\"Permissions-Policy\");", + " pm.expect(policy).to.be.a(\"string\");", + " pm.expect(policy).to.include(\"camera=()\");", + " pm.expect(policy).to.include(\"geolocation=()\");", + " pm.expect(policy).to.include(\"clipboard-write=(self)\");", + " pm.expect(policy).to.include(\"fullscreen=(self)\");", + "});", + "", "pm.test(\"Explicit false TLS settings remain false when present\", function () {", " var jsonData = {};", " try {", diff --git a/integration-test/collections/console_rps_apis.postman_collection.json b/integration-test/collections/console_rps_apis.postman_collection.json index 9c8db15a7..a50298914 100644 --- a/integration-test/collections/console_rps_apis.postman_collection.json +++ b/integration-test/collections/console_rps_apis.postman_collection.json @@ -8423,6 +8423,17 @@ " pm.expect(pm.response.headers.get(\"X-Content-Type-Options\")).to.eql(\"nosniff\");", "});", "", + "// Permissions-Policy is configurable via HTTP_PERMISSIONS_POLICY;", + "// this asserts the shipped default.", + "pm.test(\"Response includes a Permissions-Policy\", function () {", + " var policy = pm.response.headers.get(\"Permissions-Policy\");", + " pm.expect(policy).to.be.a(\"string\");", + " pm.expect(policy).to.include(\"camera=()\");", + " pm.expect(policy).to.include(\"geolocation=()\");", + " pm.expect(policy).to.include(\"clipboard-write=(self)\");", + " pm.expect(policy).to.include(\"fullscreen=(self)\");", + "});", + "", "pm.test(\"Explicit false TLS settings remain false when present\", function () {", " var jsonData = {};", " try {", diff --git a/internal/app/app.go b/internal/app/app.go index 2c48620b5..1055b333b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -76,7 +76,7 @@ func setupHTTPHandler(cfg *config.Config, log logger.Interface, usecases *usecas handler := gin.New() // Ahead of CORS on purpose: the CORS middleware answers preflights and // rejects disallowed origins itself, so anything after it never runs. - handler.Use(securityHeaders()) + handler.Use(securityHeaders(cfg.PermissionsPolicy)) defaultConfig := cors.DefaultConfig() defaultConfig.AllowOrigins = cfg.AllowedOrigins @@ -105,11 +105,21 @@ func setupHTTPHandler(cfg *config.Config, log logger.Interface, usecases *usecas return handler } -// securityHeaders sets X-Content-Type-Options: nosniff to stop MIME sniffing. -func securityHeaders() gin.HandlerFunc { +// securityHeaders sets X-Content-Type-Options: nosniff to stop MIME sniffing, +// and Permissions-Policy to deny browser APIs the bundled UI never uses. +// +// The Permissions-Policy value is configurable and an empty one skips the +// header: Console only serves the HTML document when the UI is embedded, so +// deployments that host the UI elsewhere, or front Console with a gateway that +// sets its own policy, need to be able to hand the header back. +func securityHeaders(permissionsPolicy string) gin.HandlerFunc { return func(c *gin.Context) { c.Header("X-Content-Type-Options", "nosniff") + if permissionsPolicy != "" { + c.Header("Permissions-Policy", permissionsPolicy) + } + c.Next() } } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 16d6f9433..2bfe900b0 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -16,11 +16,12 @@ import ( "github.com/device-management-toolkit/console/pkg/logger" ) -func TestSecurityHeadersSetsNoSniff(t *testing.T) { - t.Parallel() - +// headerTestEngine wires the middleware onto the three response shapes the +// scanner reached: a JSON route, the SPA fallback that serves index.html, and +// an aborted request. +func headerTestEngine(permissionsPolicy string) *gin.Engine { r := gin.New() - r.Use(securityHeaders()) + r.Use(securityHeaders(permissionsPolicy)) r.GET("/ok", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"a": 1}) }) // SPA fallback, the path the finding was reported against. r.NoRoute(func(c *gin.Context) { @@ -30,15 +31,68 @@ func TestSecurityHeadersSetsNoSniff(t *testing.T) { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) }) - for _, path := range []string{"/ok", "/no/such/route", "/unauth"} { + return r +} + +var headerTestPaths = []string{"/ok", "/no/such/route", "/unauth"} + +func TestSecurityHeadersSetsNoSniff(t *testing.T) { + t.Parallel() + + r := headerTestEngine(config.DefaultPermissionsPolicy) + + for _, path := range headerTestPaths { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, http.NoBody)) + + require.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"), path) + } +} + +func TestSecurityHeadersSetsPermissionsPolicy(t *testing.T) { + t.Parallel() + + r := headerTestEngine(config.DefaultPermissionsPolicy) + + for _, path := range headerTestPaths { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, http.NoBody)) + + require.Equal(t, config.DefaultPermissionsPolicy, w.Header().Get("Permissions-Policy"), path) + } +} + +// The bundled UI uses both of these; denying them outright would break the KVM +// viewer and the copy-to-clipboard buttons. +func TestDefaultPermissionsPolicyKeepsFeaturesTheUIUses(t *testing.T) { + t.Parallel() + + for _, feature := range []string{"clipboard-write=(self)", "fullscreen=(self)"} { + require.Contains(t, config.DefaultPermissionsPolicy, feature) + } + + for _, denied := range []string{"camera=()", "microphone=()", "geolocation=()", "payment=()", "usb=()"} { + require.Contains(t, config.DefaultPermissionsPolicy, denied) + } +} + +// An empty policy hands the header back to whoever serves the UI or fronts +// Console, without dropping nosniff. +func TestSecurityHeadersOmitsEmptyPermissionsPolicy(t *testing.T) { + t.Parallel() + + r := headerTestEngine("") + + for _, path := range headerTestPaths { w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, http.NoBody)) + require.Empty(t, w.Header().Get("Permissions-Policy"), path) require.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"), path) } } -// The nosniff middleware has to sit ahead of CORS in the engine chain: the CORS +// The security-header middleware has to sit ahead of CORS in the engine chain: the CORS // middleware answers preflights and rejects disallowed origins itself, so // anything registered after it never runs on those responses. // @@ -47,6 +101,7 @@ func TestSetupHTTPHandlerSetsNoSniffAheadOfCORS(t *testing.T) { cfg := &config.Config{} cfg.AllowedOrigins = []string{"https://allowed.example"} cfg.AllowedHeaders = []string{"Content-Type"} + cfg.PermissionsPolicy = config.DefaultPermissionsPolicy cfg.Disabled = true prev := config.ConsoleConfig @@ -82,6 +137,7 @@ func TestSetupHTTPHandlerSetsNoSniffAheadOfCORS(t *testing.T) { require.Equal(t, tc.wantCode, w.Code, tc.name) require.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"), tc.name) + require.Equal(t, config.DefaultPermissionsPolicy, w.Header().Get("Permissions-Policy"), tc.name) } }