From f30a613ab3ce034e9f7491a4ed185fd4b0666f86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:25:57 -0700 Subject: [PATCH 1/6] Bump serialize-javascript, terser-webpack-plugin and copy-webpack-plugin (#2525) Bumps [serialize-javascript](https://github.com/yahoo/serialize-javascript) to 7.1.0 and updates ancestor dependencies [serialize-javascript](https://github.com/yahoo/serialize-javascript), [terser-webpack-plugin](https://github.com/webpack/minimizer-webpack-plugin) and [copy-webpack-plugin](https://github.com/webpack/copy-webpack-plugin). These dependencies need to be updated together. Updates `serialize-javascript` from 6.0.2 to 7.1.0 - [Release notes](https://github.com/yahoo/serialize-javascript/releases) - [Commits](https://github.com/yahoo/serialize-javascript/compare/v6.0.2...v7.1.0) Updates `terser-webpack-plugin` from 5.3.16 to 5.6.1 - [Release notes](https://github.com/webpack/minimizer-webpack-plugin/releases) - [Changelog](https://github.com/webpack/minimizer-webpack-plugin/blob/main/CHANGELOG.md) - [Commits](https://github.com/webpack/minimizer-webpack-plugin/compare/v5.3.16...v5.6.1) Updates `copy-webpack-plugin` from 11.0.0 to 14.0.0 - [Release notes](https://github.com/webpack/copy-webpack-plugin/releases) - [Changelog](https://github.com/webpack/copy-webpack-plugin/blob/main/CHANGELOG.md) - [Commits](https://github.com/webpack/copy-webpack-plugin/compare/v11.0.0...v14.0.0) Updates `terser-webpack-plugin` from 5.3.9 to 5.6.1 - [Release notes](https://github.com/webpack/minimizer-webpack-plugin/releases) - [Changelog](https://github.com/webpack/minimizer-webpack-plugin/blob/main/CHANGELOG.md) - [Commits](https://github.com/webpack/minimizer-webpack-plugin/compare/v5.3.16...v5.6.1) --- updated-dependencies: - dependency-name: copy-webpack-plugin dependency-version: 14.0.0 dependency-type: direct:production - dependency-name: serialize-javascript dependency-version: 7.0.6 dependency-type: indirect - dependency-name: terser-webpack-plugin dependency-version: 5.6.1 dependency-type: indirect - dependency-name: terser-webpack-plugin dependency-version: 5.6.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 284 ++++++++++++++++++++++------------------------ package.json | 4 +- 2 files changed, 137 insertions(+), 151 deletions(-) diff --git a/package-lock.json b/package-lock.json index 01ec4ba8f..67e5ff124 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "canvas": "3.2.1", "clean-webpack-plugin": "4.0.0", "clipboard-copy": "4.0.1", - "copy-webpack-plugin": "11.0.0", + "copy-webpack-plugin": "14.0.0", "crossroads": "0.12.2", "css-element-queries": "1.1.1", "d3": "7.9.0", @@ -91,7 +91,7 @@ "shell-quote": "1.10.0", "styled-components": "5.0.1", "swr": "0.4.0", - "terser-webpack-plugin": "5.3.9", + "terser-webpack-plugin": "5.6.1", "tinykeys": "2.1.0", "underscore": "1.13.8", "utility-types": "3.10.0", @@ -8969,6 +8969,7 @@ }, "node_modules/ajv-keywords": { "version": "3.5.2", + "dev": true, "license": "MIT", "peerDependencies": { "ajv": "^6.9.1" @@ -10663,18 +10664,19 @@ } }, "node_modules/copy-webpack-plugin": { - "version": "11.0.0", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz", + "integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==", "license": "MIT", "dependencies": { - "fast-glob": "^3.2.11", "glob-parent": "^6.0.1", - "globby": "^13.1.1", "normalize-path": "^3.0.0", - "schema-utils": "^4.0.0", - "serialize-javascript": "^6.0.0" + "schema-utils": "^4.2.0", + "serialize-javascript": "^7.0.3", + "tinyglobby": "^0.2.12" }, "engines": { - "node": ">= 14.15.0" + "node": ">= 20.9.0" }, "funding": { "type": "opencollective", @@ -10708,47 +10710,6 @@ "ajv": "^8.8.2" } }, - "node_modules/copy-webpack-plugin/node_modules/fast-glob": { - "version": "3.3.2", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/copy-webpack-plugin/node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/copy-webpack-plugin/node_modules/globby": { - "version": "13.2.2", - "license": "MIT", - "dependencies": { - "dir-glob": "^3.0.1", - "fast-glob": "^3.3.0", - "ignore": "^5.2.4", - "merge2": "^1.4.1", - "slash": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/copy-webpack-plugin/node_modules/json-schema-traverse": { "version": "1.0.0", "license": "MIT" @@ -10770,16 +10731,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/copy-webpack-plugin/node_modules/slash": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/core-js-compat": { "version": "3.38.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.0.tgz", @@ -14085,7 +14036,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -22626,13 +22576,6 @@ "node": ">=0.12" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.1", "dev": true, @@ -23946,11 +23889,12 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dependencies": { - "randombytes": "^2.1.0" + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.0.tgz", + "integrity": "sha512-RNEqWOyhhUQYN9V1GfHwu9AR/g+NTciH6Z5u3/no6X3/w+04J2lVDL+svFQVXgXrEGBMG2puMVN3gq2SNGuTGw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" } }, "node_modules/serve-index": { @@ -24806,14 +24750,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.9", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.17", + "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.1", - "terser": "^5.16.8" + "schema-utils": "^4.3.0", + "terser": "^5.31.1" }, "engines": { "node": ">= 10.13.0" @@ -24826,17 +24771,72 @@ "webpack": "^5.1.0" }, "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, "@swc/core": { "optional": true }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, "esbuild": { "optional": true }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, "uglify-js": { "optional": true } } }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, "node_modules/terser-webpack-plugin/node_modules/has-flag": { "version": "4.0.0", "license": "MIT", @@ -24856,13 +24856,22 @@ "node": ">= 10.13.0" } }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/terser-webpack-plugin/node_modules/schema-utils": { - "version": "3.3.0", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" }, "engines": { "node": ">= 10.13.0" @@ -24906,6 +24915,51 @@ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinykeys": { "version": "2.1.0", "license": "MIT" @@ -26866,27 +26920,6 @@ "node": ">=4.0" } }, - "node_modules/webpack/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/webpack/node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, "node_modules/webpack/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -26910,53 +26943,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/webpack/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/webpack/node_modules/terser-webpack-plugin": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", - "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", - "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" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, "node_modules/websocket-driver": { "version": "0.7.5", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", diff --git a/package.json b/package.json index 201da1576..832342c01 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "canvas": "3.2.1", "clean-webpack-plugin": "4.0.0", "clipboard-copy": "4.0.1", - "copy-webpack-plugin": "11.0.0", + "copy-webpack-plugin": "14.0.0", "crossroads": "0.12.2", "css-element-queries": "1.1.1", "d3": "7.9.0", @@ -89,7 +89,7 @@ "shell-quote": "1.10.0", "styled-components": "5.0.1", "swr": "0.4.0", - "terser-webpack-plugin": "5.3.9", + "terser-webpack-plugin": "5.6.1", "tinykeys": "2.1.0", "underscore": "1.13.8", "utility-types": "3.10.0", From 49a2ad491aa0c4aadb8a482bae3dd73a5aa1e758 Mon Sep 17 00:00:00 2001 From: jawelton74 <103591340+jawelton74@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:30:06 -0700 Subject: [PATCH 2/6] Fix js-yaml Dependabot alert (#2571) Update ESLint's transitive js-yaml copies to a patched version that addresses CVE-2026-59869. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e102d1bf-4540-4ab5-9dd2-f82fe0267005 --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 67e5ff124..f6ab234a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2697,9 +2697,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dependencies": { "argparse": "^2.0.1" }, @@ -13020,9 +13020,9 @@ } }, "node_modules/eslint/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dependencies": { "argparse": "^2.0.1" }, From a825a7ddc53179e931168852c13e6ebbd2bbefe7 Mon Sep 17 00:00:00 2001 From: asier-isayas Date: Mon, 31 Aug 2026 10:35:54 -0400 Subject: [PATCH 3/6] Support Sovereign/PPE endpoint zones for connection string login + E2E test for SQL account with public network access disabled (#2568) * Support sovereign/PPE endpoint zones for connection string login Move the hardcoded account endpoint suffixes out of Constants and into ConfigContext so connection string login works in sovereign clouds and PPE, and widen ConnectionStringParser to accept every configured zone. Surface Portal Backend rejections in the hosted connect form: read the body off the Response that fetchEncryptedToken throws, and offer a firewall help link on a 403. Add an E2E test covering SQL connection string login against an account with public network access disabled. * Harden connection string DNS zone matching Detect PPE accounts from Mongo and Cassandra connection strings, build their document endpoint from the matched zone, match the PPE suffix on a label boundary, and escape every regex metacharacter in config-supplied zones. Drop the sqlx.cosmosdb.azure.com zone, which is not a real SQL zone. * Mock web-vitals globally in test setup ScenarioMonitor subscribes to web-vitals when it is imported, so the onTTFB timer can throw during any suite that outlives it. Mocking it in setupTests.ts keeps that out of individual test files. * Improve connection string login feedback * Preserve connection restriction check order * Flatten connection login error handling --------- Co-authored-by: Asier Isayas --- src/Common/Constants.ts | 11 - src/ConfigContext.ts | 33 ++ src/HostedExplorer.tsx | 5 +- .../Components/ConnectExplorer.test.tsx | 172 ++++++++++ .../Hosted/Components/ConnectExplorer.tsx | 98 ++++-- src/Platform/Hosted/ConnectScreen.less | 44 +-- .../Helpers/ConnectionStringParser.test.ts | 318 +++++++++++++++--- .../Hosted/Helpers/ConnectionStringParser.ts | 98 ++++-- src/setupTests.ts | 3 + test/fx.ts | 4 + test/sql/connectionStringLogin.spec.ts | 28 ++ 11 files changed, 671 insertions(+), 143 deletions(-) diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index c4d0e1cd5..5f52c1d05 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -1,14 +1,3 @@ -export class EndpointsRegex { - public static readonly cassandra = [ - "AccountEndpoint=(.*).cassandra.cosmosdb.azure.com", - "HostName=(.*).cassandra.cosmos.azure.com", - ]; - public static readonly mongo = "mongodb://.*:(.*)@(.*).documents.azure.com"; - public static readonly mongoCompute = "mongodb://.*:(.*)@(.*).mongo.cosmos.azure.com"; - public static readonly sql = "AccountEndpoint=https://(.*).documents.azure.com"; - public static readonly table = "TableEndpoint=https://(.*).table.cosmos(?:db)?.azure.com"; -} - export class ApiEndpoints { public static runtimeProxy: string = "/api/RuntimeProxy"; public static guestRuntimeProxy: string = "/api/guest/RuntimeProxy"; diff --git a/src/ConfigContext.ts b/src/ConfigContext.ts index 76574a117..a14deb248 100644 --- a/src/ConfigContext.ts +++ b/src/ConfigContext.ts @@ -30,6 +30,13 @@ export interface ConfigContext { allowedCassandraProxyEndpoints: ReadonlyArray; allowedMongoProxyEndpoints: ReadonlyArray; allowedParentFrameOrigins: ReadonlyArray; + SQL_DNS_ZONES: ReadonlyArray; + MONGO_DNS_ZONES: ReadonlyArray; + MONGO_COMPUTE_DNS_ZONES: ReadonlyArray; + CASSANDRA_DNS_ZONES: ReadonlyArray; + TABLE_DNS_ZONES: ReadonlyArray; + DOCUMENT_ENDPOINT_ZONES: ReadonlyArray; + GREMLIN_ENDPOINT_ZONES: ReadonlyArray; gitSha?: string; proxyPath?: string; AAD_ENDPOINT: string; @@ -81,6 +88,32 @@ let configContext: Readonly = { `^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.sg$`, `^https:\\/\\/portal\\.sovcloud-azure\\.sg$`, ], // Webpack injects this at build time + SQL_DNS_ZONES: [ + "documents.azure.com", + "sql.cosmosdb.azure.com", + "sql.cosmos.azure.com", + "sqlx.cosmos.azure.com", + "documents-staging.windows-ppe.net", + "sql.cosmosdb.windows-ppe.net", + "sql.cosmos.windows-ppe.net", + "sqlx.cosmos.windows-ppe.net", + ], + MONGO_DNS_ZONES: ["documents.azure.com", "documents-staging.windows-ppe.net"], + MONGO_COMPUTE_DNS_ZONES: ["mongo.cosmos.azure.com", "mongo.cosmos.windows-ppe.net"], + CASSANDRA_DNS_ZONES: [ + "cassandra.cosmosdb.azure.com", + "cassandra.cosmos.azure.com", + "cassandra.cosmosdb.windows-ppe.net", + "cassandra.cosmos.windows-ppe.net", + ], + TABLE_DNS_ZONES: [ + "table.cosmosdb.azure.com", + "table.cosmos.azure.com", + "table.cosmosdb.windows-ppe.net", + "table.cosmos.windows-ppe.net", + ], + DOCUMENT_ENDPOINT_ZONES: ["documents.azure.com", "documents-staging.windows-ppe.net"], + GREMLIN_ENDPOINT_ZONES: ["gremlin.cosmos.azure.com", "gremlin.cosmos.windows-ppe.net"], gitSha: process.env.GIT_SHA, hostedExplorerURL: "https://cosmos.azure.com/", AAD_ENDPOINT: "https://login.microsoftonline.com/", diff --git a/src/HostedExplorer.tsx b/src/HostedExplorer.tsx index ca28376d6..642141002 100644 --- a/src/HostedExplorer.tsx +++ b/src/HostedExplorer.tsx @@ -1,6 +1,5 @@ import { initializeIcons } from "@fluentui/react"; import { useBoolean } from "@fluentui/react-hooks"; -import { getErrorMessage } from "Common/ErrorHandlingUtils"; import { AadAuthorizationFailure } from "Platform/Hosted/Components/AadAuthorizationFailure"; import * as React from "react"; import { render } from "react-dom"; @@ -83,9 +82,9 @@ const App: React.FunctionComponent = () => { setEncryptedToken(token); setAuthType(AuthType.ConnectionString); }) - .catch((error) => { + .catch(async (error) => { logError( - `Failed to connect with connection string: ${getErrorMessage(error)}`, + `Failed to connect with connection string: ${await (error as Response).text()}`, "HostedExplorer/connectWithConnectionString", ); }); diff --git a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx index a98a89ccc..251502f27 100644 --- a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx +++ b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx @@ -1,11 +1,27 @@ jest.mock("../../../hooks/useDirectories"); +jest.mock("../../../Common/PortalBackendClient"); import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; import { extractFeatures } from "Platform/Hosted/extractFeatures"; import { updateUserContext, userContext } from "UserContext"; import React from "react"; +import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient"; import { ConnectExplorer } from "./ConnectExplorer"; +const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction; +const mockIsAccountRestricted = isAccountRestrictedForConnectionStringLogin as jest.MockedFunction< + typeof isAccountRestrictedForConnectionStringLogin +>; + +// fetchEncryptedToken rejects with the raw Response. +const rejectWithResponse = (status: number, body: string) => + mockFetchEncryptedToken.mockRejectedValue({ status, text: async () => body } as Response); + +beforeEach(() => { + jest.resetAllMocks(); + mockIsAccountRestricted.mockResolvedValue(false); +}); + it("shows the connect form", () => { const connectionString = "fakeConnectionString"; const login = jest.fn(); @@ -63,3 +79,159 @@ it("hides the connection string link when feature.disableConnectionStringLogin i updateUserContext({ features: oldFeatures }); }); + +it("rejects an unrecognized connection string before token exchange", async () => { + render( + , + ); + fireEvent.click(screen.getByText("Connect to your account with connection string")); + fireEvent.click(screen.getByDisplayValue("Connect")); + + expect( + await screen.findByText( + "We couldn't recognize this connection string. Verify that it is a valid Azure Cosmos DB connection string and try again.", + ), + ).toBeInTheDocument(); + expect(mockIsAccountRestricted).toHaveBeenCalledWith("not-a-valid-connection-string"); + expect(mockFetchEncryptedToken).not.toHaveBeenCalled(); +}); + +it("shows that a connection is in progress", async () => { + let finishRestrictionCheck: (restricted: boolean) => void = () => undefined; + mockIsAccountRestricted.mockImplementation( + () => + new Promise((resolve) => { + finishRestrictionCheck = resolve; + }), + ); + + render( + , + ); + fireEvent.click(screen.getByText("Connect to your account with connection string")); + fireEvent.click(screen.getByDisplayValue("Connect")); + + const connectButton = screen.getByDisplayValue("Connecting..."); + expect(connectButton).toBeDisabled(); + expect(connectButton.closest("form")).toHaveAttribute("aria-busy", "true"); + + finishRestrictionCheck(false); + expect(await screen.findByDisplayValue("Connect")).toBeEnabled(); +}); + +it("shows the error when the Portal Backend rejects the connection string", async () => { + // Mongo and Cassandra are the APIs that still exchange the connection string for an encrypted token. + const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255"; + rejectWithResponse(403, "Request originated from IP 1.2.3.4 through public internet."); + + render( + , + ); + fireEvent.click(screen.getByText("Connect to your account with connection string")); + fireEvent.click(screen.getByDisplayValue("Connect")); + + expect( + await screen.findByText( + "Couldn't authenticate with Cosmos DB: Request originated from IP 1.2.3.4 through public internet.", + ), + ).toBeInTheDocument(); +}); + +it("shows a generic error when the Portal Backend fails without a message", async () => { + const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255"; + // A failure with an empty body leaves nothing worth rendering. + rejectWithResponse(500, ""); + + render( + , + ); + fireEvent.click(screen.getByText("Connect to your account with connection string")); + fireEvent.click(screen.getByDisplayValue("Connect")); + + expect( + await screen.findByText("Failed to connect to the account. Please check the connection string and try again."), + ).toBeInTheDocument(); +}); + +it("offers the firewall help link when the Portal Backend is blocked by the account firewall", async () => { + const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255"; + rejectWithResponse(403, "Request originated from IP 1.2.3.4 through public internet."); + + render( + , + ); + fireEvent.click(screen.getByText("Connect to your account with connection string")); + fireEvent.click(screen.getByDisplayValue("Connect")); + + expect(await screen.findByText("Allow access from Azure Portal")).toBeInTheDocument(); +}); + +it("does not offer the firewall help link for a failure the firewall did not cause", async () => { + const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255"; + rejectWithResponse(401, "The connection string is invalid."); + + render( + , + ); + fireEvent.click(screen.getByText("Connect to your account with connection string")); + fireEvent.click(screen.getByDisplayValue("Connect")); + + expect( + await screen.findByText("Couldn't authenticate with Cosmos DB: The connection string is invalid."), + ).toBeInTheDocument(); + expect(screen.queryByText("Allow access from Azure Portal")).toBeNull(); +}); diff --git a/src/Platform/Hosted/Components/ConnectExplorer.tsx b/src/Platform/Hosted/Components/ConnectExplorer.tsx index 99ce638cd..8158b4baf 100644 --- a/src/Platform/Hosted/Components/ConnectExplorer.tsx +++ b/src/Platform/Hosted/Components/ConnectExplorer.tsx @@ -1,10 +1,11 @@ +import { FluentProvider, Link, MessageBar, MessageBarBody, webLightTheme } from "@fluentui/react-components"; import { useBoolean } from "@fluentui/react-hooks"; import { getErrorMessage } from "Common/ErrorHandlingUtils"; import { userContext } from "UserContext"; import * as React from "react"; import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg"; -import ErrorImage from "../../../../images/error.svg"; import { AuthType } from "../../../AuthType"; +import { HttpStatusCodes } from "../../../Common/Constants"; import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient"; import { AccessInputMetadata } from "../../../Contracts/DataModels"; import { parseConnectionString } from "../Helpers/ConnectionStringParser"; @@ -30,6 +31,8 @@ export const ConnectExplorer: React.FunctionComponent = ({ }: Props) => { const [isFormVisible, { setTrue: showForm }] = useBoolean(false); const [errorMessage, setErrorMessage] = React.useState(""); + const [isBlockedByFirewall, setIsBlockedByFirewall] = React.useState(false); + const [isConnecting, setIsConnecting] = React.useState(false); const enableConnectionStringLogin = !userContext.features.disableConnectionStringLogin; return ( @@ -43,39 +46,70 @@ export const ConnectExplorer: React.FunctionComponent = ({ {isFormVisible && enableConnectionStringLogin ? (
{ event.preventDefault(); + if (isConnecting) { + return; + } + setErrorMessage(""); + setIsBlockedByFirewall(false); + setIsConnecting(true); try { if (await isAccountRestrictedForConnectionStringLogin(connectionString)) { setErrorMessage( "This account has been blocked from connection-string login. Please go to cosmos.azure.com/aad for AAD based login.", ); + setIsConnecting(false); return; } } catch (error) { - setErrorMessage(getErrorMessage(error)); + setErrorMessage(getErrorMessage(error as Error)); + setIsConnecting(false); return; } - if (isResourceTokenConnectionString(connectionString)) { - setAuthType(AuthType.ResourceToken); - return; - } + try { + if (isResourceTokenConnectionString(connectionString)) { + setAuthType(AuthType.ResourceToken); + return; + } - const metadata = parseConnectionString(connectionString); - if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) { - // SQL, Table, and Gremlin sign data-plane requests client-side with the account key, so - // we skip the Portal Backend proxy and use the metadata parsed from the connection string. - setAccountMetadata(metadata); + const metadata = parseConnectionString(connectionString); + if (!metadata) { + setErrorMessage( + "We couldn't recognize this connection string. Verify that it is a valid Azure Cosmos DB connection string and try again.", + ); + return; + } + + if (isDirectConnectionStringLoginApi(metadata.apiKind)) { + setAccountMetadata(metadata); + setAuthType(AuthType.ConnectionString); + return; + } + + // Mongo and Cassandra go through the Portal Backend + const encryptedToken = await fetchEncryptedToken(connectionString); + setEncryptedToken(encryptedToken); setAuthType(AuthType.ConnectionString); - return; - } + } catch (error) { + const errorDetails = await (error as Response).text(); - const encryptedToken = await fetchEncryptedToken(connectionString); - setEncryptedToken(encryptedToken); - setAuthType(AuthType.ConnectionString); + setErrorMessage( + errorDetails + ? `Couldn't authenticate with Cosmos DB: ${errorDetails}` + : "Failed to connect to the account. Please check the connection string and try again.", + ); + // A Forbidden usually means the account firewall dropped the request. The connection + // string is exchanged by the Portal Backend rather than the browser, so the account has + // to allowlist those services. + setIsBlockedByFirewall((error as Response).status === HttpStatusCodes.Forbidden); + } finally { + setIsConnecting(false); + } }} >

Connect to your account with connection string

@@ -90,15 +124,33 @@ export const ConnectExplorer: React.FunctionComponent = ({ setConnectionString(event.target.value); }} /> - {errorMessage.length > 0 && ( - - Error notification - {errorMessage} - - )}

+ {errorMessage.length > 0 && ( + + + + {errorMessage} + {isBlockedByFirewall && ( + + Allow access from Azure Portal + + )} + + + + )}

- +

Sign In with Azure Account diff --git a/src/Platform/Hosted/ConnectScreen.less b/src/Platform/Hosted/ConnectScreen.less index d4123f23f..2be2cf128 100644 --- a/src/Platform/Hosted/ConnectScreen.less +++ b/src/Platform/Hosted/ConnectScreen.less @@ -55,41 +55,19 @@ .connectExplorerContainer .connectExplorer .connectExplorerContent .inputToken::placeholder { font-style: italic; } -.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip { - position: relative; - display: inline-block; - padding-left: 4px; - vertical-align: top; +.connectExplorerContainer .connectExplorer .connectErrorMessageBar { + /* Matches the width of the connection string input so the form stays balanced. */ + width: 308px; + margin: 0px auto 8px auto; + text-align: left; } -.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip:hover .errorDetails { - visibility: visible; +.connectExplorerContainer .connectExplorer .connectErrorMessageBar .errorDetails { + /* Service messages are diagnostics containing unbroken URIs and ids. */ + overflow-wrap: anywhere; } -.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails { - bottom: 24px; - width: 165px; - visibility: hidden; - background-color: #393939; - color: #ffffff; - position: absolute; - z-index: 1; - left: -10px; - padding: 6px; -} -.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails:after { - border-width: 10px 10px 0px 10px; - bottom: -8px; - content: ""; - position: absolute; - right: 100%; - border-style: solid; - left: 12px; - width: 0; - height: 0; - border-color: #3b3b3b transparent; -} -.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorImg { - height: 14px; - width: 14px; +.connectExplorerContainer .connectExplorer .connectErrorMessageBar .errorHelpLink { + display: block; + margin-top: 8px; } .filterbtnstyle { diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts index 823ebb115..f1cb417ba 100644 --- a/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts +++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts @@ -1,19 +1,75 @@ +import { configContext, updateConfigContext } from "../../../ConfigContext"; import * as DataModels from "../../../Contracts/DataModels"; -import { parseConnectionString } from "./ConnectionStringParser"; +import { + buildEndpointsRegex, + dnsZoneAlternation, + parseConnectionString, + selectEndpointZone, +} from "./ConnectionStringParser"; describe("ConnectionStringParser", () => { const mockAccountName = "Test"; const mockMasterKey = "some-key"; - it("should parse a valid sql account connection string", () => { - const metadata = parseConnectionString( - `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};`, - ); + // The shape of each api's connection string, parameterized by the dns zone the account sits in. What + // these tests are about is the zones, so keeping the shapes here stops them being restated once per zone. + const buildConnectionString = { + sql: (zone: string) => `AccountEndpoint=https://${mockAccountName}.${zone}:443/;AccountKey=${mockMasterKey};`, + mongo: (zone: string) => `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.${zone}:10255`, + // A cassandra connection string can name the account host under either AccountEndpoint or HostName. + cassandra: (zone: string, hostKey = "AccountEndpoint") => + `${hostKey}=${mockAccountName}.${zone};AccountKey=${mockMasterKey};`, + table: (zone: string) => + `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.${zone}:443/;`, + graph: (zone: string) => + `AccountEndpoint=https://${mockAccountName}.${zone}:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`, + }; - expect(metadata.accountName).toBe(mockAccountName); - expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL); - expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`); - expect(metadata.apiEndpoint).toBeUndefined(); + const connectionStringsByApiKind: Record = { + [DataModels.ApiKind.SQL]: buildConnectionString.sql("documents.azure.com"), + [DataModels.ApiKind.MongoDB]: buildConnectionString.mongo("documents.azure.com"), + [DataModels.ApiKind.MongoDBCompute]: buildConnectionString.mongo("mongo.cosmos.azure.com"), + [DataModels.ApiKind.Cassandra]: buildConnectionString.cassandra("cassandra.cosmosdb.azure.com"), + [DataModels.ApiKind.Table]: buildConnectionString.table("table.cosmosdb.azure.com"), + [DataModels.ApiKind.Graph]: buildConnectionString.graph("documents.azure.com"), + }; + + it("should parse a connection string for every api kind", () => { + Object.entries(connectionStringsByApiKind).forEach(([apiKind, connectionString]) => { + const metadata = parseConnectionString(connectionString); + + expect(metadata.accountName).toBe(mockAccountName); + expect(metadata.apiKind).toBe(Number(apiKind)); + }); + }); + + // The parameterized tests below iterate the zone lists, so removing a zone would silently shrink the + // suite rather than fail it. Pin the expected contents so that stays visible in review. + it("should support the expected dns zones", () => { + expect(configContext.SQL_DNS_ZONES).toEqual([ + "documents.azure.com", + "sql.cosmosdb.azure.com", + "sql.cosmos.azure.com", + "sqlx.cosmos.azure.com", + "documents-staging.windows-ppe.net", + "sql.cosmosdb.windows-ppe.net", + "sql.cosmos.windows-ppe.net", + "sqlx.cosmos.windows-ppe.net", + ]); + expect(configContext.MONGO_DNS_ZONES).toEqual(["documents.azure.com", "documents-staging.windows-ppe.net"]); + expect(configContext.MONGO_COMPUTE_DNS_ZONES).toEqual(["mongo.cosmos.azure.com", "mongo.cosmos.windows-ppe.net"]); + expect(configContext.CASSANDRA_DNS_ZONES).toEqual([ + "cassandra.cosmosdb.azure.com", + "cassandra.cosmos.azure.com", + "cassandra.cosmosdb.windows-ppe.net", + "cassandra.cosmos.windows-ppe.net", + ]); + expect(configContext.TABLE_DNS_ZONES).toEqual([ + "table.cosmosdb.azure.com", + "table.cosmos.azure.com", + "table.cosmosdb.windows-ppe.net", + "table.cosmos.windows-ppe.net", + ]); }); it("should keep the document endpoint given by the connection string", () => { @@ -26,28 +82,99 @@ describe("ConnectionStringParser", () => { expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com/`); }); - it("should parse a valid mongo account connection string", () => { - const metadata = parseConnectionString( - `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.documents.azure.com:10255`, - ); + it.each(configContext.SQL_DNS_ZONES)( + "should parse a sql account connection string using the %s zone", + (dnsZone: string) => { + const metadata = parseConnectionString(buildConnectionString.sql(dnsZone)); - expect(metadata.accountName).toBe(mockAccountName); - expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB); - }); + expect(metadata.accountName).toBe(mockAccountName); + expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL); + expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.${dnsZone}:443/`); + expect(metadata.apiEndpoint).toBeUndefined(); + }, + ); - it("should parse a valid compute mongo account connection string", () => { - const metadata = parseConnectionString( - `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.mongo.cosmos.azure.com:10255`, - ); + it.each(configContext.MONGO_DNS_ZONES)( + "should parse a mongo account connection string using the %s zone", + (dnsZone: string) => { + const metadata = parseConnectionString(buildConnectionString.mongo(dnsZone)); - expect(metadata.accountName).toBe(mockAccountName); - expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute); - }); + expect(metadata.accountName).toBe(mockAccountName); + expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB); + }, + ); + + it.each(configContext.MONGO_COMPUTE_DNS_ZONES)( + "should parse a compute mongo account connection string using the %s zone", + (dnsZone: string) => { + const metadata = parseConnectionString(buildConnectionString.mongo(dnsZone)); + + expect(metadata.accountName).toBe(mockAccountName); + expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute); + }, + ); + + it.each( + ["AccountEndpoint", "HostName"].flatMap((hostKey) => + configContext.CASSANDRA_DNS_ZONES.map((dnsZone) => [hostKey, dnsZone]), + ), + )( + "should parse a cassandra account connection string using %s and the %s zone", + (hostKey: string, dnsZone: string) => { + const metadata = parseConnectionString(buildConnectionString.cassandra(dnsZone, hostKey)); + + expect(metadata.accountName).toBe(mockAccountName); + expect(metadata.apiKind).toBe(DataModels.ApiKind.Cassandra); + }, + ); + + it.each(configContext.TABLE_DNS_ZONES)( + "should parse a table account connection string using the %s zone", + (dnsZone: string) => { + const metadata = parseConnectionString(buildConnectionString.table(dnsZone)); + + expect(metadata.accountName).toBe(mockAccountName); + expect(metadata.apiKind).toBe(DataModels.ApiKind.Table); + expect(metadata.apiEndpoint).toBeUndefined(); + }, + ); + + // A Mongo, Cassandra or Table connection string names the account in its own api's dns zone rather than + // giving the document endpoint, so the document endpoint that data plane operations go through is built + // from the account name, under a zone of the same kind as the one that matched. + const publicDocumentEndpoint = `https://${mockAccountName}.documents.azure.com:443/`; + const ppeDocumentEndpoint = `https://${mockAccountName}.documents-staging.windows-ppe.net:443/`; + const expectedDocumentEndpointByZone: Record = { + "documents.azure.com": publicDocumentEndpoint, + "documents-staging.windows-ppe.net": ppeDocumentEndpoint, + "mongo.cosmos.azure.com": publicDocumentEndpoint, + "mongo.cosmos.windows-ppe.net": ppeDocumentEndpoint, + "cassandra.cosmosdb.azure.com": publicDocumentEndpoint, + "cassandra.cosmos.azure.com": publicDocumentEndpoint, + "cassandra.cosmosdb.windows-ppe.net": ppeDocumentEndpoint, + "cassandra.cosmos.windows-ppe.net": ppeDocumentEndpoint, + "table.cosmosdb.azure.com": publicDocumentEndpoint, + "table.cosmos.azure.com": publicDocumentEndpoint, + "table.cosmosdb.windows-ppe.net": ppeDocumentEndpoint, + "table.cosmos.windows-ppe.net": ppeDocumentEndpoint, + }; + + it.each([ + ...configContext.MONGO_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.mongo(dnsZone)]), + ...configContext.MONGO_COMPUTE_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.mongo(dnsZone)]), + ...configContext.CASSANDRA_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.cassandra(dnsZone)]), + ...configContext.TABLE_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.table(dnsZone)]), + ])( + "should construct the document endpoint for an account in the %s zone", + (dnsZone: string, connectionString: string) => { + const metadata = parseConnectionString(connectionString); + + expect(metadata.documentEndpoint).toBe(expectedDocumentEndpointByZone[dnsZone]); + }, + ); it("should parse a valid graph account connection string", () => { - const metadata = parseConnectionString( - `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`, - ); + const metadata = parseConnectionString(buildConnectionString.graph("documents.azure.com")); expect(metadata.accountName).toBe(mockAccountName); expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph); @@ -55,36 +182,41 @@ describe("ConnectionStringParser", () => { expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.azure.com:443`); }); - it("should parse a valid table account connection string", () => { - const metadata = parseConnectionString( - `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`, - ); + it("should construct a PPE gremlin endpoint for a PPE graph account", () => { + const metadata = parseConnectionString(buildConnectionString.graph("documents-staging.windows-ppe.net")); expect(metadata.accountName).toBe(mockAccountName); - expect(metadata.apiKind).toBe(DataModels.ApiKind.Table); - // Table data operations go through the document endpoint, which is constructed from the account name. - expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`); - expect(metadata.apiEndpoint).toBeUndefined(); + expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph); + expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents-staging.windows-ppe.net:443/`); + // The constructed endpoint has to match the kind of zone the document endpoint we matched came from. + expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.windows-ppe.net:443`); }); - it("should parse a valid table account connection string using the cosmos.azure.com zone", () => { - const metadata = parseConnectionString( - `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmos.azure.com:443/;`, - ); + it("should reject a connection string when no DNS zone matches the account", () => { + const originalZones = configContext.DOCUMENT_ENDPOINT_ZONES; + updateConfigContext({ DOCUMENT_ENDPOINT_ZONES: ["documents.azure.com"] }); - expect(metadata.accountName).toBe(mockAccountName); - expect(metadata.apiKind).toBe(DataModels.ApiKind.Table); - expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`); - expect(metadata.apiEndpoint).toBeUndefined(); + try { + const metadata = parseConnectionString(buildConnectionString.table("table.cosmos.windows-ppe.net")); + + // The account key travels to the constructed document endpoint, so a config carrying no PPE zone + // has to fail on a PPE account rather than fall back to a zone the account does not own. + expect(metadata).toBe(undefined); + } finally { + updateConfigContext({ DOCUMENT_ENDPOINT_ZONES: originalZones }); + } }); - it("should parse a valid cassandra account connection string", () => { - const metadata = parseConnectionString( - `AccountEndpoint=${mockAccountName}.cassandra.cosmosdb.azure.com;AccountKey=${mockMasterKey};`, - ); - - expect(metadata.accountName).toBe(mockAccountName); - expect(metadata.apiKind).toBe(DataModels.ApiKind.Cassandra); + it.each([ + buildConnectionString.sql("documents.azure.com.attacker.example"), + buildConnectionString.mongo("documents.azure.com.attacker.example"), + buildConnectionString.mongo("mongo.cosmos.azure.com.attacker.example"), + buildConnectionString.cassandra("cassandra.cosmosdb.azure.com.attacker.example"), + buildConnectionString.table("table.cosmosdb.azure.com.attacker.example"), + ])("should not accept a host that only begins with a known zone: %s", (connectionString: string) => { + // The zone list is what keeps the account key from being sent somewhere arbitrary, so a host that + // appends to an allowed zone must not pass as that zone. + expect(parseConnectionString(connectionString)).toBe(undefined); }); it("should fail to parse an invalid connection string", () => { @@ -98,4 +230,92 @@ describe("ConnectionStringParser", () => { expect(metadata).toBe(undefined); }); + + describe("dnsZoneAlternation", () => { + it("should escape the dots in a zone", () => { + expect(dnsZoneAlternation(["documents.azure.com"])).toBe("(documents\\.azure\\.com)(?=[:/\\s]|$)"); + }); + + it("should join multiple zones into a single alternation", () => { + expect(dnsZoneAlternation(["a.example", "b.test"])).toBe("(a\\.example|b\\.test)(?=[:/\\s]|$)"); + }); + + it("should not let the dots match arbitrary characters", () => { + // An unescaped dot would make the zone list match hosts that only resemble a real zone. + const regex = RegExp(dnsZoneAlternation(["documents.azure.com"])); + + expect(regex.test("documents.azure.com")).toBe(true); + expect(regex.test("documentsXazure.com")).toBe(false); + }); + + it("should capture the zone that matched", () => { + const regex = RegExp(dnsZoneAlternation(["a.example", "b.test"])); + + expect("account.b.test".match(regex)[1]).toBe("b.test"); + }); + + it("should require the zone to run to the end of the host", () => { + const regex = RegExp(dnsZoneAlternation(["documents.azure.com"])); + + expect(regex.test("account.documents.azure.com")).toBe(true); + expect(regex.test("account.documents.azure.com:443/")).toBe(true); + expect(regex.test("account.documents.azure.com/")).toBe(true); + // Without this the zone list stops being an allowlist, since anything can be appended to a zone. + expect(regex.test("account.documents.azure.com.attacker.example")).toBe(false); + }); + }); + + describe("buildEndpointsRegex", () => { + it("should build a pattern for every api matched by dns zone", () => { + expect(Object.keys(buildEndpointsRegex())).toEqual(["sql", "mongo", "mongoCompute", "cassandra", "table"]); + }); + + it("should build a cassandra pattern for each supported key", () => { + const { cassandra } = buildEndpointsRegex(); + + expect(cassandra).toHaveLength(2); + expect(cassandra[0]).toContain("AccountEndpoint="); + expect(cassandra[1]).toContain("HostName="); + }); + + it("should build each pattern from its own zone list", () => { + // The patterns are near identical, so a zone list wired to the wrong api would be easy to miss in + // review and would let an account of one api be parsed as another. + const { sql, mongo, mongoCompute, cassandra, table } = buildEndpointsRegex(); + + expect(sql).toContain(dnsZoneAlternation(configContext.SQL_DNS_ZONES)); + expect(mongo).toContain(dnsZoneAlternation(configContext.MONGO_DNS_ZONES)); + expect(mongoCompute).toContain(dnsZoneAlternation(configContext.MONGO_COMPUTE_DNS_ZONES)); + cassandra.forEach((pattern) => expect(pattern).toContain(dnsZoneAlternation(configContext.CASSANDRA_DNS_ZONES))); + expect(table).toContain(dnsZoneAlternation(configContext.TABLE_DNS_ZONES)); + }); + }); + + describe("selectEndpointZone", () => { + const nonPpeZone = "documents.azure.com"; + const ppeZone = "documents-staging.windows-ppe.net"; + + it("should pick the ppe zone for a ppe account", () => { + expect(selectEndpointZone([nonPpeZone, ppeZone], true)).toBe(ppeZone); + }); + + it("should pick the non ppe zone for a non ppe account", () => { + expect(selectEndpointZone([nonPpeZone, ppeZone], false)).toBe(nonPpeZone); + }); + + it("should not depend on the order of the zones", () => { + expect(selectEndpointZone([ppeZone, nonPpeZone], true)).toBe(ppeZone); + expect(selectEndpointZone([ppeZone, nonPpeZone], false)).toBe(nonPpeZone); + }); + + it("should return undefined when no zone matches the kind of account", () => { + // Sovereign configs carry no ppe zone, and a ppe only config carries no non ppe zone. + expect(selectEndpointZone([nonPpeZone], true)).toBeUndefined(); + expect(selectEndpointZone([ppeZone], false)).toBeUndefined(); + }); + + it("should return undefined for an empty zone list", () => { + expect(selectEndpointZone([], false)).toBeUndefined(); + }); + }); }); diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts index 44be7e82a..9730052ea 100644 --- a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts +++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts @@ -1,44 +1,80 @@ -import * as Constants from "../../../Common/Constants"; +import { configContext } from "../../../ConfigContext"; import { AccessInputMetadata, ApiKind } from "../../../Contracts/DataModels"; -// Cosmos DB DNS zones used to construct endpoints client-side. These mirror what the Portal Backend's -// accessinputmetadata API constructs from the account name when the connection string does not already -// contain the endpoint. -const DocumentEndpointZone = "documents.azure.com"; -const GremlinEndpointZone = "gremlin.cosmos.azure.com"; +const PpeDnsSuffix = "windows-ppe.net"; const DnsPort = "443"; +// Match on a label boundary so a zone like "notwindows-ppe.net" is not taken for a PPE zone. +const isPpeZone = (zone: string): boolean => zone === PpeDnsSuffix || zone.endsWith(`.${PpeDnsSuffix}`); + +// Picks the DNS zone matching the kind of account the connection string came from, since a PPE +// account's endpoints sit under PPE zones and every other account's do not. +export const selectEndpointZone = (zones: ReadonlyArray, isPpeAccount: boolean): string | undefined => + zones.find((zone) => isPpeZone(zone) === isPpeAccount); + +// Builds an alternation matching any of the given DNS zones, e.g. "(documents\.azure\.com|sql\.cosmos\.azure\.com)". +// The group captures so callers can tell which zone matched, and with it whether the account is a PPE account. +// The zone has to run to the end of the host, otherwise a host that merely starts with an allowed zone +// would pass as that zone and the account key would travel to whatever was appended to it. Zones come +// from config, so every regex metacharacter is escaped rather than just the dots. +export const dnsZoneAlternation = (zones: ReadonlyArray): string => + `(${zones.map((zone) => zone.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(?=[:/\\s]|$)`; + +// The zone lists live in ConfigContext, which is populated asynchronously by initializeConfiguration, +// so these are built per call rather than once at module load. +export const buildEndpointsRegex = () => ({ + sql: `AccountEndpoint=https://([^.]+)\\.${dnsZoneAlternation(configContext.SQL_DNS_ZONES)}`, + mongo: `mongodb://.*:(.*)@([^.]+)\\.${dnsZoneAlternation(configContext.MONGO_DNS_ZONES)}`, + mongoCompute: `mongodb://.*:(.*)@([^.]+)\\.${dnsZoneAlternation(configContext.MONGO_COMPUTE_DNS_ZONES)}`, + cassandra: ["AccountEndpoint", "HostName"].map( + (key) => `${key}=([^.]+)\\.${dnsZoneAlternation(configContext.CASSANDRA_DNS_ZONES)}`, + ), + table: `TableEndpoint=https://([^.]+)\\.${dnsZoneAlternation(configContext.TABLE_DNS_ZONES)}`, +}); + export function parseConnectionString(connectionString: string): AccessInputMetadata { if (connectionString) { try { const accessInput = {} as AccessInputMetadata; const connectionStringParts = connectionString.split(";"); + const endpointsRegex = buildEndpointsRegex(); + // Endpoints we build from the account name have to match the kind of zone the connection string + // actually matched, since PPE accounts and other accounts do not share zones. + let isPpeAccount = false; connectionStringParts.forEach((connectionStringPart: string) => { - if (RegExp(Constants.EndpointsRegex.sql).test(connectionStringPart)) { - accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.sql)[1]; + if (RegExp(endpointsRegex.sql).test(connectionStringPart)) { + const matches: string[] = connectionStringPart.match(endpointsRegex.sql); + accessInput.accountName = matches[1]; accessInput.apiKind = ApiKind.SQL; // SQL and Gremlin connection strings carry the account's document endpoint, so take it as // given instead of rebuilding it from the account name. accessInput.documentEndpoint = connectionStringPart.substring(connectionStringPart.indexOf("=") + 1); - } else if (RegExp(Constants.EndpointsRegex.mongo).test(connectionStringPart)) { - const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongo); + isPpeAccount = isPpeZone(matches[2]); + } else if (RegExp(endpointsRegex.mongo).test(connectionStringPart)) { + const matches: string[] = connectionStringPart.match(endpointsRegex.mongo); accessInput.accountName = matches && matches.length > 1 && matches[2]; accessInput.apiKind = ApiKind.MongoDB; - } else if (RegExp(Constants.EndpointsRegex.mongoCompute).test(connectionStringPart)) { - const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongoCompute); + isPpeAccount = isPpeZone(matches[3]); + } else if (RegExp(endpointsRegex.mongoCompute).test(connectionStringPart)) { + const matches: string[] = connectionStringPart.match(endpointsRegex.mongoCompute); accessInput.accountName = matches && matches.length > 1 && matches[2]; accessInput.apiKind = ApiKind.MongoDBCompute; - } else if (Constants.EndpointsRegex.cassandra.some((regex) => RegExp(regex).test(connectionStringPart))) { - Constants.EndpointsRegex.cassandra.forEach((regex) => { - if (RegExp(regex).test(connectionStringPart)) { - accessInput.accountName = connectionStringPart.match(regex)[1]; + isPpeAccount = isPpeZone(matches[3]); + } else if (endpointsRegex.cassandra.some((regex) => RegExp(regex).test(connectionStringPart))) { + endpointsRegex.cassandra.forEach((regex) => { + const matches: string[] = connectionStringPart.match(regex); + if (matches) { + accessInput.accountName = matches[1]; accessInput.apiKind = ApiKind.Cassandra; + isPpeAccount = isPpeZone(matches[2]); } }); - } else if (RegExp(Constants.EndpointsRegex.table).test(connectionStringPart)) { - accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.table)[1]; + } else if (RegExp(endpointsRegex.table).test(connectionStringPart)) { + const matches: string[] = connectionStringPart.match(endpointsRegex.table); + accessInput.accountName = matches[1]; accessInput.apiKind = ApiKind.Table; + isPpeAccount = isPpeZone(matches[2]); } else if (connectionStringPart.indexOf("ApiKind=Gremlin") >= 0) { accessInput.apiKind = ApiKind.Graph; } @@ -48,14 +84,28 @@ export function parseConnectionString(connectionString: string): AccessInputMeta return undefined; } - // Table connection strings only carry the table endpoint, so the document endpoint that data plane - // operations go through has to be derived from the account name. Gremlin accounts additionally - // need the Gremlin endpoint, which is never part of the connection string. + // A Table, Mongo or Cassandra connection string names the account in its own api's dns zone rather + // than giving the document endpoint, so the document endpoint that data plane operations go through + // has to be built from the account name. SQL and Gremlin strings carry it and take it as given. + // Gremlin additionally needs the Gremlin endpoint, which is never part of the connection string. if (accessInput.accountName) { - if (accessInput.apiKind === ApiKind.Table) { - accessInput.documentEndpoint = `https://${accessInput.accountName}.${DocumentEndpointZone}:${DnsPort}/`; + if ( + accessInput.apiKind === ApiKind.Table || + accessInput.apiKind === ApiKind.MongoDB || + accessInput.apiKind === ApiKind.MongoDBCompute || + accessInput.apiKind === ApiKind.Cassandra + ) { + const documentEndpointZone = selectEndpointZone(configContext.DOCUMENT_ENDPOINT_ZONES, isPpeAccount); + if (!documentEndpointZone) { + return undefined; + } + accessInput.documentEndpoint = `https://${accessInput.accountName}.${documentEndpointZone}:${DnsPort}/`; } else if (accessInput.apiKind === ApiKind.Graph) { - accessInput.apiEndpoint = `${accessInput.accountName}.${GremlinEndpointZone}:${DnsPort}`; + const gremlinEndpointZone = selectEndpointZone(configContext.GREMLIN_ENDPOINT_ZONES, isPpeAccount); + if (!gremlinEndpointZone) { + return undefined; + } + accessInput.apiEndpoint = `${accessInput.accountName}.${gremlinEndpointZone}:${DnsPort}`; } } diff --git a/src/setupTests.ts b/src/setupTests.ts index 720a385d8..9d2e6dfb3 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -6,6 +6,9 @@ import enableHooks from "jest-react-hooks-shallow"; import { TextDecoder, TextEncoder } from "util"; import i18n from "./i18n"; import enResources from "./Localization/en/Resources.json"; + +jest.mock("web-vitals"); + configure({ adapter: new Adapter() }); initializeIcons(); diff --git a/test/fx.ts b/test/fx.ts index a3cad859a..f3fc2e679 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -49,6 +49,7 @@ export enum TestAccount { SQLReadOnly = "SQLReadOnly", SQLContainerCopyOnly = "SQLContainerCopyOnly", SQLConnectionString = "SQLConnectionString", + SQLConnectionStringPublicNetworkAccessDisabled = "SQLConnectionStringPublicNetworkAccessDisabled", TableConnectionString = "TableConnectionString", GremlinConnectionString = "GremlinConnectionString", } @@ -83,6 +84,8 @@ export function getDefaultAccountName(accountType: TestAccount): string { return `${accountNamePrefix}-de-test-sql-containercopy`; case TestAccount.SQLConnectionString: return `${accountNamePrefix}-de-test-sql-connstring-1`; + case TestAccount.SQLConnectionStringPublicNetworkAccessDisabled: + return `${accountNamePrefix}-de-test-sql-connstring-nopublic-1`; case TestAccount.TableConnectionString: return `${accountNamePrefix}-de-test-table-connstring-1`; case TestAccount.GremlinConnectionString: @@ -258,6 +261,7 @@ export async function getTestExplorerUrl(accountType: TestAccount, options?: Tes break; case TestAccount.SQLConnectionString: + case TestAccount.SQLConnectionStringPublicNetworkAccessDisabled: case TestAccount.TableConnectionString: case TestAccount.GremlinConnectionString: // Connection string (account key) login navigates directly to hostedExplorer.html and doesn't diff --git a/test/sql/connectionStringLogin.spec.ts b/test/sql/connectionStringLogin.spec.ts index 8359e6dee..88293bd64 100644 --- a/test/sql/connectionStringLogin.spec.ts +++ b/test/sql/connectionStringLogin.spec.ts @@ -115,4 +115,32 @@ test.describe("SQL account using connection string login", () => { await expect(page.locator("#connectExplorer")).toHaveCount(0); await expect(page.locator(".errorDetails")).toHaveCount(0); }); + + test("opens Data Explorer but loads no databases when the account rejects the client IP", async ({ page }) => { + // An account that refuses this client's IP. + const armClient = new CosmosDBManagementClient(getAzureCLICredentials(), subscriptionId); + const blockedAccountName = getAccountName(TestAccount.SQLConnectionStringPublicNetworkAccessDisabled); + const blockedAccount = await armClient.databaseAccounts.get(resourceGroupName, blockedAccountName); + const blockedKeys = await armClient.databaseAccounts.listKeys(resourceGroupName, blockedAccountName); + + await loginWithConnectionString( + page, + `AccountEndpoint=${blockedAccount.documentEndpoint!};AccountKey=${blockedKeys.primaryMasterKey};`, + ); + + const explorer = await DataExplorer.waitForExplorer(page); + + // Login is a client-side parse of the connection string, so nothing checks whether the account will + // accept requests from this IP before letting the user in. + await expect(page.locator("#connectExplorer")).toHaveCount(0); + await expect(page.locator(".errorDetails")).toHaveCount(0); + + // The rejection surfaces once the tree tries to read the data plane, and only in the console. + const consoleMessages = await explorer.getNotificationConsoleMessages(); + await expect(consoleMessages).toContainText("Error while refreshing databases", { timeout: ONE_MINUTE_MS }); + + // The tree is left with the static Home node and no database or container beneath it. + await expect(explorer.treeNode("Home").element).toBeAttached(); + await expect(explorer.frame.locator("[data-test^='TreeNode:']")).toHaveCount(1); + }); }); From 325abbdff5a46290d7aa1a8bffc478eae705bf95 Mon Sep 17 00:00:00 2001 From: jawelton74 <103591340+jawelton74@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:40:33 -0700 Subject: [PATCH 4/6] Pin GitHub Actions to full-length commit SHAs (#2574) Co-authored-by: Dan Fiedler --- .github/dependabot.yml | 9 ++++++ .github/workflows/ci.yml | 54 +++++++++++++++++------------------ .github/workflows/cleanup.yml | 6 ++-- 3 files changed, 39 insertions(+), 30 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 60fa0ee1a..5103aa0e3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,3 +7,12 @@ updates: directory: "/" schedule: interval: "daily" + - package-ecosystem: "github-actions" + directory: "/" + groups: + github-actions: + patterns: ["*"] + schedule: + interval: "weekly" + cooldown: + default-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 274d5b862..8dc333990 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,9 +19,9 @@ jobs: name: "Log Code Metrics" if: github.ref == 'refs/heads/master' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Use Node.js 22.x - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22.x - run: npm ci @@ -32,9 +32,9 @@ jobs: runs-on: ubuntu-latest name: "Compile TypeScript" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Use Node.js 22.x - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22.x - run: npm ci @@ -44,9 +44,9 @@ jobs: runs-on: ubuntu-latest name: "Check Format" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Use Node.js 22.x - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22.x - run: npm ci @@ -55,9 +55,9 @@ jobs: runs-on: ubuntu-latest name: "Lint" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Use Node.js 22.x - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22.x - run: npm ci @@ -66,9 +66,9 @@ jobs: runs-on: ubuntu-latest name: "Unit Tests" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Use Node.js 22.x - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22.x - run: npm ci @@ -77,15 +77,15 @@ jobs: runs-on: ubuntu-latest name: "Build" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Use Node.js 22.x - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22.x - run: npm ci - run: npm run build:contracts - name: Restore Build Cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .cache key: ${{ runner.os }}-build-cache @@ -94,12 +94,12 @@ jobs: NODE_OPTIONS: "--max-old-space-size=4096" - run: cp -r ./Contracts ./dist/contracts - run: cp -r ./configs ./dist/configs - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist path: dist/ - name: "Az CLI login" - uses: azure/login@v1 + uses: azure/login@cb79c773a3cfa27f31f25eb3f677781210c9ce3d # v1.6.1 with: client-id: ${{ secrets.E2ETESTS_CLIENT_ID }} tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }} @@ -118,7 +118,7 @@ jobs: AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }} steps: - name: Download Dist Folder - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: dist - run: cp ./configs/prod.json config.json @@ -126,7 +126,7 @@ jobs: - run: dotnet pack DataExplorer.proj /p:PackageVersion="2.0.0-github-${GITHUB_SHA}" - run: dotnet nuget push "bin/Release/*.nupkg" --skip-duplicate --api-key Az --source="$NUGET_SOURCE" - run: dotnet nuget remove source "ADO" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 name: Upload package to Artifacts with: name: prod-package @@ -142,7 +142,7 @@ jobs: AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }} steps: - name: Download Dist Folder - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: dist - run: cp ./configs/mpac.json config.json @@ -151,7 +151,7 @@ jobs: - run: dotnet pack DataExplorer.proj /p:PackageVersion="2.0.0-github-${GITHUB_SHA}" - run: dotnet nuget push "bin/Release/*.nupkg" --skip-duplicate --api-key Az --source="$NUGET_SOURCE" - run: dotnet nuget remove source "ADO" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 name: Upload package to Artifacts with: name: mpac-package @@ -172,22 +172,22 @@ jobs: shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20] shardTotal: [20] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Use Node.js 22.x - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22.x - run: npm ci - run: npx playwright install --with-deps - name: "Az CLI login" - uses: Azure/login@v2 + uses: Azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2.3.1 with: client-id: ${{ secrets.E2ETESTS_CLIENT_ID }} tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }} subscription-id: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }} # We can't use MSAL within playwright so we acquire tokens prior to running the tests - name: "Acquire RBAC tokens for test accounts" - uses: azure/cli@v2 + uses: azure/cli@9f7ce6f37c31b777ec6c6b6d1dfe7db79f497956 # v2.2.0 env: DE_ACCOUNT_PREFIX: ${{ secrets.E2ETESTS_ACCOUNT_PREFIX }} DE_TEST_RESOURCE_GROUP: ${{ secrets.E2ETESTS_RESOURCEGROUP_NAME }} @@ -231,7 +231,7 @@ jobs: run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=3 - name: "Re-auth for upload (refresh OIDC token)" if: ${{ !cancelled() }} - uses: Azure/login@v2 + uses: Azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2.3.1 with: client-id: ${{ secrets.E2ETESTS_CLIENT_ID }} tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }} @@ -262,15 +262,15 @@ jobs: pull-requests: write id-token: write steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22.x - name: Install dependencies run: npm ci - name: "Az CLI login" - uses: Azure/login@v2 + uses: Azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2.3.1 with: client-id: ${{ secrets.E2ETESTS_CLIENT_ID }} tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }} diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index add9f15e1..5382dc11e 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -23,16 +23,16 @@ jobs: AZURE_SUBSCRIPTION_ID: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }} E2ETESTS_RESOURCEGROUP_NAME : ${{ secrets.E2ETESTS_RESOURCEGROUP_NAME }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: "Az CLI login" - uses: azure/login@v2 + uses: azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2.3.1 with: client-id: ${{ secrets.E2ETESTS_CLIENT_ID }} tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }} subscription-id: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }} - name: Use Node.js 22.x - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22.x - run: npm ci From 5080e516d7d245bbfa68ca194bc1647ebbe8981a Mon Sep 17 00:00:00 2001 From: jawelton74 <103591340+jawelton74@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:28:31 -0700 Subject: [PATCH 5/6] Update preview site URL (#2572) * Update all preview website URL references to the new URL. * Remove az cli config. * Fix formatting. * Fix new preview URL in ConfigContext --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- preview/.azure/config | 7 ------- preview/README.md | 4 ++-- preview/config.json | 2 +- preview/index.js | 2 +- src/ConfigContext.ts | 2 +- src/Utils/EndpointUtils.ts | 4 +++- web.config | 2 +- 8 files changed, 10 insertions(+), 15 deletions(-) delete mode 100644 preview/.azure/config diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7ff47e44c..f294ca39f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1 +1 @@ -[Preview this branch](https://dataexplorer-preview.azurewebsites.net/pull/EDIT_THIS_NUMBER_IN_THE_PR_DESCRIPTION?feature.someFeatureFlagYouMightNeed=true) +[Preview this branch](https://dataexplorer-preview.portal.cosmos.azure.com/pull/EDIT_THIS_NUMBER_IN_THE_PR_DESCRIPTION?feature.someFeatureFlagYouMightNeed=true) diff --git a/preview/.azure/config b/preview/.azure/config deleted file mode 100644 index 4e174b741..000000000 --- a/preview/.azure/config +++ /dev/null @@ -1,7 +0,0 @@ -[defaults] -group = dataexplorer-preview -sku = P1v2 -appserviceplan = dataexplorer-preview -location = westus2 -web = dataexplorer-preview - diff --git a/preview/README.md b/preview/README.md index 59a84a2d7..86858539e 100644 --- a/preview/README.md +++ b/preview/README.md @@ -4,8 +4,8 @@ Cosmos Explorer Preview makes it possible to try a working version of any commit Initial support is for Hosted (Connection string only) or the Azure Portal. Examples: -Connection string URLs: https://dataexplorer-preview.azurewebsites.net/commit/COMMIT_SHA/hostedExplorer.html -Portal URLs: https://ms.portal.azure.com/?dataExplorerSource=https://dataexplorer-preview.azurewebsites.net/commit/COMMIT_SHA/explorer.html#home +Connection string URLs: https://dataexplorer-preview.portal.cosmos.azure.com/commit/COMMIT_SHA/hostedExplorer.html +Portal URLs: https://ms.portal.azure.com/?dataExplorerSource=https://dataexplorer-preview.portal.cosmos.azure.com/commit/COMMIT_SHA/explorer.html#home In both cases replace `COMMIT_SHA` with the commit you want to view. It must have already completed its build on GitHub Actions. diff --git a/preview/config.json b/preview/config.json index a67bcd2ef..0332eae1f 100644 --- a/preview/config.json +++ b/preview/config.json @@ -1,4 +1,4 @@ { "PROXY_PATH": "/proxy", - "msalRedirectURI": "https://dataexplorer-preview.azurewebsites.net/" + "msalRedirectURI": "https://dataexplorer-preview.portal.cosmos.azure.com/" } diff --git a/preview/index.js b/preview/index.js index be44c63ed..7a994a7e6 100644 --- a/preview/index.js +++ b/preview/index.js @@ -4,7 +4,7 @@ const port = process.env.PORT || 3000; const fetch = require("node-fetch"); const backendEndpoint = "https://cdb-ms-mpac-pbe.cosmos.azure.com"; -const previewSiteEndpoint = "https://dataexplorer-preview.azurewebsites.net"; +const previewSiteEndpoint = "https://dataexplorer-preview.portal.cosmos.azure.com"; const previewStorageWebsiteEndpoint = "_REPLACE_STORAGE_WEBSITE_ENDPOINT_"; const githubApiUrl = "https://api.github.com/repos/Azure/cosmos-explorer"; const azurePortalMpacEndpoint = "https://ms.portal.azure.com/"; diff --git a/src/ConfigContext.ts b/src/ConfigContext.ts index a14deb248..34eaeed07 100644 --- a/src/ConfigContext.ts +++ b/src/ConfigContext.ts @@ -80,7 +80,7 @@ let configContext: Readonly = { `^https:\\/\\/cosmos-db-dataexplorer-germanycentral\\.azurewebsites\\.de$`, `^https:\\/\\/.*\\.fabric\\.microsoft\\.com$`, `^https:\\/\\/.*\\.powerbi\\.com$`, - `^https:\\/\\/dataexplorer-preview\\.azurewebsites\\.net$`, + `^https:\\/\\/dataexplorer-preview\\.portal\\.cosmos\\.azure\\.com$`, `^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.fr$`, `^https:\\/\\/portal\\.sovcloud-azure\\.fr$`, `^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.de$`, diff --git a/src/Utils/EndpointUtils.ts b/src/Utils/EndpointUtils.ts index 9f716fcb2..e230a0469 100644 --- a/src/Utils/EndpointUtils.ts +++ b/src/Utils/EndpointUtils.ts @@ -88,7 +88,9 @@ export const allowedHostedExplorerEndpoints: ReadonlyArray = [ ...(process.env.NODE_ENV === "development" ? ["https://localhost:12900"] : []), ]; -export const allowedMsalRedirectEndpoints: ReadonlyArray = ["https://dataexplorer-preview.azurewebsites.net/"]; +export const allowedMsalRedirectEndpoints: ReadonlyArray = [ + "https://dataexplorer-preview.portal.cosmos.azure.com/", +]; export const allowedJunoOrigins: ReadonlyArray = [ JunoEndpoints.Test, diff --git a/web.config b/web.config index e987fa939..2bea8b149 100644 --- a/web.config +++ b/web.config @@ -30,7 +30,7 @@ - + From 1f04f0ae7662aed13941f5b65dc59532aeeea7d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:01:01 -0700 Subject: [PATCH 6/6] Bump fast-uri from 3.1.5 to 3.1.7 (#2582) Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.5 to 3.1.7. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.7) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.7 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f6ab234a8..b7e080993 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13493,9 +13493,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github",