Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/meteor/app/api/server/lib/getServerInfo.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,6 +16,8 @@ export async function getServerInfo(userId?: string): Promise<IWorkspaceInfo> {
const cloudWorkspaceId = settings.get<string | undefined>('Cloud_Workspace_Id');

return {
workspaceUrl: License.getWorkspaceUrl(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: These fields are returned unconditionally, but the /info endpoint is unauthenticated (authRequired: false). This exposes workspaceUrl and hashedWorkspaceUrl to any anonymous caller. Move these fields inside the hasPermissionToViewStatistics guard or to a privileged endpoint to avoid publicly disclosing workspace identifiers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/app/api/server/lib/getServerInfo.ts, line 19:

<comment>These fields are returned unconditionally, but the `/info` endpoint is unauthenticated (`authRequired: false`). This exposes `workspaceUrl` and `hashedWorkspaceUrl` to any anonymous caller. Move these fields inside the `hasPermissionToViewStatistics` guard or to a privileged endpoint to avoid publicly disclosing workspace identifiers.</comment>

<file context>
@@ -15,6 +16,8 @@ export async function getServerInfo(userId?: string): Promise<IWorkspaceInfo> {
 	const cloudWorkspaceId = settings.get<string | undefined>('Cloud_Workspace_Id');
 
 	return {
+		workspaceUrl: License.getWorkspaceUrl(),
+		hashedWorkspaceUrl: License.getHashedWorkspaceUrl(),
 		version: getTrimmedServerVersion(),
</file context>

hashedWorkspaceUrl: License.getHashedWorkspaceUrl(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
version: getTrimmedServerVersion(),
...(hasPermissionToViewStatistics && {
info: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ type DeploymentCardProps = {
statistics: IStats;
};

const DeploymentCard = ({ serverInfo: { info, cloudWorkspaceId }, statistics, instances }: DeploymentCardProps) => {
const DeploymentCard = ({
serverInfo: { info, cloudWorkspaceId, workspaceUrl, hashedWorkspaceUrl },
statistics,
instances,
}: DeploymentCardProps) => {
const { t } = useTranslation();
const formatDateAndTime = useFormatDateAndTime();
const setModal = useSetModal();
Expand All @@ -39,6 +43,18 @@ const DeploymentCard = ({ serverInfo: { info, cloudWorkspaceId }, statistics, in
<WorkspaceCardSectionTitle title={t('Version')} />
{statistics.version}
</WorkspaceCardSection>
{workspaceUrl && (
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Site_Url')} />
{workspaceUrl}
</WorkspaceCardSection>
)}
{hashedWorkspaceUrl && (
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Hashed_Site_Url')} />
<span style={{ lineBreak: 'anywhere' }}>{hashedWorkspaceUrl}</span>
</WorkspaceCardSection>
)}
<WorkspaceCardSection>
<WorkspaceCardSectionTitle title={t('Deployment_ID')} />
{statistics.uniqueId}
Expand Down
4 changes: 3 additions & 1 deletion apps/meteor/server/startup/serverRunning.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string>('Site_Url')}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The displayed Site URL comes from the raw Site_Url setting (which may include scheme and trailing slash), but the Hashed Site URL is computed from License.getWorkspaceUrl() which strips those. This means the two displayed values won't correspond — the hash isn't of the URL shown next to it. Use License.getWorkspaceUrl() for the plain URL as well to keep them consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/server/startup/serverRunning.ts, line 52:

<comment>The displayed Site URL comes from the raw `Site_Url` setting (which may include scheme and trailing slash), but the Hashed Site URL is computed from `License.getWorkspaceUrl()` which strips those. This means the two displayed values won't correspond — the hash isn't of the URL shown next to it. Use `License.getWorkspaceUrl()` for the plain URL as well to keep them consistent.</comment>

<file context>
@@ -48,7 +49,8 @@ Meteor.startup(async () => {
 			`           Platform: ${process.platform}`,
 			`       Process Port: ${process.env.PORT}`,
-			`           Site URL: ${settings.get('Site_Url')}`,
+			`           Site URL: ${settings.get<string>('Site_Url')}`,
+			`    Hashed Site URL: ${License.getHashedWorkspaceUrl()}`,
 		];
</file context>
Suggested change
` Site URL: ${settings.get<string>('Site_Url')}`,
` Site URL: ${License.getWorkspaceUrl()}`,

` Hashed Site URL: ${License.getHashedWorkspaceUrl()}`,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
];

if (Info.commit?.hash) {
Expand Down
16 changes: 16 additions & 0 deletions ee/packages/license/src/license.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import crypto from 'node:crypto';

import type {
ILicenseTag,
LicenseEvents,
Expand Down Expand Up @@ -161,6 +163,20 @@ export abstract class LicenseManager extends Emitter<LicenseEvents> {
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<LicenseValidationOptions, 'isNewLicense'> = {}): Promise<void> {
if (!this.hasValidLicense()) {
return;
Expand Down
33 changes: 33 additions & 0 deletions ee/packages/license/src/validation/validateLicenseUrl.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
});
37 changes: 29 additions & 8 deletions ee/packages/license/src/validation/validateLicenseUrl.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import crypto from 'node:crypto';

import type { ILicenseV3, BehaviorWithContext, LicenseValidationOptions } from '@rocket.chat/core-typings';

import { isBehaviorAllowed } from '../isItemAllowed';
Expand All @@ -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[] {
Expand All @@ -41,25 +38,49 @@ export function validateLicenseUrl(this: LicenseManager, license: ILicenseV3, op
return [getResultingBehavior({ behavior: 'invalidate_license' }, { reason: 'url' })];
}

const hashedWorkspaceUrl = this.hashWorkspaceUrl(workspaceUrl);

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, workspaceUrl);
return !validateHash(url.value, hashedWorkspaceUrl);
case 'url':
return !validateUrl(url.value, workspaceUrl);
default:
return false;
return true; // If the type is unknown, consider it invalid to be safe.
}
})
.map((url) => {
if (!options.suppressLog) {
logger.error({
msg: 'Url validation failed',
url,
licenseUrl: url,
workspaceUrl,
hashedWorkspaceUrl,
});
}
return getResultingBehavior({ behavior: 'invalidate_license' }, { reason: 'url' });
Expand Down
2 changes: 2 additions & 0 deletions packages/core-typings/src/IWorkspaceInfo.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -2538,6 +2538,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",
Expand Down
Loading