Browse Source

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.
pull/15254/head
Viacheslav Klimov 7 months ago
parent
commit
07af263997
Failed to extract signature
  1. 59
      application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java
  2. 56
      application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java
  3. 12
      application/src/main/java/org/thingsboard/server/config/TbRuleEngineSecurityConfiguration.java
  4. 19
      application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
  5. 65
      application/src/main/resources/thingsboard.yml
  6. 14
      msa/web-ui/config/custom-environment-variables.yml
  7. 14
      msa/web-ui/config/default.yml
  8. 33
      msa/web-ui/server.ts

59
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();
}
});
}
}
}

56
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;
}
}

12
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

19
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 -> {})

65
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.

14
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"

14
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"

33
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<string, string> = {};
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({

Loading…
Cancel
Save