From 0f27e2f18bec97a7b2bcddeb14fed5d6aded20df Mon Sep 17 00:00:00 2001 From: Neal Ormsbee Date: Wed, 22 Jan 2025 16:16:36 -0700 Subject: [PATCH 1/4] Add options to values.yaml and the nginx template for injecting extra add_header directives at the server and location levels. I haven't gone and stubbed this out for every location directive yet - I'd like directional feedback on the concept. The primary motivation here is that security tools (correctly) flag that `Access-Control-Allow-Origin '*'` is, in many cases, overly-permissive, and that the absence of a `Content-Security-Policy` header opens attack vectors for XSS and clickjacking. The default values supplied in `values.yaml` with this commit keep the CORS behavior consistent, by defaulting to the permissive approach. Users will now be able to override this permissive configuration to harden their application. The default values for CSP disallow iframe embedding to prevent clickjacking, and forbid pulling in sources (scripts, images, etc) from domains other than self, and Userway (an accessibility application leveraged by some Kubecost users). Users who embed Kubecost in an iframe in their own context would need to update their Helm charts to allow this behavior again. Signed-off-by: Neal Ormsbee --- ...analyzer-frontend-config-map-template.yaml | 52 +++++++++++++------ cost-analyzer/values.yaml | 34 ++++++++++++ 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/cost-analyzer/templates/cost-analyzer-frontend-config-map-template.yaml b/cost-analyzer/templates/cost-analyzer-frontend-config-map-template.yaml index 80f65e3d2..b67635f3d 100755 --- a/cost-analyzer/templates/cost-analyzer-frontend-config-map-template.yaml +++ b/cost-analyzer/templates/cost-analyzer-frontend-config-map-template.yaml @@ -154,6 +154,9 @@ data: index index.html; add_header Cache-Control "must-revalidate"; + {{- range .Values.kubecostFrontend.nginxHeaders.server }} + add_header {{ . }} + {{- end }} {{- if .Values.kubecostFrontend.extraServerConfig }} {{- .Values.kubecostFrontend.extraServerConfig | toString | nindent 8 -}} @@ -170,6 +173,9 @@ data: add_header Cache-Control "max-age=0"; location / { + {{- range .Values.kubecostFrontend.nginxHeaders.location.root }} + add_header {{ . }} + {{- end }} auth_request /auth; proxy_redirect off; proxy_http_version 1.1; @@ -183,6 +189,9 @@ data: # need to be served outside of the auth middleware location /healthz { add_header 'Content-Type' 'text/plain'; + {{- range .Values.kubecostFrontend.nginxHeaders.location.healthz }} + add_header {{ . }} + {{- end }} return 200 "healthy\n"; } location = /teamsError.html { } @@ -224,6 +233,9 @@ data: {{- end }} location /model/ { + {{- range .Values.kubecostFrontend.nginxHeaders.location.model }} + add_header {{ . }} + {{- end }} proxy_connect_timeout {{ .Values.kubecostFrontend.timeoutSeconds | default 300 }}; proxy_send_timeout {{ .Values.kubecostFrontend.timeoutSeconds | default 300 }}; proxy_read_timeout {{ .Values.kubecostFrontend.timeoutSeconds | default 300 }}; @@ -240,8 +252,9 @@ data: location ~ ^/(turndown|cluster)/ { - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always; + {{- range .Values.kubecostFrontend.nginxHeaders.location.clusterTurndown }} + add_header {{ . }} + {{- end }} {{- if .Values.clusterController }} {{- if .Values.clusterController.enabled }} {{- if or .Values.saml .Values.oidc }} @@ -1473,8 +1486,9 @@ data: {{- end }} location = /model/hideOrphanedResources { default_type 'application/json'; - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always; + {{- range .Values.kubecostFrontend.nginxHeaders.location.hideOrphanedResources }} + add_header {{ . }} + {{- end }} {{- if .Values.kubecostFrontend.hideOrphanedResources }} return 200 '{"hideOrphanedResources": "true"}'; {{- else }} @@ -1483,8 +1497,9 @@ data: } location = /model/hideDiagnostics { default_type 'application/json'; - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always; + {{- range .Values.kubecostFrontend.nginxHeaders.location.hideDiagnostics }} + add_header {{ . }} + {{- end }} {{- if .Values.kubecostFrontend.hideDiagnostics }} return 200 '{"hideDiagnostics": "true"}'; {{- else }} @@ -1500,8 +1515,9 @@ data: location /model/multi-cluster-diagnostics-enabled { default_type 'application/json'; - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always; + {{- range .Values.kubecostFrontend.nginxHeaders.location.multiClusterDiagnosticsEnabled }} + add_header {{ . }} + {{- end }} {{- if and .Values.diagnostics.enabled .Values.diagnostics.primary.enabled }} {{- if or (not (empty .Values.kubecostModel.federatedStorageConfigSecret )) .Values.kubecostModel.federatedStorageConfig }} return 200 '{"multiClusterDiagnosticsEnabled": true}'; @@ -1521,8 +1537,9 @@ data: # Deployment, we should forward that path to the K8s Service. location /model/diagnostics/multicluster { default_type 'application/json'; - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always; + {{- range .Values.kubecostFrontend.nginxHeaders.location.multiClusterDiagnostics }} + add_header {{ . }} + {{- end }} proxy_read_timeout 300; proxy_pass http://multi-cluster-diagnostics/status; proxy_redirect off; @@ -1533,8 +1550,9 @@ data: # simple alias for support location /mcd { default_type 'application/json'; - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always; + {{- range .Values.kubecostFrontend.nginxHeaders.location.multiClusterDiagnostics }} + add_header {{ . }} + {{- end }} proxy_read_timeout 300; proxy_pass http://multi-cluster-diagnostics/status?window=7d; proxy_redirect off; @@ -1554,8 +1572,9 @@ data: {{- if .Values.forecasting.enabled }} location /forecasting { default_type 'application/json'; - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always; + {{- range .Values.kubecostFrontend.nginxHeaders.location.forecastingEnabled }} + add_header {{ . }} + {{- end }} proxy_read_timeout 300; proxy_pass http://forecasting/; proxy_redirect off; @@ -1572,8 +1591,9 @@ data: location /model/productConfigs { default_type 'application/json'; - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always; + {{- range .Values.kubecostFrontend.nginxHeaders.location.productConfigs }} + add_header {{ . }} + {{- end }} return 200 '\n { "ssoConfigured": "{{ template "ssoEnabled" . }}", diff --git a/cost-analyzer/values.yaml b/cost-analyzer/values.yaml index 5fe2dd5e0..951456c50 100644 --- a/cost-analyzer/values.yaml +++ b/cost-analyzer/values.yaml @@ -537,6 +537,39 @@ kubecostFrontend: # fqdn: kubecost-multi-diag.kubecost.svc.cluster.local:9007 # clusterController: # fqdn: cluster-controller.kubecost.svc.cluster.local:9731 +# + + # Configurable, optional headers for nginx responses. + nginxHeaders: + # applied to all route locations + server: + - Content-Security-Policy "default-src 'self' cdn.userway.org; frame-ancestors 'none';"; + # per-location headers + location: + root: [] + healthz: [] + model: [] + clusterTurndown: + - "'Access-Control-Allow-Origin' '*' always;" + - "'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always;" + hideOrphanedResources: + - "'Access-Control-Allow-Origin' '*' always;" + - "'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always;" + hideDiagnostics: + - "'Access-Control-Allow-Origin' '*' always;" + - "'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always;" + multiClusterDiagnosticsEnabled: + - "'Access-Control-Allow-Origin' '*' always;" + - "'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always;" + multiClusterDiagnostics: + - "'Access-Control-Allow-Origin' '*' always;" + - "'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always;" + forecastingEnabled: + - "'Access-Control-Allow-Origin' '*' always;" + - "'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always;" + productConfigs: + - "'Access-Control-Allow-Origin' '*' always;" + - "'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always;" # Kubecost Metrics deploys a separate pod which will emit kubernetes specific metrics required # by the cost-model. This pod is designed to remain active and decoupled from the cost-model itself. @@ -2458,3 +2491,4 @@ extraObjects: [] # -- Optional override for the image used for the basic health test container # basicHealth: # fullImageName: alpine/k8s:1.26.9 + From 31b37be7e8aaa5ed9f008182908676f4084d387e Mon Sep 17 00:00:00 2001 From: jesse goodier <31039225+jessegoodier@users.noreply.github.com> Date: Thu, 23 Jan 2025 09:30:56 -0500 Subject: [PATCH 2/4] Signed-off-by: jesse goodier <31039225+jessegoodier@users.noreply.github.com> configurable cache control --- .../templates/cost-analyzer-frontend-config-map-template.yaml | 1 - cost-analyzer/values.yaml | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/cost-analyzer/templates/cost-analyzer-frontend-config-map-template.yaml b/cost-analyzer/templates/cost-analyzer-frontend-config-map-template.yaml index b67635f3d..704f3e05d 100755 --- a/cost-analyzer/templates/cost-analyzer-frontend-config-map-template.yaml +++ b/cost-analyzer/templates/cost-analyzer-frontend-config-map-template.yaml @@ -153,7 +153,6 @@ data: root /var/www; index index.html; - add_header Cache-Control "must-revalidate"; {{- range .Values.kubecostFrontend.nginxHeaders.server }} add_header {{ . }} {{- end }} diff --git a/cost-analyzer/values.yaml b/cost-analyzer/values.yaml index 951456c50..3803a8241 100644 --- a/cost-analyzer/values.yaml +++ b/cost-analyzer/values.yaml @@ -539,11 +539,12 @@ kubecostFrontend: # fqdn: cluster-controller.kubecost.svc.cluster.local:9731 # - # Configurable, optional headers for nginx responses. + # Configurable headers for nginx responses. nginxHeaders: # applied to all route locations server: - Content-Security-Policy "default-src 'self' cdn.userway.org; frame-ancestors 'none';"; + - Cache-Control "must-revalidate"; # per-location headers location: root: [] @@ -2491,4 +2492,3 @@ extraObjects: [] # -- Optional override for the image used for the basic health test container # basicHealth: # fullImageName: alpine/k8s:1.26.9 - From 5a1532deb0465256b02d066db8f8cd9ebe235025 Mon Sep 17 00:00:00 2001 From: Neal Ormsbee Date: Thu, 23 Jan 2025 15:31:11 -0700 Subject: [PATCH 3/4] Add additional whitelist sources to CSP Signed-off-by: Neal Ormsbee --- cost-analyzer/values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cost-analyzer/values.yaml b/cost-analyzer/values.yaml index 3803a8241..d884b4b27 100644 --- a/cost-analyzer/values.yaml +++ b/cost-analyzer/values.yaml @@ -543,7 +543,7 @@ kubecostFrontend: nginxHeaders: # applied to all route locations server: - - Content-Security-Policy "default-src 'self' cdn.userway.org; frame-ancestors 'none';"; + - Content-Security-Policy "default-src 'self' 'unsafe-inline' api.userway.org cdn.userway.org api-js.mixpanel.com keyper.kubecost.com; frame-ancestors 'none'; font-src 'self' data:;"; - Cache-Control "must-revalidate"; # per-location headers location: From b2f2d0f9cb9c36948a704eafdb0b295f6b77a9fa Mon Sep 17 00:00:00 2001 From: Neal Ormsbee Date: Thu, 23 Jan 2025 16:49:20 -0700 Subject: [PATCH 4/4] Remove support for per-route header configs. Only use global server header configs for now Signed-off-by: Neal Ormsbee --- ...analyzer-frontend-config-map-template.yaml | 33 ------------------- cost-analyzer/values.yaml | 28 ++-------------- 2 files changed, 2 insertions(+), 59 deletions(-) diff --git a/cost-analyzer/templates/cost-analyzer-frontend-config-map-template.yaml b/cost-analyzer/templates/cost-analyzer-frontend-config-map-template.yaml index 704f3e05d..ccb262034 100755 --- a/cost-analyzer/templates/cost-analyzer-frontend-config-map-template.yaml +++ b/cost-analyzer/templates/cost-analyzer-frontend-config-map-template.yaml @@ -172,9 +172,6 @@ data: add_header Cache-Control "max-age=0"; location / { - {{- range .Values.kubecostFrontend.nginxHeaders.location.root }} - add_header {{ . }} - {{- end }} auth_request /auth; proxy_redirect off; proxy_http_version 1.1; @@ -188,9 +185,6 @@ data: # need to be served outside of the auth middleware location /healthz { add_header 'Content-Type' 'text/plain'; - {{- range .Values.kubecostFrontend.nginxHeaders.location.healthz }} - add_header {{ . }} - {{- end }} return 200 "healthy\n"; } location = /teamsError.html { } @@ -232,9 +226,6 @@ data: {{- end }} location /model/ { - {{- range .Values.kubecostFrontend.nginxHeaders.location.model }} - add_header {{ . }} - {{- end }} proxy_connect_timeout {{ .Values.kubecostFrontend.timeoutSeconds | default 300 }}; proxy_send_timeout {{ .Values.kubecostFrontend.timeoutSeconds | default 300 }}; proxy_read_timeout {{ .Values.kubecostFrontend.timeoutSeconds | default 300 }}; @@ -251,9 +242,6 @@ data: location ~ ^/(turndown|cluster)/ { - {{- range .Values.kubecostFrontend.nginxHeaders.location.clusterTurndown }} - add_header {{ . }} - {{- end }} {{- if .Values.clusterController }} {{- if .Values.clusterController.enabled }} {{- if or .Values.saml .Values.oidc }} @@ -1485,9 +1473,6 @@ data: {{- end }} location = /model/hideOrphanedResources { default_type 'application/json'; - {{- range .Values.kubecostFrontend.nginxHeaders.location.hideOrphanedResources }} - add_header {{ . }} - {{- end }} {{- if .Values.kubecostFrontend.hideOrphanedResources }} return 200 '{"hideOrphanedResources": "true"}'; {{- else }} @@ -1496,9 +1481,6 @@ data: } location = /model/hideDiagnostics { default_type 'application/json'; - {{- range .Values.kubecostFrontend.nginxHeaders.location.hideDiagnostics }} - add_header {{ . }} - {{- end }} {{- if .Values.kubecostFrontend.hideDiagnostics }} return 200 '{"hideDiagnostics": "true"}'; {{- else }} @@ -1514,9 +1496,6 @@ data: location /model/multi-cluster-diagnostics-enabled { default_type 'application/json'; - {{- range .Values.kubecostFrontend.nginxHeaders.location.multiClusterDiagnosticsEnabled }} - add_header {{ . }} - {{- end }} {{- if and .Values.diagnostics.enabled .Values.diagnostics.primary.enabled }} {{- if or (not (empty .Values.kubecostModel.federatedStorageConfigSecret )) .Values.kubecostModel.federatedStorageConfig }} return 200 '{"multiClusterDiagnosticsEnabled": true}'; @@ -1536,9 +1515,6 @@ data: # Deployment, we should forward that path to the K8s Service. location /model/diagnostics/multicluster { default_type 'application/json'; - {{- range .Values.kubecostFrontend.nginxHeaders.location.multiClusterDiagnostics }} - add_header {{ . }} - {{- end }} proxy_read_timeout 300; proxy_pass http://multi-cluster-diagnostics/status; proxy_redirect off; @@ -1549,9 +1525,6 @@ data: # simple alias for support location /mcd { default_type 'application/json'; - {{- range .Values.kubecostFrontend.nginxHeaders.location.multiClusterDiagnostics }} - add_header {{ . }} - {{- end }} proxy_read_timeout 300; proxy_pass http://multi-cluster-diagnostics/status?window=7d; proxy_redirect off; @@ -1571,9 +1544,6 @@ data: {{- if .Values.forecasting.enabled }} location /forecasting { default_type 'application/json'; - {{- range .Values.kubecostFrontend.nginxHeaders.location.forecastingEnabled }} - add_header {{ . }} - {{- end }} proxy_read_timeout 300; proxy_pass http://forecasting/; proxy_redirect off; @@ -1590,9 +1560,6 @@ data: location /model/productConfigs { default_type 'application/json'; - {{- range .Values.kubecostFrontend.nginxHeaders.location.productConfigs }} - add_header {{ . }} - {{- end }} return 200 '\n { "ssoConfigured": "{{ template "ssoEnabled" . }}", diff --git a/cost-analyzer/values.yaml b/cost-analyzer/values.yaml index d884b4b27..d0fe63008 100644 --- a/cost-analyzer/values.yaml +++ b/cost-analyzer/values.yaml @@ -543,34 +543,10 @@ kubecostFrontend: nginxHeaders: # applied to all route locations server: + - "'Access-Control-Allow-Origin' '*' always;" + - "'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always;" - Content-Security-Policy "default-src 'self' 'unsafe-inline' api.userway.org cdn.userway.org api-js.mixpanel.com keyper.kubecost.com; frame-ancestors 'none'; font-src 'self' data:;"; - Cache-Control "must-revalidate"; - # per-location headers - location: - root: [] - healthz: [] - model: [] - clusterTurndown: - - "'Access-Control-Allow-Origin' '*' always;" - - "'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always;" - hideOrphanedResources: - - "'Access-Control-Allow-Origin' '*' always;" - - "'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always;" - hideDiagnostics: - - "'Access-Control-Allow-Origin' '*' always;" - - "'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always;" - multiClusterDiagnosticsEnabled: - - "'Access-Control-Allow-Origin' '*' always;" - - "'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always;" - multiClusterDiagnostics: - - "'Access-Control-Allow-Origin' '*' always;" - - "'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always;" - forecastingEnabled: - - "'Access-Control-Allow-Origin' '*' always;" - - "'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always;" - productConfigs: - - "'Access-Control-Allow-Origin' '*' always;" - - "'Access-Control-Allow-Methods' 'GET, PUT, POST, DELETE, OPTIONS' always;" # Kubecost Metrics deploys a separate pod which will emit kubernetes specific metrics required # by the cost-model. This pod is designed to remain active and decoupled from the cost-model itself.