Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 22 additions & 7 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 -.
Expand Down Expand Up @@ -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: "",
Expand Down
1 change: 1 addition & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {",
Expand Down
16 changes: 13 additions & 3 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
Expand Down
68 changes: 62 additions & 6 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
//
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
Loading