diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index 6eec556b93..67ef7f6ab1 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -622,11 +622,21 @@ public class ActorSystemContext { @Value("${actors.rule.external.ssrf_additional_blocked_hosts:}") private List ssrfAdditionalBlockedHosts; + @Value("${actors.rule.external.ssrf_allowed_hosts:}") + private List ssrfAllowedHosts; + @PostConstruct public void init() { this.localCacheType = "caffeine".equals(cacheType); SsrfProtectionValidator.setEnabled(ssrfProtectionEnabled); SsrfProtectionValidator.setAdditionalBlockedHosts(ssrfAdditionalBlockedHosts); + SsrfProtectionValidator.setAllowedHosts(ssrfAllowedHosts); + if (!ssrfProtectionEnabled) { + log.warn("SSRF protection for external rule nodes is DISABLED. This allows rule chains to make HTTP requests to " + + "internal/private network addresses including cloud metadata endpoints. It is strongly recommended to " + + "enable SSRF protection by setting SSRF_PROTECTION_ENABLED=true. If your rule chains need to access " + + "devices on local networks, use SSRF_ALLOWED_HOSTS to whitelist specific addresses or ranges."); + } } @Value("${actors.tenant.create_components_on_init:true}") 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..318c435353 --- /dev/null +++ b/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(); + } + }); + } + + } + +} 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..224e2aeea0 --- /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.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; + } + +} 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 cd5cecbef7..65bbe459c1 100644 --- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java @@ -143,6 +143,9 @@ public class ThingsboardSecurityConfiguration { @Autowired private AuthExceptionHandler authExceptionHandler; + @Autowired + private HttpSecurityHeadersCustomizer httpSecurityHeadersCustomizer; + @Bean protected PayloadSizeFilter payloadSizeFilter() { return new PayloadSizeFilter(maxPayloadSizeConfig); @@ -231,9 +234,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) @@ -243,8 +248,12 @@ public class ThingsboardSecurityConfiguration { @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.headers(headers -> headers.defaultsDisabled() - .crossOriginOpenerPolicy(coop -> coop.policy(CrossOriginOpenerPolicy.SAME_ORIGIN))) + http.headers(headers -> { + headers.defaultsDisabled(); + headers.cacheControl(config -> {}); + headers.crossOriginOpenerPolicy(coop -> coop.policy(CrossOriginOpenerPolicy.SAME_ORIGIN)); + httpSecurityHeadersCustomizer.customize(headers); + }) .cors(cors -> {}) .csrf(AbstractHttpConfigurer::disable) .exceptionHandling(config -> {}) diff --git a/application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java b/application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java index df163283ce..d023ed9b53 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java @@ -29,6 +29,7 @@ import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.info.NotificationInfo; @@ -109,10 +110,13 @@ public class MicrosoftTeamsNotificationChannel implements NotificationChannel request = new HttpEntity<>(JacksonUtil.toString(teamsAdaptiveCard), headers); - restTemplate.postForEntity(new URI(targetConfig.getWebhookUrl()), request, String.class); + restTemplate.postForEntity(webhookUri, request, String.class); } private void sendTeamsMessageCard(MicrosoftTeamsNotificationTargetConfig targetConfig, MicrosoftTeamsDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws JsonProcessingException, URISyntaxException { @@ -139,10 +143,13 @@ public class MicrosoftTeamsNotificationChannel implements NotificationChannel request = new HttpEntity<>(JacksonUtil.toString(teamsMessageCard), headers); - restTemplate.postForEntity(new URI(targetConfig.getWebhookUrl()), request, String.class); + restTemplate.postForEntity(webhookUri, request, String.class); } private String getButtonUri(MicrosoftTeamsDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws JsonProcessingException { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java index 8477c69a99..97d24b8601 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java @@ -23,12 +23,15 @@ import org.springframework.security.oauth2.client.authentication.OAuth2Authentic import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.oauth2.OAuth2CustomMapperConfig; import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig; import org.thingsboard.server.common.data.oauth2.OAuth2Client; import org.thingsboard.server.dao.oauth2.OAuth2User; import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.net.URI; import org.thingsboard.server.service.security.model.SecurityUser; @Service(value = "customOAuth2ClientMapper") @@ -64,6 +67,7 @@ public class CustomOAuth2ClientMapper extends AbstractOAuth2ClientMapper impleme throw new RuntimeException("Can't convert principal to JSON string", e); } try { + SsrfProtectionValidator.validateUri(new URI(custom.getUrl())); return restTemplate.postForEntity(custom.getUrl(), request, OAuth2User.class).getBody(); } catch (Exception e) { log.error("There was an error during connection to custom mapper endpoint", e); diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index da73af2b55..ff68635410 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -188,6 +188,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 # Configures mail service OAuth2 token refresh and per-tenant sending rate limits. @@ -536,6 +587,10 @@ actors: # Comma-separated list of additional blocked destinations (IPs, CIDR subnets, or hostnames). # Example: "198.51.100.0/24,metadata.tencentyun.com,rancher-metadata" ssrf_additional_blocked_hosts: "${SSRF_ADDITIONAL_BLOCKED_HOSTS:}" + # Comma-separated list of allowed destinations that bypass SSRF blocking (IPs, CIDR subnets, or hostnames). + # Use this when your rule chains need to reach devices on private networks (e.g., 192.168.1.0/24). + # Example: "192.168.1.0/24,10.0.0.0/8,my-internal-service.corp" + ssrf_allowed_hosts: "${SSRF_ALLOWED_HOSTS:}" rpc: # Maximum number of persistent RPC call retries in case of failed request delivery. max_retries: "${ACTORS_RPC_MAX_RETRIES:5}" @@ -826,22 +881,28 @@ updates: # Enable/disable checks for the new version enabled: "${UPDATES_ENABLED:true}" -# Spring CORS configuration parameters -# Defines allowed origins, methods, headers, and credentials for cross-origin REST API requests. +# 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 # Miscellaneous Spring Boot settings for circular references, Freemarker, MVC, multipart, and JPA dialect. diff --git a/common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java b/common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java index 15da77f663..9132d117ae 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java +++ b/common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java @@ -38,6 +38,7 @@ public class SsrfProtectionValidator { private static final Set BLOCKED_HOSTNAME_SUFFIXES = Set.of(".internal", ".local"); private static volatile AdditionalBlockedHosts additionalBlocked = AdditionalBlockedHosts.EMPTY; + private static volatile AllowedHosts allowedHosts = AllowedHosts.EMPTY; // Well-known cloud metadata endpoints not covered by the JDK checks (isLoopback, isSiteLocal, isLinkLocal) private static final List CLOUD_METADATA_RANGES = List.of( @@ -66,6 +67,13 @@ public class SsrfProtectionValidator { } String hostLower = host.toLowerCase(); + + // Allow-listed hostnames bypass all hostname and IP checks + AllowedHosts currentAllowed = allowedHosts; + if (currentAllowed.hostnames.contains(hostLower)) { + return; + } + if (BLOCKED_HOSTNAMES.contains(hostLower) || additionalBlocked.hostnames.contains(hostLower)) { throwBlockedHost(host); } @@ -98,7 +106,15 @@ public class SsrfProtectionValidator { } } - private static boolean isBlockedAddress(InetAddress address) { + public static boolean isBlockedAddress(InetAddress address) { + // Check allow-list first: allowed addresses bypass all block checks + AllowedHosts currentAllowed = allowedHosts; + for (CidrRange cidr : currentAllowed.cidrRanges) { + if (cidr.contains(address)) { + return false; + } + } + // Covers 127.0.0.0/8 and ::1 if (address.isLoopbackAddress()) { return true; @@ -142,14 +158,37 @@ public class SsrfProtectionValidator { throw new RuntimeException("URI is invalid: host '" + host + "' is not allowed"); } + public static boolean isEnabled() { + return enabled; + } + public static void setEnabled(boolean enabled) { SsrfProtectionValidator.enabled = enabled; } public static void setAdditionalBlockedHosts(List entries) { + ParsedHostEntries parsed = parseHostEntries(entries); + additionalBlocked = new AdditionalBlockedHosts(parsed.cidrRanges, parsed.hostnames); + if (!parsed.cidrRanges.isEmpty() || !parsed.hostnames.isEmpty()) { + log.info("SSRF additional blocked hosts configured: {} CIDR range(s), {} hostname(s)", parsed.cidrRanges.size(), parsed.hostnames.size()); + } + } + + public static void setAllowedHosts(List entries) { + ParsedHostEntries parsed = parseHostEntries(entries); + allowedHosts = new AllowedHosts(parsed.cidrRanges, parsed.hostnames); + if (!parsed.cidrRanges.isEmpty() || !parsed.hostnames.isEmpty()) { + log.info("SSRF allowed hosts configured: {} CIDR range(s), {} hostname(s)", parsed.cidrRanges.size(), parsed.hostnames.size()); + } + } + + public static boolean isHostnameAllowed(String hostname) { + return allowedHosts.hostnames.contains(hostname.toLowerCase()); + } + + private static ParsedHostEntries parseHostEntries(List entries) { if (entries == null || entries.isEmpty()) { - additionalBlocked = AdditionalBlockedHosts.EMPTY; - return; + return ParsedHostEntries.EMPTY; } List cidrRanges = new ArrayList<>(); Set hostnames = new HashSet<>(); @@ -168,10 +207,9 @@ public class SsrfProtectionValidator { hostnames.add(trimmed.toLowerCase()); } } - additionalBlocked = new AdditionalBlockedHosts( + return new ParsedHostEntries( Collections.unmodifiableList(cidrRanges), Collections.unmodifiableSet(hostnames)); - log.info("SSRF additional blocked hosts configured: {} CIDR range(s), {} hostname(s)", cidrRanges.size(), hostnames.size()); } private static boolean isIpLiteral(String entry) { @@ -179,10 +217,18 @@ public class SsrfProtectionValidator { return !entry.isEmpty() && (Character.isDigit(entry.charAt(0)) || entry.contains(":")); } + private record ParsedHostEntries(List cidrRanges, Set hostnames) { + static final ParsedHostEntries EMPTY = new ParsedHostEntries(Collections.emptyList(), Collections.emptySet()); + } + record AdditionalBlockedHosts(List cidrRanges, Set hostnames) { static final AdditionalBlockedHosts EMPTY = new AdditionalBlockedHosts(Collections.emptyList(), Collections.emptySet()); } + record AllowedHosts(List cidrRanges, Set hostnames) { + static final AllowedHosts EMPTY = new AllowedHosts(Collections.emptyList(), Collections.emptySet()); + } + record CidrRange(byte[] network, int prefixLength) { static CidrRange of(String ip, int prefixLength) { diff --git a/common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java b/common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java index 6cb2d21a9a..d2d1be80c9 100644 --- a/common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java +++ b/common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java @@ -20,10 +20,12 @@ import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import java.net.InetAddress; import java.net.URI; import java.util.Collections; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -335,4 +337,244 @@ public class SsrfProtectionValidatorTest { } } + // --- Allow-list tests --- + + @Test + void testAllowListCidrAllowsPrivateAddress() { + try { + SsrfProtectionValidator.setAllowedHosts(List.of("192.168.1.0/24")); + // 192.168.1.1 is normally blocked (site-local), but allow-listed + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.1"), true)); + // Other private ranges remain blocked + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://10.0.0.1"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testAllowListHostnameBypassesSuffixCheck() { + try { + SsrfProtectionValidator.setAllowedHosts(List.of("my-device.local")); + // .local suffix is normally blocked, but allow-listed hostname passes + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://my-device.local/api"), true)); + // Other .local hostnames remain blocked + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://other-device.local/api"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testAllowListPrecedenceOverBlockList() { + try { + // Block 8.8.8.0/24 via additional-blocked, but allow 8.8.8.8 via allow-list + SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("8.8.8.0/24")); + SsrfProtectionValidator.setAllowedHosts(List.of("8.8.8.8")); + // Allow-list should win + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.8"), true)); + // Adjacent IP still blocked + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.9"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } finally { + SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testIsBlockedAddressPublicApi() throws Exception { + InetAddress loopback = InetAddress.getByName("127.0.0.1"); + assertThat(SsrfProtectionValidator.isBlockedAddress(loopback)).isTrue(); + + InetAddress publicIp = InetAddress.getByName("8.8.8.8"); + assertThat(SsrfProtectionValidator.isBlockedAddress(publicIp)).isFalse(); + + // Allow-listed private address + try { + SsrfProtectionValidator.setAllowedHosts(List.of("10.0.0.0/8")); + InetAddress privateIp = InetAddress.getByName("10.1.2.3"); + assertThat(SsrfProtectionValidator.isBlockedAddress(privateIp)).isFalse(); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testIsEnabledAccessor() { + boolean original = SsrfProtectionValidator.isEnabled(); + try { + SsrfProtectionValidator.setEnabled(true); + assertThat(SsrfProtectionValidator.isEnabled()).isTrue(); + SsrfProtectionValidator.setEnabled(false); + assertThat(SsrfProtectionValidator.isEnabled()).isFalse(); + } finally { + SsrfProtectionValidator.setEnabled(original); + } + } + + @Test + void testSetAllowedHostsEmptyAndNull() { + // Should not throw + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + SsrfProtectionValidator.setAllowedHosts(null); + } + + @Test + void testIsHostnameAllowed() { + try { + SsrfProtectionValidator.setAllowedHosts(List.of("my-device.local", "Internal-Server.Corp")); + assertThat(SsrfProtectionValidator.isHostnameAllowed("my-device.local")).isTrue(); + assertThat(SsrfProtectionValidator.isHostnameAllowed("MY-DEVICE.LOCAL")).isTrue(); // case-insensitive + assertThat(SsrfProtectionValidator.isHostnameAllowed("internal-server.corp")).isTrue(); + assertThat(SsrfProtectionValidator.isHostnameAllowed("other-device.local")).isFalse(); + assertThat(SsrfProtectionValidator.isHostnameAllowed("example.com")).isFalse(); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testIsHostnameAllowedEmptyList() { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + assertThat(SsrfProtectionValidator.isHostnameAllowed("anything")).isFalse(); + } + + @Test + void testValidateUriUsesStaticEnabledFlag() { + boolean original = SsrfProtectionValidator.isEnabled(); + try { + // When enabled, loopback is blocked via the public one-arg overload + SsrfProtectionValidator.setEnabled(true); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://127.0.0.1"))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + + // When disabled, loopback passes + SsrfProtectionValidator.setEnabled(false); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://127.0.0.1"))); + } finally { + SsrfProtectionValidator.setEnabled(original); + } + } + + @Test + void testAllowListHostnameCaseInsensitive() { + try { + SsrfProtectionValidator.setAllowedHosts(List.of("My-Device.LOCAL")); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://my-device.local/api"), true)); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://MY-DEVICE.LOCAL/api"), true)); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testAllowListOverridesCloudMetadataRange() { + try { + // 169.254.169.254 is link-local (blocked by default), allow-list should override + SsrfProtectionValidator.setAllowedHosts(List.of("169.254.169.254")); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://169.254.169.254/latest/meta-data/"), true)); + // Other link-local still blocked + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://169.254.1.1"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testAllowListOverridesLoopback() { + try { + SsrfProtectionValidator.setAllowedHosts(List.of("127.0.0.0/8")); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://127.0.0.1"), true)); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://127.1.2.3"), true)); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testAllowListCidrBoundary() { + try { + SsrfProtectionValidator.setAllowedHosts(List.of("192.168.1.0/24")); + // Last address in range + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.255"), true)); + // First address outside range + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.2.0"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + // Different subnet entirely + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.0.1"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testBlockedIpv6UniqueLocal() throws Exception { + // fc00::/7 covers fc00:: through fdff:: + InetAddress fc00 = InetAddress.getByName("fc00::1"); + assertThat(SsrfProtectionValidator.isBlockedAddress(fc00)).isTrue(); + + InetAddress fdAddr = InetAddress.getByName("fd12:3456:789a::1"); + assertThat(SsrfProtectionValidator.isBlockedAddress(fdAddr)).isTrue(); + + // fe00:: is NOT in fc00::/7 (it's in fe80::/10 link-local, but fe00:: without the 80 bits is different) + // 2001:db8:: is a public documentation prefix, not blocked + InetAddress publicV6 = InetAddress.getByName("2001:db8::1"); + assertThat(SsrfProtectionValidator.isBlockedAddress(publicV6)).isFalse(); + } + + @Test + void testParseHostEntriesWithWhitespaceAndBlanks() { + try { + SsrfProtectionValidator.setAllowedHosts(List.of(" 192.168.1.0/24 ", "", " ", "my-host.corp")); + // Trimmed CIDR works + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.1"), true)); + // Trimmed hostname works + assertThat(SsrfProtectionValidator.isHostnameAllowed("my-host.corp")).isTrue(); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testSetAllowedHostsReplacePrevious() { + try { + SsrfProtectionValidator.setAllowedHosts(List.of("192.168.1.0/24")); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.1"), true)); + + // Replace with different range + SsrfProtectionValidator.setAllowedHosts(List.of("10.0.0.0/8")); + // Old range no longer allowed + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.1"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + // New range allowed + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://10.1.2.3"), true)); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testAllowListHostnameBypassesBlockedHostname() { + try { + // "localhost" is in BLOCKED_HOSTNAMES; allow-listing it should let it through + SsrfProtectionValidator.setAllowedHosts(List.of("localhost")); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://localhost/path"), true)); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java index 42e872a87c..289220710c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.service.validator; import lombok.AllArgsConstructor; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.oauth2.MapperType; @@ -28,6 +29,8 @@ import org.thingsboard.server.common.data.oauth2.TenantNameStrategyType; import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; +import java.net.URI; + @Component @AllArgsConstructor public class Oauth2ClientDataValidator extends DataValidator { @@ -64,6 +67,11 @@ public class Oauth2ClientDataValidator extends DataValidator { if (StringUtils.isEmpty(customConfig.getUrl())) { throw new DataValidationException("Custom mapper URL should be specified!"); } + try { + SsrfProtectionValidator.validateUri(new URI(customConfig.getUrl())); + } catch (Exception e) { + throw new DataValidationException("Custom mapper URL is not allowed: " + e.getMessage()); + } } } } diff --git a/msa/js-executor/yarn.lock b/msa/js-executor/yarn.lock index 6457be1de7..08a789e9b5 100644 --- a/msa/js-executor/yarn.lock +++ b/msa/js-executor/yarn.lock @@ -1036,9 +1036,9 @@ mimic-response@^3.1.0: integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== minimatch@^3.1.2: - version "3.1.3" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.3.tgz#6a5cba9b31f503887018f579c89f81f61162e624" - integrity sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA== + version "3.1.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" + integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== dependencies: brace-expansion "^1.1.7" @@ -1549,9 +1549,9 @@ tar-stream@^2.1.4: readable-stream "^3.1.1" tar@>=7.5.8, tar@^7.4.3: - version "7.5.9" - resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.9.tgz#817ac12a54bc4362c51340875b8985d7dc9724b8" - integrity sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg== + version "7.5.11" + resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.11.tgz#1250fae45d98806b36d703b30973fa8e0a6d8868" + integrity sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ== dependencies: "@isaacs/fs-minipass" "^4.0.0" chownr "^3.0.0" diff --git a/msa/web-ui/config/custom-environment-variables.yml b/msa/web-ui/config/custom-environment-variables.yml index 90bce53cb4..6ba9147555 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" + 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 b26424a8da..6ffa85b3bc 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: "" + report-only: false logger: level: "info" path: "logs" diff --git a/msa/web-ui/server.ts b/msa/web-ui/server.ts index 68baf5079f..0a4ddbfa6b 100644 --- a/msa/web-ui/server.ts +++ b/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 = {}; + 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({ diff --git a/msa/web-ui/yarn.lock b/msa/web-ui/yarn.lock index 11a708d8bf..52dd2a03f3 100644 --- a/msa/web-ui/yarn.lock +++ b/msa/web-ui/yarn.lock @@ -1098,9 +1098,9 @@ mimic-response@^3.1.0: integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== minimatch@^3.1.2: - version "3.1.3" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.3.tgz#6a5cba9b31f503887018f579c89f81f61162e624" - integrity sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA== + version "3.1.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" + integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== dependencies: brace-expansion "^1.1.7" @@ -1631,9 +1631,9 @@ tar-stream@^2.1.4: readable-stream "^3.1.1" tar@>=7.5.8, tar@^7.4.3: - version "7.5.9" - resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.9.tgz#817ac12a54bc4362c51340875b8985d7dc9724b8" - integrity sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg== + version "7.5.11" + resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.11.tgz#1250fae45d98806b36d703b30973fa8e0a6d8868" + integrity sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ== dependencies: "@isaacs/fs-minipass" "^4.0.0" chownr "^3.0.0" diff --git a/pom.xml b/pom.xml index c29ea51406..4dcc3929fd 100755 --- a/pom.xml +++ b/pom.xml @@ -534,6 +534,8 @@ -PpkgInstallFolder=${pkg.installFolder} -PpkgCopyInstallScripts=${pkg.copyInstallScripts} -PpkgLogFolder=${pkg.unixLogFolder} + --project-cache-dir + ${project.build.directory}/.gradle --warning-mode all @@ -891,6 +893,21 @@ com.mycila license-maven-plugin + + org.apache.maven.plugins + maven-clean-plugin + false + + + + ${main.dir}/packaging/java/.gradle + + + ${main.dir}/packaging/js/.gradle + + + + diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroup.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroup.java new file mode 100644 index 0000000000..9d15cb9793 --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroup.java @@ -0,0 +1,174 @@ +/** + * 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.rule.engine.rest; + +import io.netty.resolver.AddressResolver; +import io.netty.resolver.AddressResolverGroup; +import io.netty.resolver.DefaultAddressResolverGroup; +import io.netty.util.concurrent.EventExecutor; +import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.Promise; +import org.thingsboard.common.util.SsrfProtectionValidator; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Custom Netty {@link AddressResolverGroup} that validates every resolved IP address + * against the SSRF block-list at connection time. This eliminates the DNS rebinding + * TOCTOU gap where a hostname resolves to a safe IP during validation but to a + * private/metadata IP when the actual connection is made. + *

+ * Only wired into {@link TbHttpClient} when SSRF protection is enabled. + */ +public final class SsrfSafeAddressResolverGroup extends AddressResolverGroup { + + public static final SsrfSafeAddressResolverGroup INSTANCE = new SsrfSafeAddressResolverGroup(); + + private SsrfSafeAddressResolverGroup() { + } + + @Override + protected AddressResolver newResolver(EventExecutor executor) throws Exception { + AddressResolver delegate = DefaultAddressResolverGroup.INSTANCE.getResolver(executor); + return new SsrfValidatingResolver(executor, delegate); + } + + private static final class SsrfValidatingResolver implements AddressResolver { + + private final EventExecutor executor; + private final AddressResolver delegate; + + SsrfValidatingResolver(EventExecutor executor, AddressResolver delegate) { + this.executor = executor; + this.delegate = delegate; + } + + @Override + public boolean isSupported(SocketAddress address) { + return delegate.isSupported(address); + } + + @Override + public boolean isResolved(SocketAddress address) { + return delegate.isResolved(address); + } + + @Override + public Future resolve(SocketAddress address) { + return resolve(address, executor.newPromise()); + } + + @Override + public Future resolve(SocketAddress address, Promise promise) { + delegate.resolve(address).addListener((Future future) -> { + try { + if (!future.isSuccess()) { + promise.tryFailure(future.cause()); + return; + } + InetSocketAddress resolved = future.getNow(); + if (isOriginalHostAllowed(address)) { + promise.trySuccess(resolved); + } else if (isBlocked(resolved)) { + promise.tryFailure(new RuntimeException( + "URI is invalid: host '" + getHostString(address) + "' is not allowed")); + } else { + promise.trySuccess(resolved); + } + } catch (Exception e) { + promise.tryFailure(e); + } + }); + return promise; + } + + @Override + public Future> resolveAll(SocketAddress address) { + return resolveAll(address, executor.newPromise()); + } + + @Override + public Future> resolveAll(SocketAddress address, Promise> promise) { + delegate.resolveAll(address).addListener((Future> future) -> { + try { + if (!future.isSuccess()) { + promise.tryFailure(future.cause()); + return; + } + List resolved = future.getNow(); + if (isOriginalHostAllowed(address)) { + promise.trySuccess(resolved); + return; + } + Set blocked = null; + for (InetSocketAddress addr : resolved) { + if (isBlocked(addr)) { + if (blocked == null) { + blocked = new HashSet<>(2); + } + blocked.add(addr); + } + } + if (blocked == null) { + promise.trySuccess(resolved); + } else if (blocked.size() == resolved.size()) { + promise.tryFailure(new RuntimeException( + "URI is invalid: host '" + getHostString(address) + "' is not allowed")); + } else { + List safe = new ArrayList<>(resolved.size() - blocked.size()); + for (InetSocketAddress addr : resolved) { + if (!blocked.contains(addr)) { + safe.add(addr); + } + } + promise.trySuccess(safe); + } + } catch (Exception e) { + promise.tryFailure(e); + } + }); + return promise; + } + + @Override + public void close() { + delegate.close(); + } + + private static boolean isBlocked(InetSocketAddress socketAddress) { + InetAddress addr = socketAddress.getAddress(); + return addr != null && SsrfProtectionValidator.isBlockedAddress(addr); + } + + private static boolean isOriginalHostAllowed(SocketAddress address) { + if (address instanceof InetSocketAddress isa) { + String host = isa.getHostString(); + return host != null && SsrfProtectionValidator.isHostnameAllowed(host); + } + return false; + } + + private static String getHostString(SocketAddress address) { + return address instanceof InetSocketAddress isa ? isa.getHostString() : address.toString(); + } + } +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java index 24f88e88a3..df9ce0194b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java @@ -103,6 +103,7 @@ public class TbHttpClient { .build(); HttpClient httpClient = HttpClient.create(connectionProvider) + .followRedirect(false) .runOn(getSharedOrCreateEventLoopGroup(eventLoopGroupShared)) .doOnConnected(c -> c.addHandlerLast(new ReadTimeoutHandler(config.getReadTimeoutMs(), TimeUnit.MILLISECONDS))); @@ -138,6 +139,10 @@ public class TbHttpClient { httpClient = httpClient.secure(t -> t.sslContext(sslContext)); } + if (SsrfProtectionValidator.isEnabled()) { + httpClient = httpClient.resolver(SsrfSafeAddressResolverGroup.INSTANCE); + } + validateMaxInMemoryBufferSize(config); this.webClient = WebClient.builder() diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroupTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroupTest.java new file mode 100644 index 0000000000..99506d3d12 --- /dev/null +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroupTest.java @@ -0,0 +1,161 @@ +/** + * 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.rule.engine.rest; + +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.resolver.AddressResolver; +import io.netty.util.concurrent.EventExecutor; +import io.netty.util.concurrent.Promise; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.thingsboard.common.util.SsrfProtectionValidator; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ResourceLock("SsrfSafeAddressResolverGroupTest") +class SsrfSafeAddressResolverGroupTest { + + private static NioEventLoopGroup eventLoopGroup; + + @BeforeAll + static void setUp() { + eventLoopGroup = new NioEventLoopGroup(1); + } + + @AfterAll + static void tearDown() { + eventLoopGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS); + SsrfProtectionValidator.setEnabled(false); + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + + @BeforeEach + void enableSsrf() { + SsrfProtectionValidator.setEnabled(true); + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + + @AfterEach + void resetState() { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + SsrfProtectionValidator.setEnabled(false); + } + + @Test + void isBlockedAddressWorksForLoopback() throws Exception { + assertThat(SsrfProtectionValidator.isBlockedAddress(InetAddress.getByName("127.0.0.1"))).isTrue(); + assertThat(SsrfProtectionValidator.isBlockedAddress(InetAddress.getByName("192.168.1.1"))).isTrue(); + assertThat(SsrfProtectionValidator.isBlockedAddress(InetAddress.getByName("8.8.8.8"))).isFalse(); + } + + @Test + void resolvePublicIpSucceeds() throws Exception { + EventExecutor executor = eventLoopGroup.next(); + AddressResolver resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); + Promise promise = executor.newPromise(); + + executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("8.8.8.8", 80), promise)); + InetSocketAddress result = promise.get(10, TimeUnit.SECONDS); + + assertThat(result.getAddress()).isNotNull(); + assertThat(result.getAddress().getHostAddress()).isEqualTo("8.8.8.8"); + } + + @Test + void resolveLoopbackFailsWhenSsrfEnabled() throws Exception { + assertThat(SsrfProtectionValidator.isEnabled()).isTrue(); + + EventExecutor executor = eventLoopGroup.next(); + AddressResolver resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); + Promise promise = executor.newPromise(); + + executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("127.0.0.1", 80), promise)); + + assertThatThrownBy(() -> promise.get(10, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class) + .hasRootCauseInstanceOf(RuntimeException.class) + .rootCause().hasMessageContaining("is not allowed"); + } + + @Test + void resolvePrivateIpFailsWhenSsrfEnabled() throws Exception { + assertThat(SsrfProtectionValidator.isEnabled()).isTrue(); + + EventExecutor executor = eventLoopGroup.next(); + AddressResolver resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); + Promise promise = executor.newPromise(); + + executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("192.168.1.1", 80), promise)); + + assertThatThrownBy(() -> promise.get(10, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class) + .hasRootCauseInstanceOf(RuntimeException.class) + .rootCause().hasMessageContaining("is not allowed"); + } + + @Test + void resolveAllowedPrivateIpSucceeds() throws Exception { + SsrfProtectionValidator.setAllowedHosts(List.of("192.168.1.0/24")); + + EventExecutor executor = eventLoopGroup.next(); + AddressResolver resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); + Promise promise = executor.newPromise(); + + executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("192.168.1.1", 80), promise)); + InetSocketAddress result = promise.get(10, TimeUnit.SECONDS); + + assertThat(result.getAddress().getHostAddress()).isEqualTo("192.168.1.1"); + } + + @Test + void resolveAllPublicIpSucceeds() throws Exception { + EventExecutor executor = eventLoopGroup.next(); + AddressResolver resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); + Promise> promise = executor.newPromise(); + + executor.submit(() -> resolver.resolveAll(InetSocketAddress.createUnresolved("8.8.8.8", 80), promise)); + List results = promise.get(10, TimeUnit.SECONDS); + + assertThat(results).isNotEmpty(); + assertThat(results.get(0).getAddress().getHostAddress()).isEqualTo("8.8.8.8"); + } + + @Test + void resolveAllPrivateIpFailsWhenSsrfEnabled() { + assertThatThrownBy(() -> { + EventExecutor executor = eventLoopGroup.next(); + AddressResolver resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); + Promise> promise = executor.newPromise(); + executor.submit(() -> resolver.resolveAll(InetSocketAddress.createUnresolved("127.0.0.1", 80), promise)); + promise.get(10, TimeUnit.SECONDS); + }).isInstanceOf(ExecutionException.class) + .hasRootCauseInstanceOf(RuntimeException.class) + .rootCause().hasMessageContaining("is not allowed"); + } + +} diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 8642ecffaa..feddd96386 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -13,16 +13,16 @@ }, "private": true, "dependencies": { - "@angular/animations": "20.3.17", + "@angular/animations": "20.3.18", "@angular/cdk": "20.2.14", - "@angular/common": "20.3.17", - "@angular/compiler": "20.3.17", - "@angular/core": "20.3.17", - "@angular/forms": "20.3.17", + "@angular/common": "20.3.18", + "@angular/compiler": "20.3.18", + "@angular/core": "20.3.18", + "@angular/forms": "20.3.18", "@angular/material": "20.2.14", - "@angular/platform-browser": "20.3.17", - "@angular/platform-browser-dynamic": "20.3.17", - "@angular/router": "20.3.17", + "@angular/platform-browser": "20.3.18", + "@angular/platform-browser-dynamic": "20.3.18", + "@angular/router": "20.3.18", "@auth0/angular-jwt": "^5.2.0", "@flowjs/flow.js": "^2.14.1", "@flowjs/ngx-flow": "20.0.2", @@ -94,13 +94,13 @@ }, "devDependencies": { "@angular-builders/custom-esbuild": "20.0.0", - "@angular-devkit/build-angular": "20.3.18", - "@angular-devkit/core": "20.3.18", - "@angular-devkit/schematics": "20.3.18", - "@angular/build": "20.3.18", - "@angular/cli": "20.3.18", - "@angular/compiler-cli": "20.3.17", - "@angular/language-service": "20.3.17", + "@angular-devkit/build-angular": "20.3.20", + "@angular-devkit/core": "20.3.20", + "@angular-devkit/schematics": "20.3.20", + "@angular/build": "20.3.20", + "@angular/cli": "20.3.20", + "@angular/compiler-cli": "20.3.18", + "@angular/language-service": "20.3.18", "@types/ace-diff": "^2.1.4", "@types/canvas-gauges": "^2.1.8", "@types/flot": "^0.0.36", @@ -121,7 +121,7 @@ "angular-eslint": "~20.7.0", "autoprefixer": "^10.4.23", "directory-tree": "^3.5.2", - "eslint": "9.39.3", + "eslint": "9.39.4", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsdoc": "^62.4.1", "eslint-plugin-prefer-arrow": "^1.2.3", diff --git a/ui-ngx/patches/@angular+build+20.3.18.patch b/ui-ngx/patches/@angular+build+20.3.20.patch similarity index 100% rename from ui-ngx/patches/@angular+build+20.3.18.patch rename to ui-ngx/patches/@angular+build+20.3.20.patch diff --git a/ui-ngx/patches/@angular+core+20.3.17.patch b/ui-ngx/patches/@angular+core+20.3.18.patch similarity index 92% rename from ui-ngx/patches/@angular+core+20.3.17.patch rename to ui-ngx/patches/@angular+core+20.3.18.patch index aa8fc928ba..12ceb3739d 100644 --- a/ui-ngx/patches/@angular+core+20.3.17.patch +++ b/ui-ngx/patches/@angular+core+20.3.18.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@angular/core/fesm2022/debug_node.mjs b/node_modules/@angular/core/fesm2022/debug_node.mjs -index d9be60f..24891ce 100755 +index 35c61af..d89462b 100755 --- a/node_modules/@angular/core/fesm2022/debug_node.mjs +++ b/node_modules/@angular/core/fesm2022/debug_node.mjs -@@ -9421,13 +9421,13 @@ function findDirectiveDefMatches(tView, tNode) { +@@ -9428,13 +9428,13 @@ function findDirectiveDefMatches(tView, tNode) { if (isNodeMatchingSelectorList(tNode, def.selectors, /* isProjectionMode */ false)) { matches ??= []; if (isComponentDef(def)) { diff --git a/ui-ngx/src/app/core/ws/websocket.service.ts b/ui-ngx/src/app/core/ws/websocket.service.ts index ea0884489f..2d7da5ae67 100644 --- a/ui-ngx/src/app/core/ws/websocket.service.ts +++ b/ui-ngx/src/app/core/ws/websocket.service.ts @@ -29,9 +29,11 @@ import { WebsocketDataMsg } from '@shared/models/telemetry/telemetry.models'; import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { NotificationType } from '@core/notification/notification.models'; import Timeout = NodeJS.Timeout; const RECONNECT_INTERVAL = 2000; +const MAX_RECONNECT_INTERVAL = 60000; const WS_IDLE_TIMEOUT = 90000; const MAX_PUBLISH_COMMANDS = 10; @@ -57,6 +59,16 @@ export abstract class WebsocketService implements WsServ errorName = 'WebSocket Error'; + // Exponential backoff: tracks the number of consecutive failed reconnect attempts. + // Reset only after a productive connection (i.e. at least one message received). + // This prevents the open→immediately-closed cycle from resetting the counter. + private reconnectAttempts = 0; + + // Stores the last close-event error code shown to the user during a reconnect cycle. + // Only suppresses duplicate notifications for the same error code; a new error code is still shown. + // Cleared after receiving a successful message. + private lastShownCloseCode: number | null = null; + protected constructor(protected store: Store, protected authService: AuthService, protected ngZone: NgZone, @@ -126,6 +138,8 @@ export abstract class WebsocketService implements WsServ this.subscribersCount = 0; this.cmdWrapper.clear(); if (close) { + this.reconnectAttempts = 0; + this.lastShownCloseCode = null; this.closeSocket(); } } @@ -221,6 +235,10 @@ export abstract class WebsocketService implements WsServ this.processOnMessage(message as WebsocketDataMsg); } this.checkToClose(); + if (this.reconnectAttempts) { + this.reconnectAttempts = 0; + this.lastShownCloseCode = null; + } } private onError(errorEvent) { @@ -231,8 +249,13 @@ export abstract class WebsocketService implements WsServ } private onClose(closeEvent: CloseEvent) { + // Show error notification only when the error code changes to prevent notification spam, + // while still surfacing new, potentially actionable errors during a reconnect cycle. + // lastShownCloseCode is cleared only after a productive connection (onMessage). if (closeEvent && closeEvent.code > 1001 && closeEvent.code !== 1006 - && closeEvent.code !== 1011 && closeEvent.code !== 1012 && closeEvent.code !== 4500) { + && closeEvent.code !== 1011 && closeEvent.code !== 1012 && closeEvent.code !== 4500 + && this.lastShownCloseCode !== closeEvent.code) { + this.lastShownCloseCode = closeEvent.code; this.showWsError(closeEvent.code, closeEvent.reason); } this.isOpening = false; @@ -251,18 +274,28 @@ export abstract class WebsocketService implements WsServ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); } - this.reconnectTimer = setTimeout(() => this.tryOpenSocket(), RECONNECT_INTERVAL); + const delay = Math.min(RECONNECT_INTERVAL * Math.pow(2, this.reconnectAttempts), MAX_RECONNECT_INTERVAL); + this.reconnectAttempts = Math.min(this.reconnectAttempts + 1, 10); + this.reconnectTimer = setTimeout(() => this.tryOpenSocket(), delay); } } private showWsError(errorCode: number, errorMsg: string) { let message = errorMsg; - if (!message) { - message += `${this.errorName}: error code - ${errorCode}.`; + let notificationType: NotificationType = 'error'; + + if (errorCode === 1008 || (errorMsg && errorMsg.includes('limit reached'))) { + message = 'Too many active sessions. Please close unused browser tabs or sign out from other devices'; + notificationType = 'warn'; + } else if (errorCode === 1009) { + message = 'Too much data to display. Please refresh the page or narrow your request.'; + notificationType = 'warn'; + } else if (!message) { + message = `${this.errorName}: error code - ${errorCode}.`; } - this.store.dispatch(new ActionNotificationShow( - { - message, type: 'error' - })); + + this.store.dispatch(new ActionNotificationShow({ + message, type: notificationType + })); } } diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 82fff6fbba..190f486536 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -160,24 +160,24 @@ "@angular-devkit/core" "^20.0.0" "@angular/build" "^20.0.0" -"@angular-devkit/architect@0.2003.18", "@angular-devkit/architect@>= 0.2000.0 < 0.2100.0", "@angular-devkit/architect@>=0.2000.0 < 0.2100.0": - version "0.2003.18" - resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.2003.18.tgz#60a1dcfe1d2401787e25e716059c81a9c8e6a08b" - integrity sha512-pPEDby3wQb40YSpH+UrjodJ78Z7q0Qvy3DTkS7mP2EIM4r0WVz8OlxLGS2uAc6tXSbIZe0bPp0B56P6uet3tUw== +"@angular-devkit/architect@0.2003.20", "@angular-devkit/architect@>= 0.2000.0 < 0.2100.0", "@angular-devkit/architect@>=0.2000.0 < 0.2100.0": + version "0.2003.20" + resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.2003.20.tgz#152edd3078be72b72c814fc625222e5ff5c90916" + integrity sha512-1g7q37Aq4dvDdQDW0PtWXfiX5hBV78K74QUtFkAXGIXU3DkguwOQaqHILCnIRAVr0wFlWDckWVuO5OT6Cl9HeQ== dependencies: - "@angular-devkit/core" "20.3.18" + "@angular-devkit/core" "20.3.20" rxjs "7.8.2" -"@angular-devkit/build-angular@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-angular/-/build-angular-20.3.18.tgz#41acc206eff8a20452520106949b11d231764f4f" - integrity sha512-ERjoEHnWDi9FFf7HBvJuWoDVHHEBvUL43vPp7vUAf3+0u/qOzXmuxFccdzT72BM1wU3y70MAXB76TUkr/KrxBA== +"@angular-devkit/build-angular@20.3.20": + version "20.3.20" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-angular/-/build-angular-20.3.20.tgz#452fdb36b0f931086107ee0c3af34c7882017037" + integrity sha512-ihBD8zsRoMsuwS3KkwqwwlJGw5jcLerG8zy1kdSEsdJwIYjITgMdV+iK02uSWptLTDd3ILOdgcoZzipcEwJX4w== dependencies: "@ampproject/remapping" "2.3.0" - "@angular-devkit/architect" "0.2003.18" - "@angular-devkit/build-webpack" "0.2003.18" - "@angular-devkit/core" "20.3.18" - "@angular/build" "20.3.18" + "@angular-devkit/architect" "0.2003.20" + "@angular-devkit/build-webpack" "0.2003.20" + "@angular-devkit/core" "20.3.20" + "@angular/build" "20.3.20" "@babel/core" "7.28.3" "@babel/generator" "7.28.3" "@babel/helper-annotate-as-pure" "7.27.3" @@ -188,12 +188,12 @@ "@babel/preset-env" "7.28.3" "@babel/runtime" "7.28.3" "@discoveryjs/json-ext" "0.6.3" - "@ngtools/webpack" "20.3.18" + "@ngtools/webpack" "20.3.20" ansi-colors "4.1.3" autoprefixer "10.4.21" babel-loader "10.0.0" browserslist "^4.21.5" - copy-webpack-plugin "13.0.1" + copy-webpack-plugin "14.0.0" css-loader "7.1.2" esbuild-wasm "0.25.9" fast-glob "3.3.3" @@ -230,15 +230,15 @@ optionalDependencies: esbuild "0.25.9" -"@angular-devkit/build-webpack@0.2003.18": - version "0.2003.18" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.2003.18.tgz#990a4670363346be3eff60e99163aa03b9257841" - integrity sha512-E70aiRrmnzk+nMQnEpg7KemqhjyI2X53zCC+1SGAVIISmtKyHQNoJDXa48im+bhrcjHpY2LOo0bFgb/vcWHc8A== +"@angular-devkit/build-webpack@0.2003.20": + version "0.2003.20" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.2003.20.tgz#e6fc4b16b448f2c45b46611ed602a9c34be7d67d" + integrity sha512-WLeYLFxRnEYwCrPUx8qciWs+PrJKDp71ZsIRqeFCcpB8qFQOgR/CjPZYxZFqAnuTJtM4MqCtczvmn57ulKTPcA== dependencies: - "@angular-devkit/architect" "0.2003.18" + "@angular-devkit/architect" "0.2003.20" rxjs "7.8.2" -"@angular-devkit/core@20.3.18", "@angular-devkit/core@>= 20.0.0 < 21.0.0", "@angular-devkit/core@^20.0.0": +"@angular-devkit/core@20.3.18": version "20.3.18" resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-20.3.18.tgz#a079775ba6a31583a0d57813b374a6c8c997f252" integrity sha512-zGWMjMqE8qXYr8baYCs43k9HlKz9J4Gh3Yx+7XE0uS0Y1LXzzALevSoUw7GIPdSvOriQJAEgtWE6QKssqSGltQ== @@ -250,12 +250,24 @@ rxjs "7.8.2" source-map "0.7.6" -"@angular-devkit/schematics@20.3.18", "@angular-devkit/schematics@>= 20.0.0 < 21.0.0": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-20.3.18.tgz#da5f9a84b2df9f13049974b6cd582475a3355d28" - integrity sha512-GRMEGl3YTL/qhQhaxYXLbSQxUTPTYMQ65IlxLQRq5+UKPomN9KVxxVdADXqs7Ss1uQcetr+jc+taVgxOqsAoxg== +"@angular-devkit/core@20.3.20", "@angular-devkit/core@>= 20.0.0 < 21.0.0", "@angular-devkit/core@^20.0.0": + version "20.3.20" + resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-20.3.20.tgz#62278795afb05ff1c2f442387e5c3e996ac304bf" + integrity sha512-Iobw7He3yJVR2aQ6JN9Kq/2ldD8+uHzJZwd41SQ91A+TzPrBRSV0t80WHHrANZ7xnAjtHDc7zSSGp/i7DzUc9g== + dependencies: + ajv "8.18.0" + ajv-formats "3.0.1" + jsonc-parser "3.3.1" + picomatch "4.0.3" + rxjs "7.8.2" + source-map "0.7.6" + +"@angular-devkit/schematics@20.3.20", "@angular-devkit/schematics@>= 20.0.0 < 21.0.0": + version "20.3.20" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-20.3.20.tgz#d7d2cc74134823305b9451a1920130daecdf8c61" + integrity sha512-+NNHQhQHcgQWZopStZ6os30YuP99lRzNS4wOnkJmoROy40SZct8lPnl2QW50a9Vc0AtaHx1a1NUZ+ohbf6fXqw== dependencies: - "@angular-devkit/core" "20.3.18" + "@angular-devkit/core" "20.3.20" jsonc-parser "3.3.1" magic-string "0.30.17" ora "8.2.0" @@ -321,20 +333,20 @@ dependencies: "@angular-eslint/bundled-angular-compiler" "20.7.0" -"@angular/animations@20.3.17": - version "20.3.17" - resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-20.3.17.tgz#a50991c88a148b7345d679a9961210942d5d6481" - integrity sha512-KvdgFjCTkOD3WVt4gzmJOoX914eey/Efu2Pb/KUM0Bqp1ZoXiFpI48GCd1b6Ks8JlDBeAfgjtpdSUB2aLnMRZQ== +"@angular/animations@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-20.3.18.tgz#e675e045839d559b4917053eb0f74313bf3235ef" + integrity sha512-XFxgSyjfs0SRD2vQVFJljmM4z9nTvUoI8TRqSre/+l8D2FgzD5pG67Aj2BgDgpSFAUkIcI37G48ijK7a3ZZ3WA== dependencies: tslib "^2.3.0" -"@angular/build@20.3.18", "@angular/build@^20.0.0": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/build/-/build-20.3.18.tgz#c9bdf9b94c62b9225509f04fcedfde822847c8bf" - integrity sha512-t+Bg0uxnyrbm5ADa8Ka5rz4bSdf8ScCnY8Hua3bLnIPITzeuuunV7a14zMSOcPL6eLu0760CvszHsGX1k6aN7A== +"@angular/build@20.3.20", "@angular/build@^20.0.0": + version "20.3.20" + resolved "https://registry.yarnpkg.com/@angular/build/-/build-20.3.20.tgz#8692af778824d43cd4e363330494144a70fa1950" + integrity sha512-+uWqGU+Qyso2uJKL1xNjAm+E3m3ncv5InMUG5BC/UhFnXTsL1o7oySGc6hHa+8rQunuifUhdy20HxZjU6QlqTw== dependencies: "@ampproject/remapping" "2.3.0" - "@angular-devkit/architect" "0.2003.18" + "@angular-devkit/architect" "0.2003.20" "@babel/core" "7.28.3" "@babel/helper-annotate-as-pure" "7.27.3" "@babel/helper-split-export-declaration" "7.24.7" @@ -370,18 +382,18 @@ parse5 "^8.0.0" tslib "^2.3.0" -"@angular/cli@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-20.3.18.tgz#3f635c64818c64df8d9358aa6b3680fad48c36cb" - integrity sha512-I0kanxt3vzedZmLY4FLoxgo3yGG1mWoiGLlzwEslJdLJj5X1zd422WPtTygZgEHFHcGxR9qxdQ+PsPdMRwykQA== +"@angular/cli@20.3.20": + version "20.3.20" + resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-20.3.20.tgz#adbffbbcaf5ffda133e2f86dcb83dbfb86671ca4" + integrity sha512-wHMo0BWhoBMXcsZ1U+yJ0eOH2B5NgCWW7PTrJ1P/QbocbCSh7eqw8wKnEthIKyhrEWtAo67Jw7i8CgbeIZgmMw== dependencies: - "@angular-devkit/architect" "0.2003.18" - "@angular-devkit/core" "20.3.18" - "@angular-devkit/schematics" "20.3.18" + "@angular-devkit/architect" "0.2003.20" + "@angular-devkit/core" "20.3.20" + "@angular-devkit/schematics" "20.3.20" "@inquirer/prompts" "7.8.2" "@listr2/prompt-adapter-inquirer" "3.0.1" "@modelcontextprotocol/sdk" "1.26.0" - "@schematics/angular" "20.3.18" + "@schematics/angular" "20.3.20" "@yarnpkg/lockfile" "1.1.0" algoliasearch "5.35.0" ini "5.0.0" @@ -394,17 +406,17 @@ yargs "18.0.0" zod "4.1.13" -"@angular/common@20.3.17": - version "20.3.17" - resolved "https://registry.yarnpkg.com/@angular/common/-/common-20.3.17.tgz#891d08610683c2b22edd3e64684647a911458cee" - integrity sha512-Dqd8f8o9MehszTZIB7o7jrERlwLOSK64gNngK14DCQazz5lpIhAF6hBjx7zjHpa7L9eAYPK1TaxQUXypjzj18Q== +"@angular/common@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/common/-/common-20.3.18.tgz#311dc0658be69f1368db2eeea92ea1b940329975" + integrity sha512-M62oQbSTRmnGavIVCwimoadg/PDWadgNhactMm9fgH0eM9rx+iWBAYJk4VufO0bwOhysFpRZpJgXlFjOifz/Jw== dependencies: tslib "^2.3.0" -"@angular/compiler-cli@20.3.17": - version "20.3.17" - resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-20.3.17.tgz#e1ab0799090e9e5b8d656f432ab102db080d9b34" - integrity sha512-w5pmO1pXO9tUMgUMWstpDmAWh5s1lJWo+2GI/ByaUEgBZkXd2S92sWoDL+bhy+JSvFzdLGdua6BncHBOX7hEjA== +"@angular/compiler-cli@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-20.3.18.tgz#0f4726d1624c9def0f60c65f60e61a226676459c" + integrity sha512-zsoEgLgnblmRbi47YwMghKirJ8IBKJ3+I8TxLBRIBrhx+KHFp+6oeDeLyu9H+djdyk88zexVd09wzR/YK73F0g== dependencies: "@babel/core" "7.28.3" "@jridgewell/sourcemap-codec" "^1.4.14" @@ -415,31 +427,31 @@ tslib "^2.3.0" yargs "^18.0.0" -"@angular/compiler@20.3.17": - version "20.3.17" - resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-20.3.17.tgz#ae64caadfc80ebc08d145723a7fa107d710ee7a8" - integrity sha512-cj3x6aFk9xOOxX+qEdeN8T5YbnBNWJ4UMHB/LQoDr7/xCJJGa40IhcOAuJeuF2kGqTwx6MCXnvjO8XOQfHhe9g== +"@angular/compiler@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-20.3.18.tgz#5370dc1a24d55623828a2b0875c776c8da13fdc6" + integrity sha512-AaP/LCiDNcYmF135EEozjyR04NRBT38ZfBHQwjhgwiBBTejmvcpHwJaHSkraLpZqZzE4BQqqmgiQ1EJqxEwLVA== dependencies: tslib "^2.3.0" -"@angular/core@20.3.17": - version "20.3.17" - resolved "https://registry.yarnpkg.com/@angular/core/-/core-20.3.17.tgz#e396f0f2a6f47a85524e8d313082f65e35001059" - integrity sha512-YlQqxMeHI9XJw7I7oM3hYFQd4lQbK37IdlD9ztROIw5FjX6i6lmLU7+X1MQGSRi2r+X9l3IZtl33hRTNvkoUBw== +"@angular/core@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/core/-/core-20.3.18.tgz#af91805841184cd87e862134a806bba019d170ea" + integrity sha512-B+NQQngd/aDbcfW0zGLis3wTLDeHTeTYMl/mGKQH+HwdPaRCKI1wEtaXaOYVJXkP2FeThocPevB8gLwNlPQUUw== dependencies: tslib "^2.3.0" -"@angular/forms@20.3.17": - version "20.3.17" - resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-20.3.17.tgz#94735ced545516ae52ccc59e1593eeaa6bae8edb" - integrity sha512-iGS6NwzcyJzinbPMapsQtcN0ZJ62vr6hcul+FNa40CaK2ePC04S+C5n+DIphzwnwsFHDBIWuTQRfk/lNYdN1JA== +"@angular/forms@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-20.3.18.tgz#ebd249a381dd3f6e5af3f760d7c3a8a6a76531ac" + integrity sha512-x6/99LfxolyZIFUL3Wr0OrtuXHEDwEz/rwx+WzE7NL+n35yO40t3kp0Sn5uMFwI94i91QZJmXHltMpZhrVLuYg== dependencies: tslib "^2.3.0" -"@angular/language-service@20.3.17": - version "20.3.17" - resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-20.3.17.tgz#8dd38151eb53db0a96138d2711e3fcaeb2b2cfed" - integrity sha512-ccNK/+FDXHWeQwfRKghzk4JDEtoT1QQA6GWDTujn+/pgai6NjQ0NNKGbqgrC1zOzEQRUo4Nz6xN1UXQSYMxCnA== +"@angular/language-service@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-20.3.18.tgz#b0c8c9bcf085907765e3cb7fbf0e14e79259e3c0" + integrity sha512-V1ZBqeTtZYH9H8/G1qCw6gafsJmhMIMFjLX0Hv2KpTpmfK9nxIHPEVnshr3xT+qKYJIrMV/cU5YOzInEapLpuQ== "@angular/material@20.2.14": version "20.2.14" @@ -448,24 +460,24 @@ dependencies: tslib "^2.3.0" -"@angular/platform-browser-dynamic@20.3.17": - version "20.3.17" - resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.17.tgz#c3dc69593176ac3b8802eaa9c0d3ab86c642ee11" - integrity sha512-yTxFuGQ+z0J9khNIhfFZ+kkT7TOFb8kFZKyUz0DxHOmE0q/TEvNZoy3jXOs8xCBFf1+6BY0NqFNlPna+uw36FQ== +"@angular/platform-browser-dynamic@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.18.tgz#c4c633fcc4782e1a361a0904d2bc3f180bbf0f81" + integrity sha512-NyTobOGYVzGmPmtI+3lxMzxi0TbLq4SRNQ2ENEJAt6k2JnMmHBm483ppLRAM47nGlDdiraW0IX93EtYYNkiK3g== dependencies: tslib "^2.3.0" -"@angular/platform-browser@20.3.17": - version "20.3.17" - resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-20.3.17.tgz#e156693a769284859fdbac76c95f6cc738bcd2f9" - integrity sha512-GA8pK+0F2/KGdYn5LMpLBrPTkQUwGjQE8Q+qsivOa150cK3OuD0po5PvYK58l+niGIVvm0wB1xGKTHTOiX/+4A== +"@angular/platform-browser@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-20.3.18.tgz#33b5d5cdddc4a0f60a9ddf886b941587a348d304" + integrity sha512-q6s5rEN1yYazpHYp+k4pboXRzMsRB9auzTRBEhyXSGYxqzrnn3qHN0DqgsLC9WAdyhCgnIEMFA8kRT+W277DqQ== dependencies: tslib "^2.3.0" -"@angular/router@20.3.17": - version "20.3.17" - resolved "https://registry.yarnpkg.com/@angular/router/-/router-20.3.17.tgz#a9390ce0c3f2fbdbc02ffc1b224e375bd5e0e0d3" - integrity sha512-p0r0IOJhUcn8WHx4gkSlfwifkkYO5mSDtq4iM5OunZTlSaeSxLb1vTRg2VBgwdzpgAM+eZSMBTTVF/M3pdoELQ== +"@angular/router@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/router/-/router-20.3.18.tgz#276fe53975ceb858f4e76405f8219fbe5cb20204" + integrity sha512-3CWejsEYr+ze+ktvWN/qHdyq5WLrj96QZpGYJyxh1pchIcpMPE9MmLpdjf0CUrWYB7g/85u0Geq/xsz72JrGng== dependencies: tslib "^2.3.0" @@ -1531,14 +1543,14 @@ resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== -"@eslint/config-array@^0.21.1": - version "0.21.1" - resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.1.tgz#7d1b0060fea407f8301e932492ba8c18aff29713" - integrity sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA== +"@eslint/config-array@^0.21.2": + version "0.21.2" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.2.tgz#f29e22057ad5316cf23836cee9a34c81fffcb7e6" + integrity sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw== dependencies: "@eslint/object-schema" "^2.1.7" debug "^4.3.1" - minimatch "^3.1.2" + minimatch "^3.1.5" "@eslint/config-helpers@^0.4.2": version "0.4.2" @@ -1554,25 +1566,25 @@ dependencies: "@types/json-schema" "^7.0.15" -"@eslint/eslintrc@^3.3.1": - version "3.3.3" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.3.tgz#26393a0806501b5e2b6a43aa588a4d8df67880ac" - integrity sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ== +"@eslint/eslintrc@^3.3.5": + version "3.3.5" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz#c131793cfc1a7b96f24a83e0a8bbd4b881558c60" + integrity sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg== dependencies: - ajv "^6.12.4" + ajv "^6.14.0" debug "^4.3.2" espree "^10.0.1" globals "^14.0.0" ignore "^5.2.0" import-fresh "^3.2.1" js-yaml "^4.1.1" - minimatch "^3.1.2" + minimatch "^3.1.5" strip-json-comments "^3.1.1" -"@eslint/js@9.39.3": - version "9.39.3" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.3.tgz#c6168736c7e0c43ead49654ed06a4bcb3833363d" - integrity sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw== +"@eslint/js@9.39.4": + version "9.39.4" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.4.tgz#a3f83bfc6fd9bf33a853dfacd0b49b398eb596c1" + integrity sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw== "@eslint/object-schema@^2.1.7": version "2.1.7" @@ -1612,9 +1624,9 @@ polyclip-ts "^0.16.5" "@hono/node-server@^1.19.9": - version "1.19.9" - resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.9.tgz#8f37119b1acf283fd3f6035f3d1356fdb97a09ac" - integrity sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw== + version "1.19.11" + resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.11.tgz#dc419f0826dd2504e9fc86ad289d5636a0444e2f" + integrity sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g== "@humanfs/core@^0.19.1": version "0.19.1" @@ -2397,10 +2409,10 @@ dependencies: tslib "^2.0.0" -"@ngtools/webpack@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-20.3.18.tgz#b32c7e2b9681bf1813077efc99b781ca8cc37999" - integrity sha512-dFH/K6byV9oWbLIDoI/RTgYkbIqaofNr7PHVkH8MhMi/rSoKEgf2Un/xjaD7zCsODHou7HE/jfiXgC+6Adzveg== +"@ngtools/webpack@20.3.20": + version "20.3.20" + resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-20.3.20.tgz#4d45b14ee374349352b90b2db6049b9901f80b9d" + integrity sha512-5azJ9+W/aMFR4C38ShoWib6xW5ou5Q5yeup+6skpvrud5mAAj3e36S5diYYXqbzVfo3DM+sWueDRNP3DV7IHhg== "@ngx-translate/core@^17.0.0": version "17.0.0" @@ -2751,13 +2763,13 @@ resolved "https://registry.yarnpkg.com/@sanity/diff-match-patch/-/diff-match-patch-3.2.0.tgz#7ce587273f7372a146308cb1075ba26177d42cdb" integrity sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww== -"@schematics/angular@20.3.18": - version "20.3.18" - resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-20.3.18.tgz#5b435f15837ac8b6faaf34fa63ab8589d67b3737" - integrity sha512-JZdvBNrWODBTLrmtUF6+UD26z5cENpV0X9liR1jPDT1O7taQqwRePSuCQcjRo1qXCjlNfBW7pGGVxVCRKK8EXw== +"@schematics/angular@20.3.20": + version "20.3.20" + resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-20.3.20.tgz#22b0e87bab0aeca0ddbaef7014eac5033f813972" + integrity sha512-LyoHwenNi8lZZO5zafAjcN8DcaOdjFrkbZdrCkvdpmOFz5wy8ZGchY6XSZEDD6kdHvR8oU7WWnGTMgCfExBKCg== dependencies: - "@angular-devkit/core" "20.3.18" - "@angular-devkit/schematics" "20.3.18" + "@angular-devkit/core" "20.3.20" + "@angular-devkit/schematics" "20.3.20" jsonc-parser "3.3.1" "@sigstore/bundle@^4.0.0": @@ -3696,7 +3708,7 @@ adjust-sourcemap-loader@^4.0.0: loader-utils "^2.0.0" regex-parser "^2.2.11" -agent-base@^7.1.0, agent-base@^7.1.1, agent-base@^7.1.2: +agent-base@^7.1.0, agent-base@^7.1.2: version "7.1.4" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== @@ -3732,10 +3744,10 @@ ajv@8.18.0, ajv@^8.0.0, ajv@^8.17.1, ajv@^8.9.0: json-schema-traverse "^1.0.0" require-from-string "^2.0.2" -ajv@^6.12.4: - version "6.12.6" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" - integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== +ajv@^6.14.0: + version "6.14.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a" + integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -4605,15 +4617,15 @@ copy-anything@^2.0.1: dependencies: is-what "^3.14.1" -copy-webpack-plugin@13.0.1: - version "13.0.1" - resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz#fba18c22bcab3633524e1b652580ff4489eddc0d" - integrity sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw== +copy-webpack-plugin@14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz#cd253b60e8e55bb41019dfe3ef2979ba705592c7" + integrity sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA== dependencies: glob-parent "^6.0.1" normalize-path "^3.0.0" schema-utils "^4.2.0" - serialize-javascript "^6.0.2" + serialize-javascript "^7.0.3" tinyglobby "^0.2.12" core-js-compat@^3.43.0: @@ -5685,24 +5697,24 @@ eslint-visitor-keys@^5.0.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz#b9aa1a74aa48c44b3ae46c1597ce7171246a94a9" integrity sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q== -eslint@9.39.3: - version "9.39.3" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.3.tgz#08d63df1533d7743c0907b32a79a7e134e63ee2f" - integrity sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg== +eslint@9.39.4: + version "9.39.4" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.4.tgz#855da1b2e2ad66dc5991195f35e262bcec8117b5" + integrity sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ== dependencies: "@eslint-community/eslint-utils" "^4.8.0" "@eslint-community/regexpp" "^4.12.1" - "@eslint/config-array" "^0.21.1" + "@eslint/config-array" "^0.21.2" "@eslint/config-helpers" "^0.4.2" "@eslint/core" "^0.17.0" - "@eslint/eslintrc" "^3.3.1" - "@eslint/js" "9.39.3" + "@eslint/eslintrc" "^3.3.5" + "@eslint/js" "9.39.4" "@eslint/plugin-kit" "^0.4.1" "@humanfs/node" "^0.16.6" "@humanwhocodes/module-importer" "^1.0.1" "@humanwhocodes/retry" "^0.4.2" "@types/estree" "^1.0.6" - ajv "^6.12.4" + ajv "^6.14.0" chalk "^4.0.0" cross-spawn "^7.0.6" debug "^4.3.2" @@ -5721,7 +5733,7 @@ eslint@9.39.3: is-glob "^4.0.0" json-stable-stringify-without-jsonify "^1.0.1" lodash.merge "^4.6.2" - minimatch "^3.1.2" + minimatch "^3.1.5" natural-compare "^1.4.0" optionator "^0.9.3" @@ -5815,11 +5827,11 @@ exponential-backoff@^3.1.1: integrity sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw== express-rate-limit@^8.2.1: - version "8.2.1" - resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-8.2.1.tgz#ec75fdfe280ecddd762b8da8784c61bae47d7f7f" - integrity sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g== + version "8.3.1" + resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-8.3.1.tgz#0aaba098eadd40f6737f30a98e6b16fa1a29edfb" + integrity sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw== dependencies: - ip-address "10.0.1" + ip-address "10.1.0" express@^4.21.2: version "4.22.1" @@ -6033,9 +6045,9 @@ flat@^5.0.2: integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== flatted@^3.2.9: - version "3.3.2" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.2.tgz#adba1448a9841bec72b42c532ea23dbbedef1a27" - integrity sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA== + version "3.4.1" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.1.tgz#84ccd9579e76e9cc0d246c11d8be0beb019143e6" + integrity sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ== "flot.curvedlines@https://github.com/MichaelZinsmaier/CurvedLines.git#master": version "1.1.1" @@ -6383,9 +6395,9 @@ hasown@^2.0.2: function-bind "^1.1.2" hono@^4.11.4: - version "4.12.2" - resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.2.tgz#05c311c271b06685a0f229c484e3a2637d7d5f2a" - integrity sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg== + version "4.12.8" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.8.tgz#5f3a9c0d5339ff460b2c652a4c64dd79059930ad" + integrity sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A== hosted-git-info@^9.0.0: version "9.0.2" @@ -6575,9 +6587,9 @@ immediate@~3.0.5: integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== immutable@^5.0.2: - version "5.1.4" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.4.tgz#e3f8c1fe7b567d56cf26698f31918c241dae8c1f" - integrity sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA== + version "5.1.5" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.5.tgz#93ee4db5c2a9ab42a4a783069f3c5d8847d40165" + integrity sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A== import-fresh@^3.2.1, import-fresh@^3.3.0: version "3.3.0" @@ -6641,18 +6653,10 @@ internmap@^1.0.0: resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95" integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw== -ip-address@10.0.1: - version "10.0.1" - resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.0.1.tgz#a8180b783ce7788777d796286d61bce4276818ed" - integrity sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA== - -ip-address@^9.0.5: - version "9.0.5" - resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-9.0.5.tgz#117a960819b08780c3bd1f14ef3c1cc1d3f3ea5a" - integrity sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g== - dependencies: - jsbn "1.1.0" - sprintf-js "^1.1.3" +ip-address@10.1.0, ip-address@^10.0.1: + version "10.1.0" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.1.0.tgz#d8dcffb34d0e02eb241427444a6e23f5b0595aa4" + integrity sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q== ipaddr.js@1.9.1: version "1.9.1" @@ -7080,11 +7084,6 @@ js-yaml@^4.1.0, js-yaml@^4.1.1: dependencies: argparse "^2.0.1" -jsbn@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040" - integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A== - jsdoc-type-pratt-parser@~7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.1.0.tgz#f2d63cbbc3d0d4eaea257eb5f847e8ebc5908dd5" @@ -7761,10 +7760,10 @@ minimatch@^10.0.3, minimatch@^10.1.1: dependencies: brace-expansion "^5.0.2" -minimatch@^3.1.2: - version "3.1.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.4.tgz#89d910ea3970a77ac8edfd30340ccd038b758079" - integrity sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw== +minimatch@^3.1.2, minimatch@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" + integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== dependencies: brace-expansion "^1.1.7" @@ -8865,13 +8864,6 @@ quickselect@^3.0.0: resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-3.0.0.tgz#a37fc953867d56f095a20ac71c6d27063d2de603" integrity sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g== -randombytes@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" - integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== - dependencies: - safe-buffer "^5.1.0" - range-parser@^1.2.1, range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -9223,7 +9215,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -9379,12 +9371,10 @@ send@~0.19.0, send@~0.19.1: range-parser "~1.2.1" statuses "~2.0.2" -serialize-javascript@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" - integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== - dependencies: - randombytes "^2.1.0" +serialize-javascript@^7.0.3: + version "7.0.4" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-7.0.4.tgz#c517735bd5b7631dd1fc191ee19cbb713ff8e05c" + integrity sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg== serve-index@^1.9.1: version "1.9.1" @@ -9587,20 +9577,20 @@ sockjs@^0.3.24: websocket-driver "^0.7.4" socks-proxy-agent@^8.0.3: - version "8.0.4" - resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.4.tgz#9071dca17af95f483300316f4b063578fa0db08c" - integrity sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw== + version "8.0.5" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz#b9cdb4e7e998509d7659d689ce7697ac21645bee" + integrity sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw== dependencies: - agent-base "^7.1.1" + agent-base "^7.1.2" debug "^4.3.4" socks "^2.8.3" socks@^2.8.3: - version "2.8.3" - resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.3.tgz#1ebd0f09c52ba95a09750afe3f3f9f724a800cb5" - integrity sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw== + version "2.8.7" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.7.tgz#e2fb1d9a603add75050a2067db8c381a0b5669ea" + integrity sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A== dependencies: - ip-address "^9.0.5" + ip-address "^10.0.1" smart-buffer "^4.2.0" sorted-btree@^1.8.1: @@ -9706,11 +9696,6 @@ split.js@^1.6.5: resolved "https://registry.yarnpkg.com/split.js/-/split.js-1.6.5.tgz#f7f61da1044c9984cb42947df4de4fadb5a3f300" integrity sha512-mPTnGCiS/RiuTNsVhCm9De9cCAUsrNFFviRbADdKiiV+Kk8HKp/0fWu7Kr8pi3/yBmsqLFHuXGT9UUZ+CNLwFw== -sprintf-js@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" - integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== - sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -9959,9 +9944,9 @@ tapable@^2.2.1, tapable@^2.3.0: integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg== tar@^7.4.3, tar@^7.5.2: - version "7.5.9" - resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.9.tgz#817ac12a54bc4362c51340875b8985d7dc9724b8" - integrity sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg== + version "7.5.11" + resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.11.tgz#1250fae45d98806b36d703b30973fa8e0a6d8868" + integrity sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ== dependencies: "@isaacs/fs-minipass" "^4.0.0" chownr "^3.0.0" @@ -9970,14 +9955,13 @@ tar@^7.4.3, tar@^7.5.2: yallist "^5.0.0" terser-webpack-plugin@^5.3.16: - version "5.3.16" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz#741e448cc3f93d8026ebe4f7ef9e4afacfd56330" - integrity sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q== + version "5.4.0" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz#95fc4cf4437e587be11ecf37d08636089174d76b" + integrity sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g== dependencies: "@jridgewell/trace-mapping" "^0.3.25" jest-worker "^27.4.5" schema-utils "^4.3.0" - serialize-javascript "^6.0.2" terser "^5.31.1" terser@5.43.1: