From 07af263997b9643ba60a041b880976a8f651b5b0 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 16 Mar 2026 16:21:17 +0200 Subject: [PATCH 1/5] Add configurable security headers and env-var-backed CORS configuration Fix security issues from penetration test report: - M2: Add configurable X-Frame-Options and CSP headers (disabled by default) - L2: Add X-Content-Type-Options and Referrer-Policy headers (enabled by default) - L3: Make CORS allowed-origin-patterns configurable via TB_CORS_* env vars Root cause: ThingsboardSecurityConfiguration called .disable() on the entire HeadersConfigurer, which removed ALL security headers including Cache-Control. Fix uses defaultsDisabled() + selective header enablement via a new HttpSecurityHeadersCustomizer component. Both Spring Boot (tb-node) and Express.js (web-ui) share the same SECURITY_HEADERS_* environment variables for consistent configuration across monolith and microservice deployments. --- .../config/HttpSecurityHeadersCustomizer.java | 59 +++++++++++++++++ .../config/HttpSecurityHeadersProperties.java | 56 ++++++++++++++++ .../TbRuleEngineSecurityConfiguration.java | 12 +++- .../ThingsboardSecurityConfiguration.java | 19 ++++-- .../src/main/resources/thingsboard.yml | 65 +++++++++++++++++-- .../config/custom-environment-variables.yml | 14 ++++ msa/web-ui/config/default.yml | 14 ++++ msa/web-ui/server.ts | 33 ++++++++++ 8 files changed, 257 insertions(+), 15 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java create mode 100644 application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java diff --git a/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java new file mode 100644 index 0000000000..922bf39afa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java @@ -0,0 +1,59 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.web.header.writers.StaticHeadersWriter; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +@RequiredArgsConstructor +public class HttpSecurityHeadersCustomizer { + + private final HttpSecurityHeadersProperties properties; + + public void customize(HeadersConfigurer headers) { + if (properties.getXContentTypeOptions().isEnabled()) { + headers.contentTypeOptions(config -> {}); + } + + if (properties.getReferrerPolicy().isEnabled()) { + headers.addHeaderWriter(new StaticHeadersWriter("Referrer-Policy", properties.getReferrerPolicy().getValue())); + } + + if (properties.getXFrameOptions().isEnabled()) { + String value = properties.getXFrameOptions().getValue(); + if ("DENY".equalsIgnoreCase(value)) { + headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny); + } else { + headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin); + } + } + + if (properties.getContentSecurityPolicy().isEnabled() && StringUtils.hasText(properties.getContentSecurityPolicy().getValue())) { + headers.contentSecurityPolicy(csp -> { + csp.policyDirectives(properties.getContentSecurityPolicy().getValue()); + if (properties.getContentSecurityPolicy().isReportOnly()) { + csp.reportOnly(); + } + }); + } + + } + +} diff --git a/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java new file mode 100644 index 0000000000..80a070f35c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java @@ -0,0 +1,56 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "security.headers") +@Data +public class HttpSecurityHeadersProperties { + + private XContentTypeOptions xContentTypeOptions = new XContentTypeOptions(); + private ReferrerPolicy referrerPolicy = new ReferrerPolicy(); + private XFrameOptions xFrameOptions = new XFrameOptions(); + private ContentSecurityPolicy contentSecurityPolicy = new ContentSecurityPolicy(); + + @Data + public static class XContentTypeOptions { + private boolean enabled = true; + } + + @Data + public static class ReferrerPolicy { + private boolean enabled = true; + private String value = "strict-origin-when-cross-origin"; + } + + @Data + public static class XFrameOptions { + private boolean enabled = false; + private String value = "SAMEORIGIN"; + } + + @Data + public static class ContentSecurityPolicy { + private boolean enabled = false; + private String value = ""; + private boolean reportOnly = false; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/config/TbRuleEngineSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/TbRuleEngineSecurityConfiguration.java index 1f94afb398..f3efaf8d4e 100644 --- a/application/src/main/java/org/thingsboard/server/config/TbRuleEngineSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/TbRuleEngineSecurityConfiguration.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.config; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.context.annotation.Bean; @@ -33,11 +34,16 @@ import org.springframework.security.web.SecurityFilterChain; @ConditionalOnExpression("'${service.type:null}'=='tb-rule-engine'") public class TbRuleEngineSecurityConfiguration { + @Autowired + private HttpSecurityHeadersCustomizer httpSecurityHeadersCustomizer; + @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.headers(headers -> headers - .cacheControl(config -> {}) - .frameOptions(config -> {}).disable()) + http.headers(headers -> { + headers.defaultsDisabled(); + headers.cacheControl(config -> {}); + httpSecurityHeadersCustomizer.customize(headers); + }) .cors(cors -> {}) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(config -> config diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java index cac4f4165e..712224b5d2 100644 --- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java @@ -131,6 +131,9 @@ public class ThingsboardSecurityConfiguration { @Autowired private AuthExceptionHandler authExceptionHandler; + @Autowired + private HttpSecurityHeadersCustomizer httpSecurityHeadersCustomizer; + @Bean protected PayloadSizeFilter payloadSizeFilter() { return new PayloadSizeFilter(maxPayloadSizeConfig); @@ -198,9 +201,11 @@ public class ThingsboardSecurityConfiguration { http .securityMatchers(matchers -> matchers .requestMatchers("/*.js", "/*.css", "/*.ico", "/assets/**", "/static/**")) - .headers(header -> header - .defaultsDisabled() - .addHeaderWriter(new StaticHeadersWriter(HttpHeaders.CACHE_CONTROL, "max-age=0, public"))) + .headers(headers -> { + headers.defaultsDisabled(); + headers.addHeaderWriter(new StaticHeadersWriter(HttpHeaders.CACHE_CONTROL, "max-age=0, public")); + httpSecurityHeadersCustomizer.customize(headers); + }) .authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll()) .requestCache(RequestCacheConfigurer::disable) .securityContext(AbstractHttpConfigurer::disable) @@ -210,9 +215,11 @@ public class ThingsboardSecurityConfiguration { @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.headers(headers -> headers - .cacheControl(config -> {}) - .frameOptions(config -> {}).disable()) + http.headers(headers -> { + headers.defaultsDisabled(); + headers.cacheControl(config -> {}); + httpSecurityHeadersCustomizer.customize(headers); + }) .cors(cors -> {}) .csrf(AbstractHttpConfigurer::disable) .exceptionHandling(config -> {}) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 60a158febe..7305363452 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -173,6 +173,52 @@ security: path: "${SECURITY_JAVA_CACERTS_PATH:${java.home}/lib/security/cacerts}" # The password of the cacerts keystore file password: "${SECURITY_JAVA_CACERTS_PASSWORD:changeit}" + # HTTP security response headers configuration. + # These headers are set on responses from the ThingsBoard backend (tb-node). + # In microservice deployments, the web-ui (Express.js) has its own header configuration + # under msa/web-ui/config/ using the same environment variable names. + headers: + # X-Content-Type-Options header prevents browsers from MIME-sniffing the Content-Type. + # Safe to enable. Only disable if you intentionally serve resources with mismatched Content-Type. + x-content-type-options: + enabled: "${SECURITY_HEADERS_X_CONTENT_TYPE_OPTIONS_ENABLED:true}" + # Referrer-Policy header controls how much referrer info the browser sends with requests. + # The default 'strict-origin-when-cross-origin' matches the browser's built-in default, + # so enabling this does not change existing behavior — it just makes the policy explicit. + # Valid values: no-referrer, no-referrer-when-downgrade, origin, origin-when-cross-origin, + # same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url + referrer-policy: + enabled: "${SECURITY_HEADERS_REFERRER_POLICY_ENABLED:true}" + value: "${SECURITY_HEADERS_REFERRER_POLICY_VALUE:strict-origin-when-cross-origin}" + # X-Frame-Options header protects against clickjacking attacks by preventing the page + # from being loaded in iframes on other domains. + # Disabled by default because ThingsBoard supports multi-domain deployments where + # the platform may be embedded in iframes on customer domains. + # WARNING: Enabling with DENY will block ALL iframe embedding including dashboards + # embedded on external sites. Use SAMEORIGIN to allow same-domain iframes only. + x-frame-options: + enabled: "${SECURITY_HEADERS_X_FRAME_OPTIONS_ENABLED:false}" + # Valid values: DENY, SAMEORIGIN + value: "${SECURITY_HEADERS_X_FRAME_OPTIONS_VALUE:SAMEORIGIN}" + # Content-Security-Policy header mitigates XSS and data injection attacks by restricting + # which resources the browser is allowed to load. + # Disabled by default because ThingsBoard supports multi-domain deployments and + # because custom HTML Card widgets may use inline scripts, inline styles, and + # external resources that a restrictive CSP would block. + # WARNING when enabling: A strict CSP (e.g. script-src 'self') will break: + # - HTML Card widgets with inline JavaScript + # - Custom widget types with inline scripts/styles + # - Widgets loading external resources (images, fonts, scripts) + # - Dashboard embedding via iframes (if frame-ancestors is restrictive) + # Use 'report-only: true' first to test the impact before enforcing. + # Example value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'" + content-security-policy: + enabled: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_ENABLED:false}" + # Full CSP directive string + value: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_VALUE:}" + # If true, uses Content-Security-Policy-Report-Only header instead — the browser + # reports violations but does not enforce them. Use for testing before enforcing. + report-only: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_REPORT_ONLY:false}" # Mail settings parameters mail: @@ -788,21 +834,28 @@ updates: # Enable/disable checks for the new version enabled: "${UPDATES_ENABLED:true}" -# Spring CORS configuration parameters +# Spring CORS configuration parameters. +# Controls the Access-Control-Allow-Origin and Access-Control-Allow-Credentials response headers. +# WARNING: The default configuration allows cross-origin requests from ANY domain with credentials. +# This means any website can make API requests on behalf of an authenticated user if the token +# is accessible (e.g., via XSS). For production deployments, restrict to your domain(s): +# TB_CORS_ALLOWED_ORIGIN_PATTERNS=https://your-domain.com +# For multi-domain deployments, list all allowed domains comma-separated: +# TB_CORS_ALLOWED_ORIGIN_PATTERNS=https://domain1.com,https://domain2.com spring.mvc.cors: mappings: # Intercept path "[/api/**]": #Comma-separated list of origins to allow. '*' allows all origins. When not set, CORS support is disabled. - allowed-origin-patterns: "*" + allowed-origin-patterns: "${TB_CORS_ALLOWED_ORIGIN_PATTERNS:*}" #Comma-separated list of methods to allow. '*' allows all methods. - allowed-methods: "*" + allowed-methods: "${TB_CORS_ALLOWED_METHODS:*}" #Comma-separated list of headers to allow in a request. '*' allows all headers. - allowed-headers: "*" + allowed-headers: "${TB_CORS_ALLOWED_HEADERS:*}" #How long, in seconds, the response from a pre-flight request can be cached by clients. - max-age: "1800" + max-age: "${TB_CORS_MAX_AGE:1800}" #Set whether credentials are supported. When not set, credentials are not supported. - allow-credentials: "true" + allow-credentials: "${TB_CORS_ALLOW_CREDENTIALS:true}" # General spring parameters spring.main.allow-circular-references: "true" # Spring Boot configuration property that controls whether circular dependencies between beans are allowed. diff --git a/msa/web-ui/config/custom-environment-variables.yml b/msa/web-ui/config/custom-environment-variables.yml index 90bce53cb4..4185e8f5ed 100644 --- a/msa/web-ui/config/custom-environment-variables.yml +++ b/msa/web-ui/config/custom-environment-variables.yml @@ -25,6 +25,20 @@ thingsboard: host: "TB_HOST" # ThingsBoard node port port: "TB_PORT" +security: + headers: + x-content-type-options: + enabled: "SECURITY_HEADERS_X_CONTENT_TYPE_OPTIONS_ENABLED" + referrer-policy: + enabled: "SECURITY_HEADERS_REFERRER_POLICY_ENABLED" + value: "SECURITY_HEADERS_REFERRER_POLICY_VALUE" + x-frame-options: + enabled: "SECURITY_HEADERS_X_FRAME_OPTIONS_ENABLED" + value: "SECURITY_HEADERS_X_FRAME_OPTIONS_VALUE" + content-security-policy: + enabled: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_ENABLED" + value: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_VALUE" + reportOnly: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_REPORT_ONLY" logger: level: "LOGGER_LEVEL" path: "LOG_FOLDER" diff --git a/msa/web-ui/config/default.yml b/msa/web-ui/config/default.yml index b26424a8da..23b0162130 100644 --- a/msa/web-ui/config/default.yml +++ b/msa/web-ui/config/default.yml @@ -25,6 +25,20 @@ thingsboard: host: "localhost" # ThingsBoard node port port: "8080" +security: + headers: + x-content-type-options: + enabled: true + referrer-policy: + enabled: true + value: "strict-origin-when-cross-origin" + x-frame-options: + enabled: false + value: "SAMEORIGIN" + content-security-policy: + enabled: false + value: "" + reportOnly: false logger: level: "info" path: "logs" diff --git a/msa/web-ui/server.ts b/msa/web-ui/server.ts index 68baf5079f..7fa35ea9c5 100644 --- a/msa/web-ui/server.ts +++ b/msa/web-ui/server.ts @@ -60,6 +60,39 @@ let connections: Socket[] = []; const app = express(); server = http.createServer(app); + // Build security headers map once at startup + const securityHeaders: Record = {}; + if (config.has('security.headers')) { + const hc: any = config.get('security.headers'); + if (hc['x-content-type-options']?.enabled !== false) { + securityHeaders['X-Content-Type-Options'] = 'nosniff'; + } + if (hc['referrer-policy']?.enabled !== false) { + securityHeaders['Referrer-Policy'] = hc['referrer-policy']?.value || 'strict-origin-when-cross-origin'; + } + if (hc['x-frame-options']?.enabled) { + securityHeaders['X-Frame-Options'] = hc['x-frame-options']?.value || 'SAMEORIGIN'; + } + if (hc['content-security-policy']?.enabled && hc['content-security-policy']?.value) { + const name = hc['content-security-policy']?.reportOnly + ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'; + securityHeaders[name] = hc['content-security-policy'].value; + } + } else { + // Defaults when no security.headers config block exists + securityHeaders['X-Content-Type-Options'] = 'nosniff'; + securityHeaders['Referrer-Policy'] = 'strict-origin-when-cross-origin'; + } + logger.info('Security headers: %s', JSON.stringify(securityHeaders)); + + // Apply security headers to all responses + app.use((_req, res, next) => { + for (const [name, value] of Object.entries(securityHeaders)) { + res.setHeader(name, value); + } + next(); + }); + let apiProxy: httpProxy; if (useApiProxy) { apiProxy = httpProxy.createProxyServer({ From 3a765209ff4c621a239d825e43bfcc7e78ff7c69 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 16 Mar 2026 16:48:38 +0200 Subject: [PATCH 2/5] Address PR review comments - Use kebab-case 'report-only' in web-ui configs to match thingsboard.yml - Add log.warn for unrecognized X-Frame-Options values in customizer - Replace @Configuration with @Component on HttpSecurityHeadersProperties - Add comment explaining '!== false' vs truthiness pattern in server.ts --- .../server/config/HttpSecurityHeadersCustomizer.java | 5 +++++ .../server/config/HttpSecurityHeadersProperties.java | 4 ++-- msa/web-ui/config/custom-environment-variables.yml | 2 +- msa/web-ui/config/default.yml | 2 +- msa/web-ui/server.ts | 9 ++++++--- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java index 922bf39afa..318c435353 100644 --- a/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java +++ b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java @@ -16,11 +16,13 @@ package org.thingsboard.server.config; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.web.header.writers.StaticHeadersWriter; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; +@Slf4j @Component @RequiredArgsConstructor public class HttpSecurityHeadersCustomizer { @@ -41,6 +43,9 @@ public class HttpSecurityHeadersCustomizer { if ("DENY".equalsIgnoreCase(value)) { headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny); } else { + if (!"SAMEORIGIN".equalsIgnoreCase(value)) { + log.warn("Unrecognized X-Frame-Options value '{}', falling back to SAMEORIGIN. Valid values: DENY, SAMEORIGIN", value); + } headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin); } } diff --git a/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java index 80a070f35c..224e2aeea0 100644 --- a/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java +++ b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java @@ -17,9 +17,9 @@ package org.thingsboard.server.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Component; -@Configuration +@Component @ConfigurationProperties(prefix = "security.headers") @Data public class HttpSecurityHeadersProperties { diff --git a/msa/web-ui/config/custom-environment-variables.yml b/msa/web-ui/config/custom-environment-variables.yml index 4185e8f5ed..6ba9147555 100644 --- a/msa/web-ui/config/custom-environment-variables.yml +++ b/msa/web-ui/config/custom-environment-variables.yml @@ -38,7 +38,7 @@ security: content-security-policy: enabled: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_ENABLED" value: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_VALUE" - reportOnly: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_REPORT_ONLY" + report-only: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_REPORT_ONLY" logger: level: "LOGGER_LEVEL" path: "LOG_FOLDER" diff --git a/msa/web-ui/config/default.yml b/msa/web-ui/config/default.yml index 23b0162130..6ffa85b3bc 100644 --- a/msa/web-ui/config/default.yml +++ b/msa/web-ui/config/default.yml @@ -38,7 +38,7 @@ security: content-security-policy: enabled: false value: "" - reportOnly: false + report-only: false logger: level: "info" path: "logs" diff --git a/msa/web-ui/server.ts b/msa/web-ui/server.ts index 7fa35ea9c5..c6b4870b28 100644 --- a/msa/web-ui/server.ts +++ b/msa/web-ui/server.ts @@ -60,7 +60,9 @@ let connections: Socket[] = []; const app = express(); server = http.createServer(app); - // Build security headers map once at startup + // Build security headers map once at startup. + // Headers enabled by default use '!== false' so they stay on unless explicitly disabled. + // Headers disabled by default use simple truthiness checks. const securityHeaders: Record = {}; if (config.has('security.headers')) { const hc: any = config.get('security.headers'); @@ -74,9 +76,10 @@ let connections: Socket[] = []; securityHeaders['X-Frame-Options'] = hc['x-frame-options']?.value || 'SAMEORIGIN'; } if (hc['content-security-policy']?.enabled && hc['content-security-policy']?.value) { - const name = hc['content-security-policy']?.reportOnly + const csp = hc['content-security-policy']; + const name = csp['report-only'] ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'; - securityHeaders[name] = hc['content-security-policy'].value; + securityHeaders[name] = csp.value; } } else { // Defaults when no security.headers config block exists From c1dd327b47a614218e92766d70458ca83ac93b75 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 16 Mar 2026 17:59:50 +0200 Subject: [PATCH 3/5] Add description comments for security headers properties in thingsboard.yml --- application/src/main/resources/thingsboard.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 7305363452..b40325c05e 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -181,6 +181,7 @@ security: # X-Content-Type-Options header prevents browsers from MIME-sniffing the Content-Type. # Safe to enable. Only disable if you intentionally serve resources with mismatched Content-Type. x-content-type-options: + # Enable/disable X-Content-Type-Options header. Prevents browsers from MIME-sniffing the Content-Type enabled: "${SECURITY_HEADERS_X_CONTENT_TYPE_OPTIONS_ENABLED:true}" # Referrer-Policy header controls how much referrer info the browser sends with requests. # The default 'strict-origin-when-cross-origin' matches the browser's built-in default, @@ -188,7 +189,9 @@ security: # Valid values: no-referrer, no-referrer-when-downgrade, origin, origin-when-cross-origin, # same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url referrer-policy: + # Enable/disable Referrer-Policy header enabled: "${SECURITY_HEADERS_REFERRER_POLICY_ENABLED:true}" + # Referrer-Policy header value value: "${SECURITY_HEADERS_REFERRER_POLICY_VALUE:strict-origin-when-cross-origin}" # X-Frame-Options header protects against clickjacking attacks by preventing the page # from being loaded in iframes on other domains. @@ -197,6 +200,7 @@ security: # WARNING: Enabling with DENY will block ALL iframe embedding including dashboards # embedded on external sites. Use SAMEORIGIN to allow same-domain iframes only. x-frame-options: + # Enable/disable X-Frame-Options header. Protects against clickjacking attacks enabled: "${SECURITY_HEADERS_X_FRAME_OPTIONS_ENABLED:false}" # Valid values: DENY, SAMEORIGIN value: "${SECURITY_HEADERS_X_FRAME_OPTIONS_VALUE:SAMEORIGIN}" @@ -213,6 +217,7 @@ security: # Use 'report-only: true' first to test the impact before enforcing. # Example value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'" content-security-policy: + # Enable/disable Content-Security-Policy header. Mitigates XSS and data injection attacks enabled: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_ENABLED:false}" # Full CSP directive string value: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_VALUE:}" From 493140f1b2a6b7a839da6ffd1a92794e0d509c12 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 16 Mar 2026 18:17:01 +0200 Subject: [PATCH 4/5] Remove dead else branch in web-ui security headers config --- msa/web-ui/server.ts | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/msa/web-ui/server.ts b/msa/web-ui/server.ts index c6b4870b28..48f17b2181 100644 --- a/msa/web-ui/server.ts +++ b/msa/web-ui/server.ts @@ -64,27 +64,21 @@ let connections: Socket[] = []; // Headers enabled by default use '!== false' so they stay on unless explicitly disabled. // Headers disabled by default use simple truthiness checks. const securityHeaders: Record = {}; - if (config.has('security.headers')) { - const hc: any = config.get('security.headers'); - if (hc['x-content-type-options']?.enabled !== false) { - securityHeaders['X-Content-Type-Options'] = 'nosniff'; - } - if (hc['referrer-policy']?.enabled !== false) { - securityHeaders['Referrer-Policy'] = hc['referrer-policy']?.value || 'strict-origin-when-cross-origin'; - } - if (hc['x-frame-options']?.enabled) { - securityHeaders['X-Frame-Options'] = hc['x-frame-options']?.value || 'SAMEORIGIN'; - } - if (hc['content-security-policy']?.enabled && hc['content-security-policy']?.value) { - const csp = hc['content-security-policy']; - const name = csp['report-only'] - ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'; - securityHeaders[name] = csp.value; - } - } else { - // Defaults when no security.headers config block exists + const hc: any = config.get('security.headers'); + if (hc['x-content-type-options']?.enabled !== false) { securityHeaders['X-Content-Type-Options'] = 'nosniff'; - securityHeaders['Referrer-Policy'] = 'strict-origin-when-cross-origin'; + } + if (hc['referrer-policy']?.enabled !== false) { + securityHeaders['Referrer-Policy'] = hc['referrer-policy']?.value || 'strict-origin-when-cross-origin'; + } + if (hc['x-frame-options']?.enabled) { + securityHeaders['X-Frame-Options'] = hc['x-frame-options']?.value || 'SAMEORIGIN'; + } + if (hc['content-security-policy']?.enabled && hc['content-security-policy']?.value) { + const csp = hc['content-security-policy']; + const name = csp['report-only'] + ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'; + securityHeaders[name] = csp.value; } logger.info('Security headers: %s', JSON.stringify(securityHeaders)); From 014c612bf1933b3e170f76e4ad8c70f07808f8c1 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 16 Mar 2026 18:19:36 +0200 Subject: [PATCH 5/5] Fix boolean/string comparison for env var overrides in web-ui security headers --- msa/web-ui/server.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/msa/web-ui/server.ts b/msa/web-ui/server.ts index 48f17b2181..0a4ddbfa6b 100644 --- a/msa/web-ui/server.ts +++ b/msa/web-ui/server.ts @@ -61,22 +61,22 @@ let connections: Socket[] = []; server = http.createServer(app); // Build security headers map once at startup. - // Headers enabled by default use '!== false' so they stay on unless explicitly disabled. - // Headers disabled by default use simple truthiness checks. + // node-config passes env var overrides as strings, so enabled can be boolean or string. + const isEnabled = (val: any) => val === true || val === 'true'; const securityHeaders: Record = {}; const hc: any = config.get('security.headers'); - if (hc['x-content-type-options']?.enabled !== false) { + if (isEnabled(hc['x-content-type-options']?.enabled)) { securityHeaders['X-Content-Type-Options'] = 'nosniff'; } - if (hc['referrer-policy']?.enabled !== false) { + if (isEnabled(hc['referrer-policy']?.enabled)) { securityHeaders['Referrer-Policy'] = hc['referrer-policy']?.value || 'strict-origin-when-cross-origin'; } - if (hc['x-frame-options']?.enabled) { + if (isEnabled(hc['x-frame-options']?.enabled)) { securityHeaders['X-Frame-Options'] = hc['x-frame-options']?.value || 'SAMEORIGIN'; } - if (hc['content-security-policy']?.enabled && hc['content-security-policy']?.value) { + if (isEnabled(hc['content-security-policy']?.enabled) && hc['content-security-policy']?.value) { const csp = hc['content-security-policy']; - const name = csp['report-only'] + const name = isEnabled(csp['report-only']) ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'; securityHeaders[name] = csp.value; }