Browse Source

Merge pull request #15254 from thingsboard/fix/cors

Added configurable security headers and env-var-backed CORS configuration
pull/15262/head
Viacheslav Klimov 7 months ago
committed by GitHub
parent
commit
5d7bfe4ee1
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 64
      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. 70
      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. 30
      msa/web-ui/server.ts

64
application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java

@ -0,0 +1,64 @@
/**
* 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 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 {
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 {
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);
}
}
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.stereotype.Component;
@Component
@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 -> {})

70
application/src/main/resources/thingsboard.yml

@ -173,6 +173,57 @@ 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:
# 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,
# 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:
# 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.
# 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:
# 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}"
# 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:
# 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:}"
# 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 +839,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"
report-only: "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: ""
report-only: false
logger:
level: "info"
path: "logs"

30
msa/web-ui/server.ts

@ -60,6 +60,36 @@ let connections: Socket[] = [];
const app = express();
server = http.createServer(app);
// Build security headers map once at startup.
// 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<string, string> = {};
const hc: any = config.get('security.headers');
if (isEnabled(hc['x-content-type-options']?.enabled)) {
securityHeaders['X-Content-Type-Options'] = 'nosniff';
}
if (isEnabled(hc['referrer-policy']?.enabled)) {
securityHeaders['Referrer-Policy'] = hc['referrer-policy']?.value || 'strict-origin-when-cross-origin';
}
if (isEnabled(hc['x-frame-options']?.enabled)) {
securityHeaders['X-Frame-Options'] = hc['x-frame-options']?.value || 'SAMEORIGIN';
}
if (isEnabled(hc['content-security-policy']?.enabled) && hc['content-security-policy']?.value) {
const csp = hc['content-security-policy'];
const name = isEnabled(csp['report-only'])
? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy';
securityHeaders[name] = csp.value;
}
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