From 615d79d163516c0b2451c84cdc5f0def581ca074 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Thu, 9 Jul 2026 17:46:57 -0300 Subject: [PATCH] fix: honor uniqueId-scoped support exceptions on server-signed path The exception scope check introduced in #3323 requires exceptions.uniqueId to equal server.uniqueID, but the server-signed validation path returns before getUniqueId() ever runs and /api/info does not include a uniqueId field, so the local uniqueID is missing on that path and uniqueId-scoped exceptions were always disqualified. Resolve the workspace uniqueID from the server before validating when the payload carries a uniqueId-scoped exceptions block, persist it via WEBVIEW_SERVER_UNIQUE_ID_UPDATED so subsequent runs (including offline cache validation) keep working, and keep rejecting when the fetched value does not match. Also compare exceptions.domain case-insensitively (DNS names are case-insensitive per RFC 4343). --- .../supportedVersions/main.main.spec.ts | 203 ++++++++++++++++++ src/servers/supportedVersions/main.ts | 40 +++- 2 files changed, 238 insertions(+), 5 deletions(-) diff --git a/src/servers/supportedVersions/main.main.spec.ts b/src/servers/supportedVersions/main.main.spec.ts index 71f5f214bd..937ea8fd1d 100644 --- a/src/servers/supportedVersions/main.main.spec.ts +++ b/src/servers/supportedVersions/main.main.spec.ts @@ -371,6 +371,173 @@ describe('supportedVersions/main.ts', () => { }); }); + // ========== EXCEPTION UNIQUE ID SCOPE (SERVER-SIGNED PATH) ========== + describe('Exception uniqueId scope resolution on server-signed path', () => { + const tenantSupportedVersions = (overrides?: Partial) => + createMockSupportedVersions({ + versions: [ + { + version: '8.6.0', + expiration: new Date(Date.now() + 86400000), + }, + ], + exceptions: { + domain: 'test.rocket.chat', + uniqueId: 'tenant-unique-id', + versions: [ + { + version: '7.13.9', + expiration: new Date(Date.now() + 86400000), + }, + ], + }, + enforcementStartDate: '2023-12-15T00:00:00Z', + ...overrides, + }); + + it('should fetch uniqueID before validating when /api/info omits uniqueId and exception is uniqueId-scoped', async () => { + const mockServer = createMockServer({ version: '7.13' }); + const mockServerInfo = createMockServerInfo({ + version: '7.13', + uniqueId: undefined, + }); + selectMock.mockReturnValue(mockServer); + axiosMock.get = jest + .fn() + .mockResolvedValueOnce({ data: mockServerInfo }) + .mockResolvedValueOnce({ + data: { settings: [{ value: 'tenant-unique-id' }] }, + }); + + (jest.spyOn(jsonwebtoken, 'verify') as jest.Mock).mockReturnValue( + tenantSupportedVersions() + ); + + await updateSupportedVersionsData(mockServer.url); + + const uniqueIdDispatch = dispatchMock.mock.calls.find( + ([action]) => + (action as any).type === WEBVIEW_SERVER_UNIQUE_ID_UPDATED && + (action as any).payload?.uniqueID === 'tenant-unique-id' + ); + expect(uniqueIdDispatch).toBeDefined(); + + const verdictDispatch = dispatchMock.mock.calls.find( + ([action]) => + (action as any).type === WEBVIEW_SERVER_IS_SUPPORTED_VERSION + ); + expect(verdictDispatch?.[0]).toEqual({ + type: WEBVIEW_SERVER_IS_SUPPORTED_VERSION, + payload: { + url: mockServer.url, + isSupportedVersion: true, + }, + }); + }); + + it('should re-fetch uniqueID when persisted value is stale and honor the exception', async () => { + const mockServer = createMockServer({ + version: '7.13', + uniqueID: 'stale-unique-id', + }); + const mockServerInfo = createMockServerInfo({ + version: '7.13', + uniqueId: undefined, + }); + selectMock.mockReturnValue(mockServer); + axiosMock.get = jest + .fn() + .mockResolvedValueOnce({ data: mockServerInfo }) + .mockResolvedValueOnce({ + data: { settings: [{ value: 'tenant-unique-id' }] }, + }); + + (jest.spyOn(jsonwebtoken, 'verify') as jest.Mock).mockReturnValue( + tenantSupportedVersions() + ); + + await updateSupportedVersionsData(mockServer.url); + + const verdictDispatch = dispatchMock.mock.calls.find( + ([action]) => + (action as any).type === WEBVIEW_SERVER_IS_SUPPORTED_VERSION + ); + expect(verdictDispatch?.[0]).toEqual({ + type: WEBVIEW_SERVER_IS_SUPPORTED_VERSION, + payload: { + url: mockServer.url, + isSupportedVersion: true, + }, + }); + }); + + it('should reject the exception when the fetched uniqueID does not match the exception scope', async () => { + const mockServer = createMockServer({ version: '7.13' }); + const mockServerInfo = createMockServerInfo({ + version: '7.13', + uniqueId: undefined, + }); + selectMock.mockReturnValue(mockServer); + axiosMock.get = jest + .fn() + .mockResolvedValueOnce({ data: mockServerInfo }) + .mockResolvedValueOnce({ + data: { settings: [{ value: 'other-tenant-id' }] }, + }); + + (jest.spyOn(jsonwebtoken, 'verify') as jest.Mock).mockReturnValue( + tenantSupportedVersions() + ); + + await updateSupportedVersionsData(mockServer.url); + + const verdictDispatch = dispatchMock.mock.calls.find( + ([action]) => + (action as any).type === WEBVIEW_SERVER_IS_SUPPORTED_VERSION + ); + expect(verdictDispatch?.[0]).toEqual({ + type: WEBVIEW_SERVER_IS_SUPPORTED_VERSION, + payload: { + url: mockServer.url, + isSupportedVersion: false, + }, + }); + }); + + it('should not fetch uniqueID when the persisted value already matches the exception scope', async () => { + const mockServer = createMockServer({ + version: '7.13', + uniqueID: 'tenant-unique-id', + }); + const mockServerInfo = createMockServerInfo({ + version: '7.13', + uniqueId: undefined, + }); + selectMock.mockReturnValue(mockServer); + axiosMock.get = jest.fn().mockResolvedValueOnce({ data: mockServerInfo }); + + (jest.spyOn(jsonwebtoken, 'verify') as jest.Mock).mockReturnValue( + tenantSupportedVersions() + ); + + await updateSupportedVersionsData(mockServer.url); + + expect(axiosMock.get).toHaveBeenCalledTimes(1); + + const verdictDispatch = dispatchMock.mock.calls.find( + ([action]) => + (action as any).type === WEBVIEW_SERVER_IS_SUPPORTED_VERSION + ); + expect(verdictDispatch?.[0]).toEqual({ + type: WEBVIEW_SERVER_IS_SUPPORTED_VERSION, + payload: { + url: mockServer.url, + isSupportedVersion: true, + }, + }); + }); + }); + // ========== CLOUD FETCH PATH TESTS ========== describe('Cloud Fetch Path', () => { it('should fetch cloud info with retries when server fails', async () => { @@ -837,6 +1004,42 @@ describe('supportedVersions/main.ts', () => { expect(result.supported).toBe(true); }); + it('should honor exceptions when exceptions.domain differs only by letter case', async () => { + const futureDate = new Date(Date.now() + 86400000); + const supportedVersions: SupportedVersions = { + enforcementStartDate: new Date(Date.now() - 86400000).toISOString(), + timestamp: new Date().toISOString(), + versions: [ + { + version: '8.4.0', + expiration: futureDate, + }, + ], + exceptions: { + domain: 'Open.Rocket.Chat', + uniqueId: 'test-unique-id', + versions: [ + { + version: '8.5.1', + expiration: futureDate, + }, + ], + }, + }; + + const result = await isServerVersionSupported( + { + url: 'https://open.rocket.chat/', + version: '8.5', + title: 'Rocket.Chat Open', + uniqueID: 'test-unique-id', + } as any, + supportedVersions + ); + + expect(result.supported).toBe(true); + }); + it('should support sha-prefixed exception versions by git commit hash', async () => { const futureDate = new Date(Date.now() + 86400000); const supportedVersions: SupportedVersions = { diff --git a/src/servers/supportedVersions/main.ts b/src/servers/supportedVersions/main.ts index 43b2dc6ed8..b299544e1b 100644 --- a/src/servers/supportedVersions/main.ts +++ b/src/servers/supportedVersions/main.ts @@ -353,7 +353,8 @@ export const isServerVersionSupported = async ( } catch { hostname = undefined; } - if (exceptions.domain && exceptions.domain !== hostname) { + // DNS names are case-insensitive; URL.hostname is already lowercased. + if (exceptions.domain && exceptions.domain.toLowerCase() !== hostname) { exceptionScopeMatches = false; } if (exceptions.uniqueId && exceptions.uniqueId !== server.uniqueID) { @@ -502,6 +503,30 @@ const dispatchSupportedVersionsUpdated = ( }); }; +// When a supported-versions payload carries a uniqueId-scoped exceptions +// block, the scope check requires server.uniqueID to equal +// exceptions.uniqueId. The server-signed fast path returns before the general +// getUniqueId call, and /api/info does not include uniqueId, so a missing or +// stale persisted uniqueID would permanently disqualify the tenant's own +// exceptions. Resolve it from the server before validating (and persist it +// for future runs, including offline cache validation). +const withExceptionScopeUniqueId = async ( + serverView: Server, + supportedVersionsData: SupportedVersions | undefined, + versionForUniqueId: string +): Promise => { + const exceptionsUniqueId = supportedVersionsData?.exceptions?.uniqueId; + if (!exceptionsUniqueId || serverView.uniqueID === exceptionsUniqueId) { + return serverView; + } + const freshUniqueId = await getUniqueId(serverView.url, versionForUniqueId) + .then(dispatchUniqueIdUpdated(serverView.url)) + .catch(logRequestError('unique ID')); + return freshUniqueId + ? { ...serverView, uniqueID: freshUniqueId } + : serverView; +}; + // Per-URL request generation counter. Each call to updateSupportedVersionsData // bumps the counter and captures its own generation. Awaited steps inside the // call check whether their generation is still current before dispatching, so @@ -540,9 +565,8 @@ export const updateSupportedVersionsData = async ( // Build a server view that reflects the freshly-fetched version and // uniqueId when available, so every downstream support check // (server/cloud/cache/builtin) is evaluated against the same authoritative - // identity. /api/info returns `uniqueId`, which is required for exception - // scope matching to honor server-source exceptions on first launch (when - // persisted server.uniqueID may be undefined). + // identity. Current servers do NOT include `uniqueId` in /api/info, so the + // fallback to persisted server.uniqueID is the common path. const serverWithFreshVersion: Server = serverInfoResult ? { ...server, @@ -567,9 +591,15 @@ export const updateSupportedVersionsData = async ( const serverSupportedVersions = decodeSupportedVersions(serverEncoded); if (isStale()) return; saveToCache(serverUrl, serverSupportedVersions); - const supported = await isServerVersionSupported( + const serverForValidation = await withExceptionScopeUniqueId( serverWithFreshVersion, serverSupportedVersions, + serverInfoResult.version + ); + if (isStale()) return; + const supported = await isServerVersionSupported( + serverForValidation, + serverSupportedVersions, freshCommitHash ); if (isStale()) return;