From 0ac3ff676b0cd492e5fdf5b791d6728d9b0284f4 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Wed, 11 Mar 2026 14:35:31 +0200 Subject: [PATCH 01/24] Add exponential backoff, error dedup, and friendly UX for WebSocket reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce exponential backoff (2s → 60s cap) for WebSocket reconnect attempts and suppress duplicate close-event error notifications during reconnect cycles. Improve UX by showing session limit and data size errors as user-friendly warnings instead of raw error codes. --- ui-ngx/src/app/core/ws/websocket.service.ts | 46 +++++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/ui-ngx/src/app/core/ws/websocket.service.ts b/ui-ngx/src/app/core/ws/websocket.service.ts index ea0884489f..3f72bb89ff 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,15 @@ 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; + + // Suppress duplicate close-event notifications while retrying. + // Set on first close with an error code; cleared after receiving a successful message. + private reconnectErrorShown = false; + protected constructor(protected store: Store, protected authService: AuthService, protected ngZone: NgZone, @@ -126,6 +137,8 @@ export abstract class WebsocketService implements WsServ this.subscribersCount = 0; this.cmdWrapper.clear(); if (close) { + this.reconnectAttempts = 0; + this.reconnectErrorShown = false; this.closeSocket(); } } @@ -221,6 +234,10 @@ export abstract class WebsocketService implements WsServ this.processOnMessage(message as WebsocketDataMsg); } this.checkToClose(); + if (this.reconnectAttempts) { + this.reconnectAttempts = 0; + this.reconnectErrorShown = false; + } } private onError(errorEvent) { @@ -231,8 +248,11 @@ export abstract class WebsocketService implements WsServ } private onClose(closeEvent: CloseEvent) { - if (closeEvent && closeEvent.code > 1001 && closeEvent.code !== 1006 + // Show error notification only once per reconnect cycle to prevent notification spam. + // reconnectErrorShown is cleared only after a productive connection (onMessage). + if (!this.reconnectErrorShown && closeEvent && closeEvent.code > 1001 && closeEvent.code !== 1006 && closeEvent.code !== 1011 && closeEvent.code !== 1012 && closeEvent.code !== 4500) { + this.reconnectErrorShown = true; this.showWsError(closeEvent.code, closeEvent.reason); } this.isOpening = false; @@ -251,18 +271,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 + })); } } From 6d127a0f0352ff3f70be3401f2dbd052e2afebcb Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 13 Mar 2026 16:02:14 +0200 Subject: [PATCH 02/24] Fixed websocket.service.ts show error when change error code --- ui-ngx/src/app/core/ws/websocket.service.ts | 23 ++++++++++++--------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/ui-ngx/src/app/core/ws/websocket.service.ts b/ui-ngx/src/app/core/ws/websocket.service.ts index 3f72bb89ff..2d7da5ae67 100644 --- a/ui-ngx/src/app/core/ws/websocket.service.ts +++ b/ui-ngx/src/app/core/ws/websocket.service.ts @@ -64,9 +64,10 @@ export abstract class WebsocketService implements WsServ // This prevents the open→immediately-closed cycle from resetting the counter. private reconnectAttempts = 0; - // Suppress duplicate close-event notifications while retrying. - // Set on first close with an error code; cleared after receiving a successful message. - private reconnectErrorShown = false; + // 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, @@ -138,7 +139,7 @@ export abstract class WebsocketService implements WsServ this.cmdWrapper.clear(); if (close) { this.reconnectAttempts = 0; - this.reconnectErrorShown = false; + this.lastShownCloseCode = null; this.closeSocket(); } } @@ -236,7 +237,7 @@ export abstract class WebsocketService implements WsServ this.checkToClose(); if (this.reconnectAttempts) { this.reconnectAttempts = 0; - this.reconnectErrorShown = false; + this.lastShownCloseCode = null; } } @@ -248,11 +249,13 @@ export abstract class WebsocketService implements WsServ } private onClose(closeEvent: CloseEvent) { - // Show error notification only once per reconnect cycle to prevent notification spam. - // reconnectErrorShown is cleared only after a productive connection (onMessage). - if (!this.reconnectErrorShown && closeEvent && closeEvent.code > 1001 && closeEvent.code !== 1006 - && closeEvent.code !== 1011 && closeEvent.code !== 1012 && closeEvent.code !== 4500) { - this.reconnectErrorShown = true; + // 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 + && this.lastShownCloseCode !== closeEvent.code) { + this.lastShownCloseCode = closeEvent.code; this.showWsError(closeEvent.code, closeEvent.reason); } this.isOpening = false; From 53f7c918e4f8aaa6745a534bc14d264bdca28418 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Mon, 16 Mar 2026 11:02:34 +0100 Subject: [PATCH 03/24] Fix Gradle parallel build cache contention in Maven -T builds When Maven runs with -T N, all modules using the packaging profile invoke gradle-maven-plugin against the same gradleProjectDirectory (packaging/java or packaging/js), causing them to share and contend on the same .gradle/ project cache directory simultaneously. Two fixes: - Pass --project-cache-dir pointing to each module's own target/.gradle, fully isolating parallel Gradle invocations from each other. - Add maven-clean-plugin filesets to remove packaging/java/.gradle and packaging/js/.gradle on mvn clean. Gradle always writes project-level metadata to the project directory regardless of --project-cache-dir, so these would otherwise accumulate on CI agents with persistent home directories. --- pom.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/pom.xml b/pom.xml index bc83721914..5dcb3f7a77 100755 --- a/pom.xml +++ b/pom.xml @@ -532,6 +532,8 @@ -PpkgInstallFolder=${pkg.installFolder} -PpkgCopyInstallScripts=${pkg.copyInstallScripts} -PpkgLogFolder=${pkg.unixLogFolder} + --project-cache-dir + ${project.build.directory}/.gradle --warning-mode all @@ -888,6 +890,21 @@ com.mycila license-maven-plugin + + org.apache.maven.plugins + maven-clean-plugin + false + + + + ${main.dir}/packaging/java/.gradle + + + ${main.dir}/packaging/js/.gradle + + + + From 5d87cfc81355b70353ad1cfb11623b44bee6d7e1 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Mon, 16 Mar 2026 12:38:53 +0200 Subject: [PATCH 04/24] Fixed CVE-2026-32635 --- ui-ngx/package.json | 30 +-- ....18.patch => @angular+build+20.3.20.patch} | 0 ...3.17.patch => @angular+core+20.3.18.patch} | 4 +- ui-ngx/yarn.lock | 208 ++++++++++-------- 4 files changed, 135 insertions(+), 107 deletions(-) rename ui-ngx/patches/{@angular+build+20.3.18.patch => @angular+build+20.3.20.patch} (100%) rename ui-ngx/patches/{@angular+core+20.3.17.patch => @angular+core+20.3.18.patch} (92%) diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 564af381ea..aa3e0cd347 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", 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/yarn.lock b/ui-ngx/yarn.lock index 82fff6fbba..6792ceda0e 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,7 +250,30 @@ rxjs "7.8.2" source-map "0.7.6" -"@angular-devkit/schematics@20.3.18", "@angular-devkit/schematics@>= 20.0.0 < 21.0.0": +"@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": + 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.20" + jsonc-parser "3.3.1" + magic-string "0.30.17" + ora "8.2.0" + rxjs "7.8.2" + +"@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== @@ -321,20 +344,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 +393,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 +417,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 +438,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 +471,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" @@ -2397,10 +2420,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 +2774,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": @@ -4605,15 +4628,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: @@ -9386,6 +9409,11 @@ serialize-javascript@^6.0.2: 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" resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" From de662463f871ec1c4c9036bb619b0618c4a2c995 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Mon, 16 Mar 2026 12:43:04 +0200 Subject: [PATCH 05/24] Fixed CVE-2026-29063 --- ui-ngx/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 6792ceda0e..aee4dd5d3d 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -6598,9 +6598,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" From ae007dee3f9a53dcd6a68a4883326b5eb2f1645a Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Mon, 16 Mar 2026 12:47:36 +0200 Subject: [PATCH 06/24] Fixed CVE-2026-29087 --- ui-ngx/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index aee4dd5d3d..e17ab39b3e 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -1635,9 +1635,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" From db502d43703b0d9462277674cb7e745804fb22fb Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Mon, 16 Mar 2026 12:50:37 +0200 Subject: [PATCH 07/24] Fixed CVE-2026-29087 --- ui-ngx/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index e17ab39b3e..2f48089ebc 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -6406,9 +6406,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" From 896bf0d15913b62449dc0cb013aba73e77db666e Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Mon, 16 Mar 2026 12:59:36 +0200 Subject: [PATCH 08/24] Fixed CWE-96 in terser-webpack-plugin (GHSA-5c6j-r48x-rmvq) --- ui-ngx/yarn.lock | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 2f48089ebc..1c39bb038e 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -8888,13 +8888,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" @@ -9246,7 +9239,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== @@ -9402,13 +9395,6 @@ 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" @@ -9998,14 +9984,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: From 7d03156dfb5e5292e2c86e03e2e53b08e79bce67 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Mon, 16 Mar 2026 13:03:49 +0200 Subject: [PATCH 09/24] Fixed CVE-2026-31802 and CVE-2026-29786 --- msa/js-executor/yarn.lock | 6 +++--- msa/web-ui/yarn.lock | 6 +++--- ui-ngx/yarn.lock | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/msa/js-executor/yarn.lock b/msa/js-executor/yarn.lock index 6457be1de7..46c6df4aa4 100644 --- a/msa/js-executor/yarn.lock +++ b/msa/js-executor/yarn.lock @@ -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/yarn.lock b/msa/web-ui/yarn.lock index 11a708d8bf..fad42b41c1 100644 --- a/msa/web-ui/yarn.lock +++ b/msa/web-ui/yarn.lock @@ -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/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 1c39bb038e..4bbd4d1667 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -9973,9 +9973,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" From b37fbbb2ec37ebf67ddb6cd2b0af16143b9dd71d Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Mon, 16 Mar 2026 13:15:11 +0200 Subject: [PATCH 10/24] Fixed CVE-2026-30827 --- ui-ngx/yarn.lock | 52 ++++++++++++++++-------------------------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 4bbd4d1667..a4a558a662 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -3719,7 +3719,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== @@ -5838,11 +5838,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" @@ -6664,18 +6664,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" @@ -7103,11 +7095,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" @@ -9601,20 +9588,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: @@ -9720,11 +9707,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" From 7fe24d2d6cddba4929b041c4f2f5fc5dc51e504e Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Mon, 16 Mar 2026 13:20:10 +0200 Subject: [PATCH 11/24] Fixed CVE-2026-32141 --- ui-ngx/package.json | 2 +- ui-ngx/yarn.lock | 69 +++++++++++++++++++++++++-------------------- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/ui-ngx/package.json b/ui-ngx/package.json index aa3e0cd347..d479a75a5d 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -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/yarn.lock b/ui-ngx/yarn.lock index a4a558a662..bd5151e86e 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -1554,14 +1554,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" @@ -1577,25 +1577,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" @@ -3755,10 +3755,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" @@ -5708,24 +5708,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" @@ -5744,7 +5744,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" @@ -6056,9 +6056,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" @@ -7778,6 +7778,13 @@ minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" +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" + minimist@1.2.8, minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" From ec2b2bd0d3b514716e59d359aa22bc72e96ff684 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Mon, 16 Mar 2026 13:25:51 +0200 Subject: [PATCH 12/24] Fixed CVE-2026-27904 --- msa/js-executor/yarn.lock | 6 +++--- msa/web-ui/yarn.lock | 6 +++--- ui-ngx/yarn.lock | 9 +-------- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/msa/js-executor/yarn.lock b/msa/js-executor/yarn.lock index 46c6df4aa4..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" diff --git a/msa/web-ui/yarn.lock b/msa/web-ui/yarn.lock index fad42b41c1..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" diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index bd5151e86e..8359228121 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -7771,14 +7771,7 @@ 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== - dependencies: - brace-expansion "^1.1.7" - -minimatch@^3.1.5: +minimatch@^3.1.5, minimatch@^3.1.2: version "3.1.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== From 3972816fdad32869c166f0e931340dc5be7160e4 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Mon, 16 Mar 2026 13:41:26 +0200 Subject: [PATCH 13/24] UI: Fixed duplicate import --- ui-ngx/yarn.lock | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 8359228121..190f486536 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -262,7 +262,7 @@ rxjs "7.8.2" source-map "0.7.6" -"@angular-devkit/schematics@20.3.20": +"@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== @@ -273,17 +273,6 @@ ora "8.2.0" rxjs "7.8.2" -"@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== - dependencies: - "@angular-devkit/core" "20.3.18" - jsonc-parser "3.3.1" - magic-string "0.30.17" - ora "8.2.0" - rxjs "7.8.2" - "@angular-eslint/builder@20.7.0": version "20.7.0" resolved "https://registry.yarnpkg.com/@angular-eslint/builder/-/builder-20.7.0.tgz#57df5635e287f3a583d495cabad0b1661d85aa87" @@ -7771,7 +7760,7 @@ minimatch@^10.0.3, minimatch@^10.1.1: dependencies: brace-expansion "^5.0.2" -minimatch@^3.1.5, minimatch@^3.1.2: +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== From ae8246fc605bd3dc92467db210684fdc83b43415 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 16 Mar 2026 15:43:11 +0200 Subject: [PATCH 14/24] Fix SSRF DNS rebinding bypass, add allow-list, protect additional HTTP vectors Add SsrfSafeAddressResolverGroup that validates resolved IPs at Netty connection time, eliminating the TOCTOU gap where DNS rebinding domains resolve to safe IPs during validation but to private/metadata IPs at connection time. Disable HTTP redirects in TbHttpClient to prevent redirect-based SSRF bypass. Add allow-list support (SSRF_ALLOWED_HOSTS) to SsrfProtectionValidator so customers with IoT devices on private networks can whitelist specific addresses or CIDR ranges while keeping SSRF protection enabled. Add SSRF validation to MS Teams webhook, custom OAuth2 mapper, and GitHub OAuth2 mapper endpoints. Log a warning when SSRF protection is disabled. --- .../server/actors/ActorSystemContext.java | 10 ++ .../MicrosoftTeamsNotificationChannel.java | 11 +- .../auth/oauth2/CustomOAuth2ClientMapper.java | 9 ++ .../auth/oauth2/GithubOAuth2ClientMapper.java | 8 + .../src/main/resources/thingsboard.yml | 4 + .../common/util/SsrfProtectionValidator.java | 54 ++++++- .../util/SsrfProtectionValidatorTest.java | 90 +++++++++++ .../rest/SsrfSafeAddressResolverGroup.java | 135 ++++++++++++++++ .../rule/engine/rest/TbHttpClient.java | 2 + .../SsrfSafeAddressResolverGroupTest.java | 150 ++++++++++++++++++ 10 files changed, 470 insertions(+), 3 deletions(-) create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroup.java create mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroupTest.java 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 892eb2beda..a9692f4b9d 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -615,11 +615,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/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..5cd6ca09b2 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,11 +23,14 @@ 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 java.net.URI; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.model.SecurityUser; @@ -63,6 +66,12 @@ public class CustomOAuth2ClientMapper extends AbstractOAuth2ClientMapper impleme log.error("Can't convert principal to JSON string", e); throw new RuntimeException("Can't convert principal to JSON string", e); } + try { + SsrfProtectionValidator.validateUri(new URI(custom.getUrl())); + } catch (Exception e) { + log.error("SSRF validation failed for custom mapper URL '{}'", custom.getUrl(), e); + throw new RuntimeException("Unable to login. Please contact your Administrator!"); + } try { return restTemplate.postForEntity(custom.getUrl(), request, OAuth2User.class).getBody(); } catch (Exception e) { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/GithubOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/GithubOAuth2ClientMapper.java index c7aa893d59..2861036097 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/GithubOAuth2ClientMapper.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/GithubOAuth2ClientMapper.java @@ -24,6 +24,7 @@ import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig; import org.thingsboard.server.common.data.oauth2.OAuth2Client; import org.thingsboard.server.dao.oauth2.OAuth2Configuration; @@ -31,6 +32,7 @@ import org.thingsboard.server.dao.oauth2.OAuth2User; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.model.SecurityUser; +import java.net.URI; import java.util.ArrayList; import java.util.Map; import java.util.Optional; @@ -62,6 +64,12 @@ public class GithubOAuth2ClientMapper extends AbstractOAuth2ClientMapper impleme restTemplateBuilder = restTemplateBuilder.defaultHeader(AUTHORIZATION, "token " + oauth2Token); RestTemplate restTemplate = restTemplateBuilder.build(); + try { + SsrfProtectionValidator.validateUri(new URI(emailUrl)); + } catch (Exception e) { + log.error("SSRF validation failed for GitHub email URL '{}'", emailUrl, e); + throw new RuntimeException("Unable to login. Please contact your Administrator!"); + } GithubEmailsResponse githubEmailsResponse; try { githubEmailsResponse = restTemplate.getForEntity(emailUrl, GithubEmailsResponse.class).getBody(); diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 60a158febe..08913b2c9f 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -509,6 +509,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}" 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..eb89b164a2 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,6 +158,10 @@ 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; } @@ -179,10 +199,42 @@ public class SsrfProtectionValidator { return !entry.isEmpty() && (Character.isDigit(entry.charAt(0)) || entry.contains(":")); } + public static void setAllowedHosts(List entries) { + if (entries == null || entries.isEmpty()) { + allowedHosts = AllowedHosts.EMPTY; + return; + } + List cidrRanges = new ArrayList<>(); + Set hostnames = new HashSet<>(); + for (String entry : entries) { + String trimmed = entry.trim(); + if (trimmed.isEmpty()) { + continue; + } + if (trimmed.contains("/") || isIpLiteral(trimmed)) { + try { + cidrRanges.add(CidrRange.parse(trimmed)); + } catch (Exception e) { + log.warn("Failed to parse allowed CIDR/IP entry '{}': {}", trimmed, e.getMessage()); + } + } else { + hostnames.add(trimmed.toLowerCase()); + } + } + allowedHosts = new AllowedHosts( + Collections.unmodifiableList(cidrRanges), + Collections.unmodifiableSet(hostnames)); + log.info("SSRF allowed hosts configured: {} CIDR range(s), {} hostname(s)", cidrRanges.size(), hostnames.size()); + } + 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..52ab865f6c 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,92 @@ 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); + } + } 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..f3cd0c83bb --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroup.java @@ -0,0 +1,135 @@ +/** + * 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.List; +import java.util.stream.Collectors; + +/** + * 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. + */ +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) -> { + if (!future.isSuccess()) { + promise.tryFailure(future.cause()); + return; + } + InetSocketAddress resolved = future.getNow(); + if (SsrfProtectionValidator.isEnabled() && isBlocked(resolved)) { + promise.tryFailure(new RuntimeException( + "SSRF protection: resolved address " + resolved.getAddress().getHostAddress() + " is blocked")); + } else { + promise.trySuccess(resolved); + } + }); + 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) -> { + if (!future.isSuccess()) { + promise.tryFailure(future.cause()); + return; + } + List resolved = future.getNow(); + if (!SsrfProtectionValidator.isEnabled()) { + promise.trySuccess(resolved); + return; + } + List safe = resolved.stream() + .filter(addr -> !isBlocked(addr)) + .collect(Collectors.toList()); + if (safe.isEmpty()) { + String host = address instanceof InetSocketAddress isa ? isa.getHostString() : address.toString(); + promise.tryFailure(new RuntimeException( + "SSRF protection: all resolved addresses for " + host + " are blocked")); + } else { + promise.trySuccess(safe); + } + }); + return promise; + } + + @Override + public void close() { + delegate.close(); + } + + private static boolean isBlocked(InetSocketAddress socketAddress) { + InetAddress addr = socketAddress.getAddress(); + return addr != null && SsrfProtectionValidator.isBlockedAddress(addr); + } + } +} 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..c450622746 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,8 @@ public class TbHttpClient { .build(); HttpClient httpClient = HttpClient.create(connectionProvider) + .resolver(SsrfSafeAddressResolverGroup.INSTANCE) + .followRedirect(false) .runOn(getSharedOrCreateEventLoopGroup(eventLoopGroupShared)) .doOnConnected(c -> c.addHandlerLast(new ReadTimeoutHandler(config.getReadTimeoutMs(), TimeUnit.MILLISECONDS))); 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..8d19239ea7 --- /dev/null +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroupTest.java @@ -0,0 +1,150 @@ +/** + * 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.Future; +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("example.com", 80), promise)); + InetSocketAddress result = promise.get(10, TimeUnit.SECONDS); + + assertThat(result.getAddress()).isNotNull(); + assertThat(result.getAddress().isLoopbackAddress()).isFalse(); + assertThat(result.getAddress().isSiteLocalAddress()).isFalse(); + } + + @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("SSRF protection"); + } + + @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("SSRF protection"); + } + + @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 resolveAllReturnsAllWhenSsrfDisabled() throws Exception { + SsrfProtectionValidator.setEnabled(false); + + 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)); + List results = promise.get(10, TimeUnit.SECONDS); + + assertThat(results).isNotEmpty(); + } +} From 07af263997b9643ba60a041b880976a8f651b5b0 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 16 Mar 2026 16:21:17 +0200 Subject: [PATCH 15/24] Add configurable security headers and env-var-backed CORS configuration Fix security issues from penetration test report: - M2: Add configurable X-Frame-Options and CSP headers (disabled by default) - L2: Add X-Content-Type-Options and Referrer-Policy headers (enabled by default) - L3: Make CORS allowed-origin-patterns configurable via TB_CORS_* env vars Root cause: ThingsboardSecurityConfiguration called .disable() on the entire HeadersConfigurer, which removed ALL security headers including Cache-Control. Fix uses defaultsDisabled() + selective header enablement via a new HttpSecurityHeadersCustomizer component. Both Spring Boot (tb-node) and Express.js (web-ui) share the same SECURITY_HEADERS_* environment variables for consistent configuration across monolith and microservice deployments. --- .../config/HttpSecurityHeadersCustomizer.java | 59 +++++++++++++++++ .../config/HttpSecurityHeadersProperties.java | 56 ++++++++++++++++ .../TbRuleEngineSecurityConfiguration.java | 12 +++- .../ThingsboardSecurityConfiguration.java | 19 ++++-- .../src/main/resources/thingsboard.yml | 65 +++++++++++++++++-- .../config/custom-environment-variables.yml | 14 ++++ msa/web-ui/config/default.yml | 14 ++++ msa/web-ui/server.ts | 33 ++++++++++ 8 files changed, 257 insertions(+), 15 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java create mode 100644 application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java diff --git a/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java new file mode 100644 index 0000000000..922bf39afa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java @@ -0,0 +1,59 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.web.header.writers.StaticHeadersWriter; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +@RequiredArgsConstructor +public class HttpSecurityHeadersCustomizer { + + private final HttpSecurityHeadersProperties properties; + + public void customize(HeadersConfigurer headers) { + if (properties.getXContentTypeOptions().isEnabled()) { + headers.contentTypeOptions(config -> {}); + } + + if (properties.getReferrerPolicy().isEnabled()) { + headers.addHeaderWriter(new StaticHeadersWriter("Referrer-Policy", properties.getReferrerPolicy().getValue())); + } + + if (properties.getXFrameOptions().isEnabled()) { + String value = properties.getXFrameOptions().getValue(); + if ("DENY".equalsIgnoreCase(value)) { + headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny); + } else { + headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin); + } + } + + if (properties.getContentSecurityPolicy().isEnabled() && StringUtils.hasText(properties.getContentSecurityPolicy().getValue())) { + headers.contentSecurityPolicy(csp -> { + csp.policyDirectives(properties.getContentSecurityPolicy().getValue()); + if (properties.getContentSecurityPolicy().isReportOnly()) { + csp.reportOnly(); + } + }); + } + + } + +} diff --git a/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java new file mode 100644 index 0000000000..80a070f35c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java @@ -0,0 +1,56 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "security.headers") +@Data +public class HttpSecurityHeadersProperties { + + private XContentTypeOptions xContentTypeOptions = new XContentTypeOptions(); + private ReferrerPolicy referrerPolicy = new ReferrerPolicy(); + private XFrameOptions xFrameOptions = new XFrameOptions(); + private ContentSecurityPolicy contentSecurityPolicy = new ContentSecurityPolicy(); + + @Data + public static class XContentTypeOptions { + private boolean enabled = true; + } + + @Data + public static class ReferrerPolicy { + private boolean enabled = true; + private String value = "strict-origin-when-cross-origin"; + } + + @Data + public static class XFrameOptions { + private boolean enabled = false; + private String value = "SAMEORIGIN"; + } + + @Data + public static class ContentSecurityPolicy { + private boolean enabled = false; + private String value = ""; + private boolean reportOnly = false; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/config/TbRuleEngineSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/TbRuleEngineSecurityConfiguration.java index 1f94afb398..f3efaf8d4e 100644 --- a/application/src/main/java/org/thingsboard/server/config/TbRuleEngineSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/TbRuleEngineSecurityConfiguration.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.config; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.context.annotation.Bean; @@ -33,11 +34,16 @@ import org.springframework.security.web.SecurityFilterChain; @ConditionalOnExpression("'${service.type:null}'=='tb-rule-engine'") public class TbRuleEngineSecurityConfiguration { + @Autowired + private HttpSecurityHeadersCustomizer httpSecurityHeadersCustomizer; + @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.headers(headers -> headers - .cacheControl(config -> {}) - .frameOptions(config -> {}).disable()) + http.headers(headers -> { + headers.defaultsDisabled(); + headers.cacheControl(config -> {}); + httpSecurityHeadersCustomizer.customize(headers); + }) .cors(cors -> {}) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(config -> config diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java index cac4f4165e..712224b5d2 100644 --- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java @@ -131,6 +131,9 @@ public class ThingsboardSecurityConfiguration { @Autowired private AuthExceptionHandler authExceptionHandler; + @Autowired + private HttpSecurityHeadersCustomizer httpSecurityHeadersCustomizer; + @Bean protected PayloadSizeFilter payloadSizeFilter() { return new PayloadSizeFilter(maxPayloadSizeConfig); @@ -198,9 +201,11 @@ public class ThingsboardSecurityConfiguration { http .securityMatchers(matchers -> matchers .requestMatchers("/*.js", "/*.css", "/*.ico", "/assets/**", "/static/**")) - .headers(header -> header - .defaultsDisabled() - .addHeaderWriter(new StaticHeadersWriter(HttpHeaders.CACHE_CONTROL, "max-age=0, public"))) + .headers(headers -> { + headers.defaultsDisabled(); + headers.addHeaderWriter(new StaticHeadersWriter(HttpHeaders.CACHE_CONTROL, "max-age=0, public")); + httpSecurityHeadersCustomizer.customize(headers); + }) .authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll()) .requestCache(RequestCacheConfigurer::disable) .securityContext(AbstractHttpConfigurer::disable) @@ -210,9 +215,11 @@ public class ThingsboardSecurityConfiguration { @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.headers(headers -> headers - .cacheControl(config -> {}) - .frameOptions(config -> {}).disable()) + http.headers(headers -> { + headers.defaultsDisabled(); + headers.cacheControl(config -> {}); + httpSecurityHeadersCustomizer.customize(headers); + }) .cors(cors -> {}) .csrf(AbstractHttpConfigurer::disable) .exceptionHandling(config -> {}) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 60a158febe..7305363452 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -173,6 +173,52 @@ security: path: "${SECURITY_JAVA_CACERTS_PATH:${java.home}/lib/security/cacerts}" # The password of the cacerts keystore file password: "${SECURITY_JAVA_CACERTS_PASSWORD:changeit}" + # HTTP security response headers configuration. + # These headers are set on responses from the ThingsBoard backend (tb-node). + # In microservice deployments, the web-ui (Express.js) has its own header configuration + # under msa/web-ui/config/ using the same environment variable names. + headers: + # X-Content-Type-Options header prevents browsers from MIME-sniffing the Content-Type. + # Safe to enable. Only disable if you intentionally serve resources with mismatched Content-Type. + x-content-type-options: + enabled: "${SECURITY_HEADERS_X_CONTENT_TYPE_OPTIONS_ENABLED:true}" + # Referrer-Policy header controls how much referrer info the browser sends with requests. + # The default 'strict-origin-when-cross-origin' matches the browser's built-in default, + # so enabling this does not change existing behavior — it just makes the policy explicit. + # Valid values: no-referrer, no-referrer-when-downgrade, origin, origin-when-cross-origin, + # same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url + referrer-policy: + enabled: "${SECURITY_HEADERS_REFERRER_POLICY_ENABLED:true}" + value: "${SECURITY_HEADERS_REFERRER_POLICY_VALUE:strict-origin-when-cross-origin}" + # X-Frame-Options header protects against clickjacking attacks by preventing the page + # from being loaded in iframes on other domains. + # Disabled by default because ThingsBoard supports multi-domain deployments where + # the platform may be embedded in iframes on customer domains. + # WARNING: Enabling with DENY will block ALL iframe embedding including dashboards + # embedded on external sites. Use SAMEORIGIN to allow same-domain iframes only. + x-frame-options: + enabled: "${SECURITY_HEADERS_X_FRAME_OPTIONS_ENABLED:false}" + # Valid values: DENY, SAMEORIGIN + value: "${SECURITY_HEADERS_X_FRAME_OPTIONS_VALUE:SAMEORIGIN}" + # Content-Security-Policy header mitigates XSS and data injection attacks by restricting + # which resources the browser is allowed to load. + # Disabled by default because ThingsBoard supports multi-domain deployments and + # because custom HTML Card widgets may use inline scripts, inline styles, and + # external resources that a restrictive CSP would block. + # WARNING when enabling: A strict CSP (e.g. script-src 'self') will break: + # - HTML Card widgets with inline JavaScript + # - Custom widget types with inline scripts/styles + # - Widgets loading external resources (images, fonts, scripts) + # - Dashboard embedding via iframes (if frame-ancestors is restrictive) + # Use 'report-only: true' first to test the impact before enforcing. + # Example value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'" + content-security-policy: + enabled: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_ENABLED:false}" + # Full CSP directive string + value: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_VALUE:}" + # If true, uses Content-Security-Policy-Report-Only header instead — the browser + # reports violations but does not enforce them. Use for testing before enforcing. + report-only: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_REPORT_ONLY:false}" # Mail settings parameters mail: @@ -788,21 +834,28 @@ updates: # Enable/disable checks for the new version enabled: "${UPDATES_ENABLED:true}" -# Spring CORS configuration parameters +# Spring CORS configuration parameters. +# Controls the Access-Control-Allow-Origin and Access-Control-Allow-Credentials response headers. +# WARNING: The default configuration allows cross-origin requests from ANY domain with credentials. +# This means any website can make API requests on behalf of an authenticated user if the token +# is accessible (e.g., via XSS). For production deployments, restrict to your domain(s): +# TB_CORS_ALLOWED_ORIGIN_PATTERNS=https://your-domain.com +# For multi-domain deployments, list all allowed domains comma-separated: +# TB_CORS_ALLOWED_ORIGIN_PATTERNS=https://domain1.com,https://domain2.com spring.mvc.cors: mappings: # Intercept path "[/api/**]": #Comma-separated list of origins to allow. '*' allows all origins. When not set, CORS support is disabled. - allowed-origin-patterns: "*" + allowed-origin-patterns: "${TB_CORS_ALLOWED_ORIGIN_PATTERNS:*}" #Comma-separated list of methods to allow. '*' allows all methods. - allowed-methods: "*" + allowed-methods: "${TB_CORS_ALLOWED_METHODS:*}" #Comma-separated list of headers to allow in a request. '*' allows all headers. - allowed-headers: "*" + allowed-headers: "${TB_CORS_ALLOWED_HEADERS:*}" #How long, in seconds, the response from a pre-flight request can be cached by clients. - max-age: "1800" + max-age: "${TB_CORS_MAX_AGE:1800}" #Set whether credentials are supported. When not set, credentials are not supported. - allow-credentials: "true" + allow-credentials: "${TB_CORS_ALLOW_CREDENTIALS:true}" # General spring parameters spring.main.allow-circular-references: "true" # Spring Boot configuration property that controls whether circular dependencies between beans are allowed. diff --git a/msa/web-ui/config/custom-environment-variables.yml b/msa/web-ui/config/custom-environment-variables.yml index 90bce53cb4..4185e8f5ed 100644 --- a/msa/web-ui/config/custom-environment-variables.yml +++ b/msa/web-ui/config/custom-environment-variables.yml @@ -25,6 +25,20 @@ thingsboard: host: "TB_HOST" # ThingsBoard node port port: "TB_PORT" +security: + headers: + x-content-type-options: + enabled: "SECURITY_HEADERS_X_CONTENT_TYPE_OPTIONS_ENABLED" + referrer-policy: + enabled: "SECURITY_HEADERS_REFERRER_POLICY_ENABLED" + value: "SECURITY_HEADERS_REFERRER_POLICY_VALUE" + x-frame-options: + enabled: "SECURITY_HEADERS_X_FRAME_OPTIONS_ENABLED" + value: "SECURITY_HEADERS_X_FRAME_OPTIONS_VALUE" + content-security-policy: + enabled: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_ENABLED" + value: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_VALUE" + reportOnly: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_REPORT_ONLY" logger: level: "LOGGER_LEVEL" path: "LOG_FOLDER" diff --git a/msa/web-ui/config/default.yml b/msa/web-ui/config/default.yml index b26424a8da..23b0162130 100644 --- a/msa/web-ui/config/default.yml +++ b/msa/web-ui/config/default.yml @@ -25,6 +25,20 @@ thingsboard: host: "localhost" # ThingsBoard node port port: "8080" +security: + headers: + x-content-type-options: + enabled: true + referrer-policy: + enabled: true + value: "strict-origin-when-cross-origin" + x-frame-options: + enabled: false + value: "SAMEORIGIN" + content-security-policy: + enabled: false + value: "" + reportOnly: false logger: level: "info" path: "logs" diff --git a/msa/web-ui/server.ts b/msa/web-ui/server.ts index 68baf5079f..7fa35ea9c5 100644 --- a/msa/web-ui/server.ts +++ b/msa/web-ui/server.ts @@ -60,6 +60,39 @@ let connections: Socket[] = []; const app = express(); server = http.createServer(app); + // Build security headers map once at startup + const securityHeaders: Record = {}; + if (config.has('security.headers')) { + const hc: any = config.get('security.headers'); + if (hc['x-content-type-options']?.enabled !== false) { + securityHeaders['X-Content-Type-Options'] = 'nosniff'; + } + if (hc['referrer-policy']?.enabled !== false) { + securityHeaders['Referrer-Policy'] = hc['referrer-policy']?.value || 'strict-origin-when-cross-origin'; + } + if (hc['x-frame-options']?.enabled) { + securityHeaders['X-Frame-Options'] = hc['x-frame-options']?.value || 'SAMEORIGIN'; + } + if (hc['content-security-policy']?.enabled && hc['content-security-policy']?.value) { + const name = hc['content-security-policy']?.reportOnly + ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'; + securityHeaders[name] = hc['content-security-policy'].value; + } + } else { + // Defaults when no security.headers config block exists + securityHeaders['X-Content-Type-Options'] = 'nosniff'; + securityHeaders['Referrer-Policy'] = 'strict-origin-when-cross-origin'; + } + logger.info('Security headers: %s', JSON.stringify(securityHeaders)); + + // Apply security headers to all responses + app.use((_req, res, next) => { + for (const [name, value] of Object.entries(securityHeaders)) { + res.setHeader(name, value); + } + next(); + }); + let apiProxy: httpProxy; if (useApiProxy) { apiProxy = httpProxy.createProxyServer({ From 2f39347dd22c0c80b94066998459453898b8f802 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 16 Mar 2026 16:32:54 +0200 Subject: [PATCH 16/24] Address PR review comments - Extract shared parseHostEntries() to deduplicate setAllowedHosts/setAdditionalBlockedHosts - Add isHostnameAllowed() and propagate hostname allow-list check in resolver - Move OAuth2 custom mapper URL SSRF validation to save-time (Oauth2ClientDataValidator) - Remove runtime SSRF checks from CustomOAuth2ClientMapper and GithubOAuth2ClientMapper (custom URL now validated at save; GitHub emailUrl is server config, not user input) - Replace example.com with 8.8.8.8 in resolver test to avoid DNS dependency --- .../auth/oauth2/CustomOAuth2ClientMapper.java | 9 ---- .../auth/oauth2/GithubOAuth2ClientMapper.java | 8 --- .../common/util/SsrfProtectionValidator.java | 54 +++++++++---------- .../validator/Oauth2ClientDataValidator.java | 8 +++ .../rest/SsrfSafeAddressResolverGroup.java | 12 ++++- .../SsrfSafeAddressResolverGroupTest.java | 5 +- 6 files changed, 44 insertions(+), 52 deletions(-) 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 5cd6ca09b2..8477c69a99 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,14 +23,11 @@ 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 java.net.URI; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.model.SecurityUser; @@ -66,12 +63,6 @@ public class CustomOAuth2ClientMapper extends AbstractOAuth2ClientMapper impleme log.error("Can't convert principal to JSON string", e); throw new RuntimeException("Can't convert principal to JSON string", e); } - try { - SsrfProtectionValidator.validateUri(new URI(custom.getUrl())); - } catch (Exception e) { - log.error("SSRF validation failed for custom mapper URL '{}'", custom.getUrl(), e); - throw new RuntimeException("Unable to login. Please contact your Administrator!"); - } try { return restTemplate.postForEntity(custom.getUrl(), request, OAuth2User.class).getBody(); } catch (Exception e) { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/GithubOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/GithubOAuth2ClientMapper.java index 2861036097..c7aa893d59 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/GithubOAuth2ClientMapper.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/GithubOAuth2ClientMapper.java @@ -24,7 +24,6 @@ import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; -import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig; import org.thingsboard.server.common.data.oauth2.OAuth2Client; import org.thingsboard.server.dao.oauth2.OAuth2Configuration; @@ -32,7 +31,6 @@ import org.thingsboard.server.dao.oauth2.OAuth2User; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.model.SecurityUser; -import java.net.URI; import java.util.ArrayList; import java.util.Map; import java.util.Optional; @@ -64,12 +62,6 @@ public class GithubOAuth2ClientMapper extends AbstractOAuth2ClientMapper impleme restTemplateBuilder = restTemplateBuilder.defaultHeader(AUTHORIZATION, "token " + oauth2Token); RestTemplate restTemplate = restTemplateBuilder.build(); - try { - SsrfProtectionValidator.validateUri(new URI(emailUrl)); - } catch (Exception e) { - log.error("SSRF validation failed for GitHub email URL '{}'", emailUrl, e); - throw new RuntimeException("Unable to login. Please contact your Administrator!"); - } GithubEmailsResponse githubEmailsResponse; try { githubEmailsResponse = restTemplate.getForEntity(emailUrl, GithubEmailsResponse.class).getBody(); 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 eb89b164a2..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 @@ -167,9 +167,28 @@ public class SsrfProtectionValidator { } 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<>(); @@ -188,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) { @@ -199,32 +217,8 @@ public class SsrfProtectionValidator { return !entry.isEmpty() && (Character.isDigit(entry.charAt(0)) || entry.contains(":")); } - public static void setAllowedHosts(List entries) { - if (entries == null || entries.isEmpty()) { - allowedHosts = AllowedHosts.EMPTY; - return; - } - List cidrRanges = new ArrayList<>(); - Set hostnames = new HashSet<>(); - for (String entry : entries) { - String trimmed = entry.trim(); - if (trimmed.isEmpty()) { - continue; - } - if (trimmed.contains("/") || isIpLiteral(trimmed)) { - try { - cidrRanges.add(CidrRange.parse(trimmed)); - } catch (Exception e) { - log.warn("Failed to parse allowed CIDR/IP entry '{}': {}", trimmed, e.getMessage()); - } - } else { - hostnames.add(trimmed.toLowerCase()); - } - } - allowedHosts = new AllowedHosts( - Collections.unmodifiableList(cidrRanges), - Collections.unmodifiableSet(hostnames)); - log.info("SSRF allowed hosts configured: {} CIDR range(s), {} hostname(s)", cidrRanges.size(), hostnames.size()); + private record ParsedHostEntries(List cidrRanges, Set hostnames) { + static final ParsedHostEntries EMPTY = new ParsedHostEntries(Collections.emptyList(), Collections.emptySet()); } record AdditionalBlockedHosts(List cidrRanges, Set hostnames) { 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 07fbc06114..d5b965face 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.dao.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/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 index f3cd0c83bb..e08dc0ac06 100644 --- 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 @@ -81,7 +81,7 @@ public final class SsrfSafeAddressResolverGroup extends AddressResolverGroup resolved = future.getNow(); - if (!SsrfProtectionValidator.isEnabled()) { + if (!SsrfProtectionValidator.isEnabled() || isOriginalHostAllowed(address)) { promise.trySuccess(resolved); return; } @@ -131,5 +131,13 @@ public final class SsrfSafeAddressResolverGroup extends AddressResolverGroup resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); Promise promise = executor.newPromise(); - executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("example.com", 80), promise)); + 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().isLoopbackAddress()).isFalse(); - assertThat(result.getAddress().isSiteLocalAddress()).isFalse(); + assertThat(result.getAddress().getHostAddress()).isEqualTo("8.8.8.8"); } @Test From 3a765209ff4c621a239d825e43bfcc7e78ff7c69 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 16 Mar 2026 16:48:38 +0200 Subject: [PATCH 17/24] Address PR review comments - Use kebab-case 'report-only' in web-ui configs to match thingsboard.yml - Add log.warn for unrecognized X-Frame-Options values in customizer - Replace @Configuration with @Component on HttpSecurityHeadersProperties - Add comment explaining '!== false' vs truthiness pattern in server.ts --- .../server/config/HttpSecurityHeadersCustomizer.java | 5 +++++ .../server/config/HttpSecurityHeadersProperties.java | 4 ++-- msa/web-ui/config/custom-environment-variables.yml | 2 +- msa/web-ui/config/default.yml | 2 +- msa/web-ui/server.ts | 9 ++++++--- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java index 922bf39afa..318c435353 100644 --- a/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java +++ b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersCustomizer.java @@ -16,11 +16,13 @@ package org.thingsboard.server.config; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.web.header.writers.StaticHeadersWriter; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; +@Slf4j @Component @RequiredArgsConstructor public class HttpSecurityHeadersCustomizer { @@ -41,6 +43,9 @@ public class HttpSecurityHeadersCustomizer { if ("DENY".equalsIgnoreCase(value)) { headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny); } else { + if (!"SAMEORIGIN".equalsIgnoreCase(value)) { + log.warn("Unrecognized X-Frame-Options value '{}', falling back to SAMEORIGIN. Valid values: DENY, SAMEORIGIN", value); + } headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin); } } diff --git a/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java index 80a070f35c..224e2aeea0 100644 --- a/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java +++ b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java @@ -17,9 +17,9 @@ package org.thingsboard.server.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Component; -@Configuration +@Component @ConfigurationProperties(prefix = "security.headers") @Data public class HttpSecurityHeadersProperties { diff --git a/msa/web-ui/config/custom-environment-variables.yml b/msa/web-ui/config/custom-environment-variables.yml index 4185e8f5ed..6ba9147555 100644 --- a/msa/web-ui/config/custom-environment-variables.yml +++ b/msa/web-ui/config/custom-environment-variables.yml @@ -38,7 +38,7 @@ security: content-security-policy: enabled: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_ENABLED" value: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_VALUE" - reportOnly: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_REPORT_ONLY" + report-only: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_REPORT_ONLY" logger: level: "LOGGER_LEVEL" path: "LOG_FOLDER" diff --git a/msa/web-ui/config/default.yml b/msa/web-ui/config/default.yml index 23b0162130..6ffa85b3bc 100644 --- a/msa/web-ui/config/default.yml +++ b/msa/web-ui/config/default.yml @@ -38,7 +38,7 @@ security: content-security-policy: enabled: false value: "" - reportOnly: false + report-only: false logger: level: "info" path: "logs" diff --git a/msa/web-ui/server.ts b/msa/web-ui/server.ts index 7fa35ea9c5..c6b4870b28 100644 --- a/msa/web-ui/server.ts +++ b/msa/web-ui/server.ts @@ -60,7 +60,9 @@ let connections: Socket[] = []; const app = express(); server = http.createServer(app); - // Build security headers map once at startup + // Build security headers map once at startup. + // Headers enabled by default use '!== false' so they stay on unless explicitly disabled. + // Headers disabled by default use simple truthiness checks. const securityHeaders: Record = {}; if (config.has('security.headers')) { const hc: any = config.get('security.headers'); @@ -74,9 +76,10 @@ let connections: Socket[] = []; securityHeaders['X-Frame-Options'] = hc['x-frame-options']?.value || 'SAMEORIGIN'; } if (hc['content-security-policy']?.enabled && hc['content-security-policy']?.value) { - const name = hc['content-security-policy']?.reportOnly + const csp = hc['content-security-policy']; + const name = csp['report-only'] ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'; - securityHeaders[name] = hc['content-security-policy'].value; + securityHeaders[name] = csp.value; } } else { // Defaults when no security.headers config block exists From c1dd327b47a614218e92766d70458ca83ac93b75 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 16 Mar 2026 17:59:50 +0200 Subject: [PATCH 18/24] Add description comments for security headers properties in thingsboard.yml --- application/src/main/resources/thingsboard.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 7305363452..b40325c05e 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -181,6 +181,7 @@ security: # X-Content-Type-Options header prevents browsers from MIME-sniffing the Content-Type. # Safe to enable. Only disable if you intentionally serve resources with mismatched Content-Type. x-content-type-options: + # Enable/disable X-Content-Type-Options header. Prevents browsers from MIME-sniffing the Content-Type enabled: "${SECURITY_HEADERS_X_CONTENT_TYPE_OPTIONS_ENABLED:true}" # Referrer-Policy header controls how much referrer info the browser sends with requests. # The default 'strict-origin-when-cross-origin' matches the browser's built-in default, @@ -188,7 +189,9 @@ security: # Valid values: no-referrer, no-referrer-when-downgrade, origin, origin-when-cross-origin, # same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url referrer-policy: + # Enable/disable Referrer-Policy header enabled: "${SECURITY_HEADERS_REFERRER_POLICY_ENABLED:true}" + # Referrer-Policy header value value: "${SECURITY_HEADERS_REFERRER_POLICY_VALUE:strict-origin-when-cross-origin}" # X-Frame-Options header protects against clickjacking attacks by preventing the page # from being loaded in iframes on other domains. @@ -197,6 +200,7 @@ security: # WARNING: Enabling with DENY will block ALL iframe embedding including dashboards # embedded on external sites. Use SAMEORIGIN to allow same-domain iframes only. x-frame-options: + # Enable/disable X-Frame-Options header. Protects against clickjacking attacks enabled: "${SECURITY_HEADERS_X_FRAME_OPTIONS_ENABLED:false}" # Valid values: DENY, SAMEORIGIN value: "${SECURITY_HEADERS_X_FRAME_OPTIONS_VALUE:SAMEORIGIN}" @@ -213,6 +217,7 @@ security: # Use 'report-only: true' first to test the impact before enforcing. # Example value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'" content-security-policy: + # Enable/disable Content-Security-Policy header. Mitigates XSS and data injection attacks enabled: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_ENABLED:false}" # Full CSP directive string value: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_VALUE:}" From 493140f1b2a6b7a839da6ffd1a92794e0d509c12 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 16 Mar 2026 18:17:01 +0200 Subject: [PATCH 19/24] Remove dead else branch in web-ui security headers config --- msa/web-ui/server.ts | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/msa/web-ui/server.ts b/msa/web-ui/server.ts index c6b4870b28..48f17b2181 100644 --- a/msa/web-ui/server.ts +++ b/msa/web-ui/server.ts @@ -64,27 +64,21 @@ let connections: Socket[] = []; // Headers enabled by default use '!== false' so they stay on unless explicitly disabled. // Headers disabled by default use simple truthiness checks. const securityHeaders: Record = {}; - if (config.has('security.headers')) { - const hc: any = config.get('security.headers'); - if (hc['x-content-type-options']?.enabled !== false) { - securityHeaders['X-Content-Type-Options'] = 'nosniff'; - } - if (hc['referrer-policy']?.enabled !== false) { - securityHeaders['Referrer-Policy'] = hc['referrer-policy']?.value || 'strict-origin-when-cross-origin'; - } - if (hc['x-frame-options']?.enabled) { - securityHeaders['X-Frame-Options'] = hc['x-frame-options']?.value || 'SAMEORIGIN'; - } - if (hc['content-security-policy']?.enabled && hc['content-security-policy']?.value) { - const csp = hc['content-security-policy']; - const name = csp['report-only'] - ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'; - securityHeaders[name] = csp.value; - } - } else { - // Defaults when no security.headers config block exists + const hc: any = config.get('security.headers'); + if (hc['x-content-type-options']?.enabled !== false) { securityHeaders['X-Content-Type-Options'] = 'nosniff'; - securityHeaders['Referrer-Policy'] = 'strict-origin-when-cross-origin'; + } + if (hc['referrer-policy']?.enabled !== false) { + securityHeaders['Referrer-Policy'] = hc['referrer-policy']?.value || 'strict-origin-when-cross-origin'; + } + if (hc['x-frame-options']?.enabled) { + securityHeaders['X-Frame-Options'] = hc['x-frame-options']?.value || 'SAMEORIGIN'; + } + if (hc['content-security-policy']?.enabled && hc['content-security-policy']?.value) { + const csp = hc['content-security-policy']; + const name = csp['report-only'] + ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'; + securityHeaders[name] = csp.value; } logger.info('Security headers: %s', JSON.stringify(securityHeaders)); From 014c612bf1933b3e170f76e4ad8c70f07808f8c1 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 16 Mar 2026 18:19:36 +0200 Subject: [PATCH 20/24] Fix boolean/string comparison for env var overrides in web-ui security headers --- msa/web-ui/server.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/msa/web-ui/server.ts b/msa/web-ui/server.ts index 48f17b2181..0a4ddbfa6b 100644 --- a/msa/web-ui/server.ts +++ b/msa/web-ui/server.ts @@ -61,22 +61,22 @@ let connections: Socket[] = []; server = http.createServer(app); // Build security headers map once at startup. - // Headers enabled by default use '!== false' so they stay on unless explicitly disabled. - // Headers disabled by default use simple truthiness checks. + // node-config passes env var overrides as strings, so enabled can be boolean or string. + const isEnabled = (val: any) => val === true || val === 'true'; const securityHeaders: Record = {}; const hc: any = config.get('security.headers'); - if (hc['x-content-type-options']?.enabled !== false) { + if (isEnabled(hc['x-content-type-options']?.enabled)) { securityHeaders['X-Content-Type-Options'] = 'nosniff'; } - if (hc['referrer-policy']?.enabled !== false) { + if (isEnabled(hc['referrer-policy']?.enabled)) { securityHeaders['Referrer-Policy'] = hc['referrer-policy']?.value || 'strict-origin-when-cross-origin'; } - if (hc['x-frame-options']?.enabled) { + if (isEnabled(hc['x-frame-options']?.enabled)) { securityHeaders['X-Frame-Options'] = hc['x-frame-options']?.value || 'SAMEORIGIN'; } - if (hc['content-security-policy']?.enabled && hc['content-security-policy']?.value) { + if (isEnabled(hc['content-security-policy']?.enabled) && hc['content-security-policy']?.value) { const csp = hc['content-security-policy']; - const name = csp['report-only'] + const name = isEnabled(csp['report-only']) ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'; securityHeaders[name] = csp.value; } From 61bccb005a675bda276fd299dce906d879a6d96b Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 17 Mar 2026 11:35:30 +0200 Subject: [PATCH 21/24] Restore runtime SSRF validation in CustomOAuth2ClientMapper Keep both save-time validation (Oauth2ClientDataValidator) and runtime re-validation as defense-in-depth: DNS records can change between config save and OAuth2 login, creating a TOCTOU gap. --- .../security/auth/oauth2/CustomOAuth2ClientMapper.java | 4 ++++ 1 file changed, 4 insertions(+) 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); From 3747553527adef9b0e425b359e63127ee50fbd02 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 17 Mar 2026 11:44:35 +0200 Subject: [PATCH 22/24] Improve SSRF validator test coverage Add tests for: isHostnameAllowed API, one-arg validateUri overload, allow-list case-insensitivity, allow-list overriding cloud metadata and loopback ranges, CIDR boundary conditions, IPv6 unique local (fc00::/7), whitespace/blank entry parsing, allow-list replacement, and allow-listing blocked hostnames like localhost. --- .../util/SsrfProtectionValidatorTest.java | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) 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 52ab865f6c..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 @@ -425,4 +425,156 @@ public class SsrfProtectionValidatorTest { 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()); + } + } + } From 959a1a84a4bd3c090606432854aa254f2d66e490 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 17 Mar 2026 11:54:50 +0200 Subject: [PATCH 23/24] Make SSRF resolver conditional, sanitize error messages, improve test coverage Wire SsrfSafeAddressResolverGroup only when SSRF protection is enabled. Remove "SSRF protection" prefix from error messages to avoid exposing internal security mechanisms to users. Add 11 new tests covering isHostnameAllowed, one-arg validateUri, allow-list case-insensitivity, cloud metadata/loopback overrides, CIDR boundaries, IPv6 unique local, whitespace parsing, allow-list replacement, and blocked hostname override. --- .../rule/engine/rest/SsrfSafeAddressResolverGroup.java | 4 ++-- .../java/org/thingsboard/rule/engine/rest/TbHttpClient.java | 5 ++++- .../rule/engine/rest/SsrfSafeAddressResolverGroupTest.java | 6 +++--- 3 files changed, 9 insertions(+), 6 deletions(-) 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 index e08dc0ac06..0a099df50e 100644 --- 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 @@ -83,7 +83,7 @@ public final class SsrfSafeAddressResolverGroup extends AddressResolverGroup @@ -140,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 index 79880d98f2..54bfc56a07 100644 --- 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 @@ -18,7 +18,6 @@ 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.Future; import io.netty.util.concurrent.Promise; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; @@ -100,7 +99,7 @@ class SsrfSafeAddressResolverGroupTest { assertThatThrownBy(() -> promise.get(10, TimeUnit.SECONDS)) .isInstanceOf(ExecutionException.class) .hasRootCauseInstanceOf(RuntimeException.class) - .rootCause().hasMessageContaining("SSRF protection"); + .rootCause().hasMessageContaining("is not allowed"); } @Test @@ -116,7 +115,7 @@ class SsrfSafeAddressResolverGroupTest { assertThatThrownBy(() -> promise.get(10, TimeUnit.SECONDS)) .isInstanceOf(ExecutionException.class) .hasRootCauseInstanceOf(RuntimeException.class) - .rootCause().hasMessageContaining("SSRF protection"); + .rootCause().hasMessageContaining("is not allowed"); } @Test @@ -146,4 +145,5 @@ class SsrfSafeAddressResolverGroupTest { assertThat(results).isNotEmpty(); } + } From d83a28beaa5d38d97bcdac91a5065fe58790c3ae Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 17 Mar 2026 11:59:59 +0200 Subject: [PATCH 24/24] Optimize SsrfSafeAddressResolverGroup, remove dead isEnabled checks Replace stream().filter().collect() in resolveAll with single-pass loop to avoid allocations on the common path (nothing blocked). Remove redundant isEnabled() checks inside the resolver since it is only wired when SSRF protection is enabled. Add resolveAll test coverage. --- .../rest/SsrfSafeAddressResolverGroup.java | 89 +++++++++++++------ .../SsrfSafeAddressResolverGroupTest.java | 20 ++++- 2 files changed, 76 insertions(+), 33 deletions(-) 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 index 0a099df50e..9d15cb9793 100644 --- 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 @@ -26,14 +26,18 @@ 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.stream.Collectors; +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 { @@ -76,16 +80,22 @@ public final class SsrfSafeAddressResolverGroup extends AddressResolverGroup resolve(SocketAddress address, Promise promise) { delegate.resolve(address).addListener((Future future) -> { - if (!future.isSuccess()) { - promise.tryFailure(future.cause()); - return; - } - InetSocketAddress resolved = future.getNow(); - if (SsrfProtectionValidator.isEnabled() && isBlocked(resolved) && !isOriginalHostAllowed(address)) { - promise.tryFailure(new RuntimeException( - "URI is invalid: host '" + resolved.getAddress().getHostAddress() + "' is not allowed")); - } else { - promise.trySuccess(resolved); + 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; @@ -99,24 +109,41 @@ public final class SsrfSafeAddressResolverGroup extends AddressResolverGroup> resolveAll(SocketAddress address, Promise> promise) { delegate.resolveAll(address).addListener((Future> future) -> { - if (!future.isSuccess()) { - promise.tryFailure(future.cause()); - return; - } - List resolved = future.getNow(); - if (!SsrfProtectionValidator.isEnabled() || isOriginalHostAllowed(address)) { - promise.trySuccess(resolved); - return; - } - List safe = resolved.stream() - .filter(addr -> !isBlocked(addr)) - .collect(Collectors.toList()); - if (safe.isEmpty()) { - String host = address instanceof InetSocketAddress isa ? isa.getHostString() : address.toString(); - promise.tryFailure(new RuntimeException( - "URI is invalid: host '" + host + "' is not allowed")); - } else { - promise.trySuccess(safe); + 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; @@ -139,5 +166,9 @@ public final class SsrfSafeAddressResolverGroup extends AddressResolverGroup resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); Promise> promise = executor.newPromise(); - executor.submit(() -> resolver.resolveAll(InetSocketAddress.createUnresolved("127.0.0.1", 80), promise)); + 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"); } }