From 0a5e2a9d17d3e86134330b212e723aaea53f8662 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Mon, 25 May 2026 17:15:20 -0300 Subject: [PATCH 1/2] chore: Expose workspace hashed url in workspace admin page and startup logs --- .../meteor/app/api/server/lib/getServerInfo.ts | 3 +++ .../DeploymentCard/DeploymentCard.tsx | 18 +++++++++++++++++- apps/meteor/server/startup/serverRunning.ts | 4 +++- ee/packages/license/src/license.ts | 16 ++++++++++++++++ .../src/validation/validateLicenseUrl.ts | 17 +++++++++-------- packages/core-typings/src/IWorkspaceInfo.ts | 2 ++ packages/i18n/src/locales/en.i18n.json | 1 + 7 files changed, 51 insertions(+), 10 deletions(-) diff --git a/apps/meteor/app/api/server/lib/getServerInfo.ts b/apps/meteor/app/api/server/lib/getServerInfo.ts index 4c65847c86ae6..adaf6ec6998c5 100644 --- a/apps/meteor/app/api/server/lib/getServerInfo.ts +++ b/apps/meteor/app/api/server/lib/getServerInfo.ts @@ -1,4 +1,5 @@ import type { IWorkspaceInfo } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import { getTrimmedServerVersion } from './getTrimmedServerVersion'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; @@ -15,6 +16,8 @@ export async function getServerInfo(userId?: string): Promise { const cloudWorkspaceId = settings.get('Cloud_Workspace_Id'); return { + workspaceUrl: License.getWorkspaceUrl(), + hashedWorkspaceUrl: License.getHashedWorkspaceUrl(), version: getTrimmedServerVersion(), ...(hasPermissionToViewStatistics && { info: { diff --git a/apps/meteor/client/views/admin/workspace/DeploymentCard/DeploymentCard.tsx b/apps/meteor/client/views/admin/workspace/DeploymentCard/DeploymentCard.tsx index 2d9c6e5e224d8..17598492f43ae 100644 --- a/apps/meteor/client/views/admin/workspace/DeploymentCard/DeploymentCard.tsx +++ b/apps/meteor/client/views/admin/workspace/DeploymentCard/DeploymentCard.tsx @@ -18,7 +18,11 @@ type DeploymentCardProps = { statistics: IStats; }; -const DeploymentCard = ({ serverInfo: { info, cloudWorkspaceId }, statistics, instances }: DeploymentCardProps): ReactElement => { +const DeploymentCard = ({ + serverInfo: { info, cloudWorkspaceId, workspaceUrl, hashedWorkspaceUrl }, + statistics, + instances, +}: DeploymentCardProps): ReactElement => { const { t } = useTranslation(); const formatDateAndTime = useFormatDateAndTime(); const setModal = useSetModal(); @@ -40,6 +44,18 @@ const DeploymentCard = ({ serverInfo: { info, cloudWorkspaceId }, statistics, in {statistics.version} + {workspaceUrl && ( + + + {workspaceUrl} + + )} + {hashedWorkspaceUrl && ( + + + {hashedWorkspaceUrl} + + )} {statistics.uniqueId} diff --git a/apps/meteor/server/startup/serverRunning.ts b/apps/meteor/server/startup/serverRunning.ts index 9408895221357..f1cc50f8950f1 100644 --- a/apps/meteor/server/startup/serverRunning.ts +++ b/apps/meteor/server/startup/serverRunning.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import { License } from '@rocket.chat/license'; // import { Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import semver from 'semver'; @@ -48,7 +49,8 @@ Meteor.startup(async () => { ` MongoDB Engine: ${mongoStorageEngine}`, ` Platform: ${process.platform}`, ` Process Port: ${process.env.PORT}`, - ` Site URL: ${settings.get('Site_Url')}`, + ` Site URL: ${settings.get('Site_Url')}`, + ` Hashed Site URL: ${License.getHashedWorkspaceUrl()}`, ]; if (Info.commit?.hash) { diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index 0e92af730b1cf..a717f9475181e 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -1,3 +1,5 @@ +import crypto from 'node:crypto'; + import type { ILicenseTag, LicenseEvents, @@ -161,6 +163,20 @@ export abstract class LicenseManager extends Emitter { return this.workspaceUrl; } + public hashWorkspaceUrl(url: string) { + return crypto.createHash('sha256').update(url).digest('hex'); + } + + public getHashedWorkspaceUrl() { + const workspaceUrl = this.getWorkspaceUrl(); + + if (!workspaceUrl) { + return undefined; + } + + return this.hashWorkspaceUrl(workspaceUrl); + } + public async revalidateLicense(options: Omit = {}): Promise { if (!this.hasValidLicense()) { return; diff --git a/ee/packages/license/src/validation/validateLicenseUrl.ts b/ee/packages/license/src/validation/validateLicenseUrl.ts index b6ba078bc745a..d6226a5428552 100644 --- a/ee/packages/license/src/validation/validateLicenseUrl.ts +++ b/ee/packages/license/src/validation/validateLicenseUrl.ts @@ -1,5 +1,3 @@ -import crypto from 'node:crypto'; - import type { ILicenseV3, BehaviorWithContext, LicenseValidationOptions } from '@rocket.chat/core-typings'; import { isBehaviorAllowed } from '../isItemAllowed'; @@ -20,9 +18,8 @@ const validateUrl = (licenseURL: string, url: string) => { return licenseURL.toLowerCase() === url.toLowerCase(); }; -const validateHash = (licenseURL: string, url: string) => { - const value = crypto.createHash('sha256').update(url).digest('hex'); - return licenseURL === value; +const validateHash = (licenseURL: string, hashedUrl: string) => { + return licenseURL === hashedUrl; }; export function validateLicenseUrl(this: LicenseManager, license: ILicenseV3, options: LicenseValidationOptions): BehaviorWithContext[] { @@ -41,15 +38,18 @@ export function validateLicenseUrl(this: LicenseManager, license: ILicenseV3, op return [getResultingBehavior({ behavior: 'invalidate_license' }, { reason: 'url' })]; } + const hashedWorkspaceUrl = this.hashWorkspaceUrl(workspaceUrl); + return serverUrls .filter((url) => { switch (url.type) { case 'regex': return !validateRegex(url.value, workspaceUrl); case 'hash': - return !validateHash(url.value, workspaceUrl); + return !validateHash(url.value, hashedWorkspaceUrl); case 'url': - return !validateUrl(url.value, workspaceUrl); + // Fall back for hash validation in case the type was not set correctly, to avoid invalidating licenses unnecessarily. + return !validateUrl(url.value, workspaceUrl) && !validateHash(url.value, hashedWorkspaceUrl); default: return false; } @@ -58,8 +58,9 @@ export function validateLicenseUrl(this: LicenseManager, license: ILicenseV3, op if (!options.suppressLog) { logger.error({ msg: 'Url validation failed', - url, + licenseUrl: url, workspaceUrl, + hashedWorkspaceUrl, }); } return getResultingBehavior({ behavior: 'invalidate_license' }, { reason: 'url' }); diff --git a/packages/core-typings/src/IWorkspaceInfo.ts b/packages/core-typings/src/IWorkspaceInfo.ts index c918e2126d08f..cdc9ac80ea53c 100644 --- a/packages/core-typings/src/IWorkspaceInfo.ts +++ b/packages/core-typings/src/IWorkspaceInfo.ts @@ -1,6 +1,8 @@ import type { IServerInfo } from './IServerInfo'; export interface IWorkspaceInfo { + workspaceUrl?: string; + hashedWorkspaceUrl?: string; info?: IServerInfo; supportedVersions?: { signed: string }; minimumClientVersions: { desktop: string; mobile: string }; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 99f7b4b067fb8..c5946562575a6 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2521,6 +2521,7 @@ "HTML": "HTML", "Hang_up_and_transfer_call": "Hang up and transfer call", "Hash": "Hash", + "Hashed_Site_Url": "Hashed Site URL", "Header": "Header", "Header_and_Footer": "Header and Footer", "Healthcare": "Healthcare", From 0b6af16e4d8ea9b125a811d456a0622550c05c16 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Wed, 27 May 2026 19:40:43 -0300 Subject: [PATCH 2/2] Improve url validation logic for type mismatch --- .../src/validation/validateLicenseUrl.spec.ts | 33 +++++++++++++++++++ .../src/validation/validateLicenseUrl.ts | 26 +++++++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/ee/packages/license/src/validation/validateLicenseUrl.spec.ts b/ee/packages/license/src/validation/validateLicenseUrl.spec.ts index c534fd887c047..6a714c424169e 100644 --- a/ee/packages/license/src/validation/validateLicenseUrl.spec.ts +++ b/ee/packages/license/src/validation/validateLicenseUrl.spec.ts @@ -127,4 +127,37 @@ describe('Url Validation', () => { ).toStrictEqual([]); }); }); + + describe('type mismatch', () => { + it('should validate as hash if the type is url but the value looks like a hash', async () => { + const licenseManager = await getReadyLicenseManager(); + + const hash = crypto.createHash('sha256').update('localhost:3000').digest('hex'); + const license = await new MockedLicenseBuilder().withServerUrls({ + value: hash, + type: 'url', + }); + await expect( + validateLicenseUrl.call(licenseManager, await license.build(), { + behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'], + suppressLog: false, + }), + ).toStrictEqual([]); + }); + + it('should validate as url if the type is hash but the value looks like a url', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withServerUrls({ + value: 'localhost:3000', + type: 'hash', + }); + await expect( + validateLicenseUrl.call(licenseManager, await license.build(), { + behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'], + suppressLog: false, + }), + ).toStrictEqual([]); + }); + }); }); diff --git a/ee/packages/license/src/validation/validateLicenseUrl.ts b/ee/packages/license/src/validation/validateLicenseUrl.ts index d6226a5428552..dd18da952f77a 100644 --- a/ee/packages/license/src/validation/validateLicenseUrl.ts +++ b/ee/packages/license/src/validation/validateLicenseUrl.ts @@ -42,16 +42,36 @@ export function validateLicenseUrl(this: LicenseManager, license: ILicenseV3, op return serverUrls .filter((url) => { + if ( + url.type === 'url' && + url.value.length === 64 && + /^[a-f0-9]{64}$/i.test(url.value) && + validateHash(url.value, hashedWorkspaceUrl) + ) { + // If the url type is 'url' but the value looks like a hash, validate it as a hash to avoid invalidating licenses unnecessarily. + logger.warn( + `License URL with type 'url' is actually a hash. Validating as hash to avoid invalidating license unnecessarily. url: ${url.value}`, + ); + return false; + } + + if (url.type === 'hash' && !/^[a-f0-9]{64}$/i.test(url.value) && validateUrl(url.value, workspaceUrl)) { + // If the url type is 'hash' but the value looks like a url, validate it as a url to avoid invalidating licenses unnecessarily. + logger.warn( + `License URL with type 'hash' does not look like a hash. Validating as url to avoid invalidating license unnecessarily. url: ${url.value}`, + ); + return false; + } + switch (url.type) { case 'regex': return !validateRegex(url.value, workspaceUrl); case 'hash': return !validateHash(url.value, hashedWorkspaceUrl); case 'url': - // Fall back for hash validation in case the type was not set correctly, to avoid invalidating licenses unnecessarily. - return !validateUrl(url.value, workspaceUrl) && !validateHash(url.value, hashedWorkspaceUrl); + return !validateUrl(url.value, workspaceUrl); default: - return false; + return true; // If the type is unknown, consider it invalid to be safe. } }) .map((url) => {