From cf515b8f39675e251ab1b63cb6482d33a8fc21b8 Mon Sep 17 00:00:00 2001 From: dakotadux Date: Fri, 11 Jul 2025 20:11:53 -0600 Subject: [PATCH 1/5] Log a user into the O11y service react app This updates the frontend to log a user into the O11y service react app. The user is logged in by passing a JWT token to the app. The token is generated by the backend and passed to the frontend. --- .../observability/components/app.vue | 261 ++++++- app/assets/javascripts/observability/index.js | 12 + .../javascripts/observability/utils/crypto.js | 156 +++++ locale/gitlab.pot | 25 +- .../observability/components/app_spec.js | 663 +++++++++++++++++- .../observability/utils/crypto_spec.js | 253 +++++++ 6 files changed, 1334 insertions(+), 36 deletions(-) create mode 100644 app/assets/javascripts/observability/utils/crypto.js create mode 100644 spec/frontend/observability/utils/crypto_spec.js diff --git a/app/assets/javascripts/observability/components/app.vue b/app/assets/javascripts/observability/components/app.vue index fab8d5b9523ee5..05b02810d90889 100644 --- a/app/assets/javascripts/observability/components/app.vue +++ b/app/assets/javascripts/observability/components/app.vue @@ -1,4 +1,7 @@ @@ -24,13 +273,17 @@ export default {
+
{{ $options.i18n.messages.loadingMetrics }}
+
+ {{ $options.i18n.messages.authenticationFailed }} +
diff --git a/app/assets/javascripts/observability/index.js b/app/assets/javascripts/observability/index.js index 500091cff2ef00..c4a2b8aaacf80a 100644 --- a/app/assets/javascripts/observability/index.js +++ b/app/assets/javascripts/observability/index.js @@ -6,6 +6,15 @@ export default () => { if (!el) return null; const { dataset } = el; + const authTokens = {}; + for (const key in dataset) { + if (key.startsWith('authTokens')) { + const newKey = key.replace(/^authTokens/, ''); + const formattedKey = newKey.charAt(0).toLowerCase() + newKey.slice(1); + authTokens[formattedKey] = dataset[key]; + } + } + return new Vue({ el, render(h) { @@ -13,6 +22,9 @@ export default () => { props: { o11yUrl: dataset.o11yUrl, path: dataset.path, + authTokens, + title: dataset.title, + encryptionKey: dataset.encryptionKey, }, }); }, diff --git a/app/assets/javascripts/observability/utils/crypto.js b/app/assets/javascripts/observability/utils/crypto.js new file mode 100644 index 00000000000000..0752477e59fb88 --- /dev/null +++ b/app/assets/javascripts/observability/utils/crypto.js @@ -0,0 +1,156 @@ +import { s__ } from '~/locale'; + +/** + * Secure Cross-Frame Messaging Encryption Utilities + * + * This module provides encryption/decryption for sensitive data passed between + * Vue parent and React iframe using Web Crypto API with AES-GCM encryption. + */ + +const CRYPTO_ERROR_MESSAGE = s__('Observability|Crypto operation failed'); + +const ENCRYPTION_CONFIG = { + algorithm: 'AES-GCM', + keyLength: 256, + ivLength: 12, + tagLength: 128, + keyDerivationIterations: 100000, + saltLength: 16, + // eslint-disable-next-line @gitlab/require-i18n-strings + keyDerivationAlgorithm: 'PBKDF2', + hashAlgorithm: 'SHA-256', +}; + +const getSharedSecret = (encryptionKey) => { + if (!encryptionKey) { + throw new Error(s__('Observability|Encryption key is required for secure operation')); + } + const envSecret = encryptionKey; + + return new TextEncoder().encode(envSecret); +}; + +const deriveKey = async (secret, salt) => { + try { + const baseKey = await crypto.subtle.importKey( + 'raw', + secret, + ENCRYPTION_CONFIG.keyDerivationAlgorithm, + false, + ['deriveKey'], + ); + + return await crypto.subtle.deriveKey( + { + name: ENCRYPTION_CONFIG.keyDerivationAlgorithm, + salt, + iterations: ENCRYPTION_CONFIG.keyDerivationIterations, + hash: ENCRYPTION_CONFIG.hashAlgorithm, + }, + baseKey, + { + name: ENCRYPTION_CONFIG.algorithm, + length: ENCRYPTION_CONFIG.keyLength, + }, + false, + ['encrypt', 'decrypt'], + ); + } catch (error) { + throw new Error(CRYPTO_ERROR_MESSAGE); + } +}; + +export const encryptPayload = async (data, origin, encryptionKey) => { + try { + const salt = crypto.getRandomValues(new Uint8Array(ENCRYPTION_CONFIG.saltLength)); + const iv = crypto.getRandomValues(new Uint8Array(ENCRYPTION_CONFIG.ivLength)); + + const sharedSecret = getSharedSecret(encryptionKey); + const key = await deriveKey(sharedSecret, salt); + + const plaintext = new TextEncoder().encode(JSON.stringify(data)); + + const additionalData = new TextEncoder().encode(origin); + const encrypted = await crypto.subtle.encrypt( + { + name: ENCRYPTION_CONFIG.algorithm, + iv, + additionalData, + tagLength: ENCRYPTION_CONFIG.tagLength, + }, + key, + plaintext, + ); + + return { + encrypted: Array.from(new Uint8Array(encrypted)), + salt: Array.from(salt), + iv: Array.from(iv), + algorithm: ENCRYPTION_CONFIG.algorithm, + timestamp: Date.now(), + }; + } catch (error) { + throw new Error(CRYPTO_ERROR_MESSAGE); + } +}; + +export const decryptPayload = async (encryptedPayload, origin, encryptionKey) => { + try { + const { encrypted, salt, iv, algorithm, timestamp } = encryptedPayload; + + if (algorithm !== ENCRYPTION_CONFIG.algorithm) { + throw new Error(CRYPTO_ERROR_MESSAGE); + } + + const maxAge = 5 * 60 * 1000; + if (Date.now() - timestamp > maxAge) { + throw new Error(CRYPTO_ERROR_MESSAGE); + } + + const encryptedData = new Uint8Array(encrypted); + const saltBytes = new Uint8Array(salt); + const ivBytes = new Uint8Array(iv); + + const sharedSecret = getSharedSecret(encryptionKey); + const key = await deriveKey(sharedSecret, saltBytes); + + const additionalData = new TextEncoder().encode(origin); + + const decrypted = await crypto.subtle.decrypt( + { + name: ENCRYPTION_CONFIG.algorithm, + iv: ivBytes, + additionalData, + tagLength: ENCRYPTION_CONFIG.tagLength, + }, + key, + encryptedData, + ); + + const plaintext = new TextDecoder().decode(decrypted); + return JSON.parse(plaintext); + } catch (error) { + throw new Error(CRYPTO_ERROR_MESSAGE); + } +}; + +export const isCryptoSupported = () => { + try { + return Boolean( + window.crypto && + window.crypto.subtle && + window.crypto.getRandomValues && + typeof crypto.subtle.encrypt === 'function' && + typeof crypto.subtle.decrypt === 'function' && + typeof crypto.subtle.deriveKey === 'function', + ); + } catch { + return false; + } +}; + +export const generateSecureRandom = (length = 32) => { + const array = new Uint8Array(length); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); +}; diff --git a/locale/gitlab.pot b/locale/gitlab.pot index bffbc7381cead2..43e73389223808 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -43125,6 +43125,12 @@ msgstr "" msgid "Observability|Are you sure you want to delete the observability service settings? This action cannot be undone." msgstr "" +msgid "Observability|Authentication failed. Please refresh the page." +msgstr "" + +msgid "Observability|Authentication timeout" +msgstr "" + msgid "Observability|Configure your observability service connection settings." msgstr "" @@ -43134,6 +43140,9 @@ msgstr "" msgid "Observability|Creates alerts automatically for Observability-related errors." msgstr "" +msgid "Observability|Crypto operation failed" +msgstr "" + msgid "Observability|Danger Zone" msgstr "" @@ -43152,9 +43161,18 @@ msgstr "" msgid "Observability|Encryption Key" msgstr "" +msgid "Observability|Encryption key is required for secure operation" +msgstr "" + +msgid "Observability|Encryption not supported in this browser" +msgstr "" + msgid "Observability|Failed to delete observability service settings." msgstr "" +msgid "Observability|Failed to encrypt authentication data" +msgstr "" + msgid "Observability|Failed to load observability usage data." msgstr "" @@ -43197,10 +43215,13 @@ msgstr "" msgid "Observability|Last 7 days" msgstr "" -msgid "Observability|O11y Service Settings" +msgid "Observability|Loading metrics..." +msgstr "" + +msgid "Observability|Missing authentication tokens." msgstr "" -msgid "Observability|Observability Dashboard" +msgid "Observability|O11y Service Settings" msgstr "" msgid "Observability|Observability Service Configuration" diff --git a/spec/frontend/observability/components/app_spec.js b/spec/frontend/observability/components/app_spec.js index 48ff99615845d3..1b76556d212d3c 100644 --- a/spec/frontend/observability/components/app_spec.js +++ b/spec/frontend/observability/components/app_spec.js @@ -1,58 +1,661 @@ import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import App from '~/observability/components/app.vue'; +const TEST_CONSTANTS = { + URLS: { + DEFAULT_O11Y: 'https://o11y.gitlab.com', + CUSTOM_O11Y: 'https://custom.o11y.com', + WRONG_ORIGIN: 'https://wrong-origin.com', + }, + PATHS: { + TRACES: 'traces-explorer', + DASHBOARD: '/dashboard', + LOGS: 'logs/logs-explorer', + }, + TOKENS: { + accessJwt: 'accessToken', + refreshJwt: 'refreshToken', + userId: 'userId', + }, + ENCRYPTION_KEY: 'test-encryption-key', + TITLE: 'Observability', + NONCE_PATTERN: /^[0-9a-f]{32}$/, +}; + +const createAuthTokens = (overrides = {}) => ({ + ...TEST_CONSTANTS.TOKENS, + ...overrides, +}); + +const createMessageData = (overrides = {}) => ({ + type: 'O11Y_AUTH_STATUS', + timestamp: Date.now() + 1, + nonce: null, + counter: 1, + authenticated: true, + ...overrides, +}); + +const createUrlTestCase = (o11yUrl, path, expected) => ({ + o11yUrl, + path, + expected, +}); + +const createMockCrypto = () => { + let callCount = 0; + return { + getRandomValues: jest.fn((array) => { + const values = Array.from( + { length: array.length }, + (_, i) => (i + callCount * 32 + Math.floor(Math.random() * 16)) % 256, + ); + array.set(values); + callCount += 1; + return array; + }), + subtle: { + encrypt: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4])), + decrypt: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4])), + deriveKey: jest.fn().mockResolvedValue({}), + importKey: jest.fn().mockResolvedValue({}), + }, + }; +}; + +const createMockEncryption = () => ({ + encryptPayload: jest.fn().mockResolvedValue({ + encrypted: [1, 2, 3, 4], + salt: [5, 6, 7, 8], + iv: [9, 10, 11, 12], + algorithm: 'AES-GCM', + timestamp: Date.now(), + }), + isCryptoSupported: jest.fn().mockReturnValue(true), +}); + +jest.mock('~/observability/utils/crypto', () => createMockEncryption()); + describe('Observability App Component', () => { let wrapper; + let originalCrypto; + let mockCrypto; - const createComponent = (props = {}) => { + const createComponent = (propsOverrides = {}, dataOverrides = {}) => { return shallowMount(App, { propsData: { - o11yUrl: 'https://o11y.gitlab.com', - path: 'traces-explorer', - ...props, + o11yUrl: TEST_CONSTANTS.URLS.DEFAULT_O11Y, + path: TEST_CONSTANTS.PATHS.TRACES, + authTokens: createAuthTokens(), + title: TEST_CONSTANTS.TITLE, + encryptionKey: TEST_CONSTANTS.ENCRYPTION_KEY, + ...propsOverrides, + }, + data() { + return { + encryptionEnabled: false, + ...dataOverrides, + }; }, }); }; - it('renders the iframe with correct attributes', () => { - wrapper = createComponent(); + const setupIframeWithContentWindow = (wrapperInstance = wrapper) => { + const iframe = wrapperInstance.find('iframe').element; + const contentWindow = { + postMessage: jest.fn(), + }; + + Object.defineProperty(iframe, 'contentWindow', { + value: contentWindow, + writable: true, + }); + + Object.defineProperty(iframe, 'style', { + value: { + display: 'none', + }, + writable: true, + }); + + iframe.addEventListener = jest.fn(); + + return { iframe, contentWindow }; + }; + + const triggerIframeLoad = (wrapperInstance = wrapper) => { + const { iframe, contentWindow } = setupIframeWithContentWindow(wrapperInstance); + iframe.dispatchEvent(new Event('load')); + return { iframe, contentWindow }; + }; + + const sendAuthMessage = ( + dataOverrides = {}, + origin = TEST_CONSTANTS.URLS.DEFAULT_O11Y, + skipSetup = false, + ) => { + if (!skipSetup) { + if (!wrapper.vm.messageNonce) { + wrapper.vm.messageNonce = wrapper.vm.generateNonce(); + } + if (!wrapper.vm.expectedResponseCounter) { + wrapper.vm.expectedResponseCounter = 1; + } + if (!wrapper.vm.lastMessageTimestamp) { + wrapper.vm.lastMessageTimestamp = Date.now() - 1000; + } + } + + const messageData = createMessageData({ + nonce: wrapper.vm.messageNonce, + counter: wrapper.vm.expectedResponseCounter, + timestamp: Date.now() + 1, + ...dataOverrides, + }); + + const messageEvent = new MessageEvent('message', { + data: messageData, + origin, + }); + + window.dispatchEvent(messageEvent); + return messageData; + }; + + const setupValidMessageContext = () => { + wrapper.vm.messageNonce = wrapper.vm.generateNonce(); + wrapper.vm.expectedResponseCounter = 1; + wrapper.vm.lastMessageTimestamp = Date.now() - 1000; + }; + + const expectIframeAttributes = (iframe, expectedAttributes = {}) => { + const defaultAttributes = { + title: TEST_CONSTANTS.TITLE, + frameborder: '0', + sandbox: 'allow-same-origin allow-scripts allow-forms allow-downloads', + }; + + const attributes = { ...defaultAttributes, ...expectedAttributes }; + + Object.entries(attributes).forEach(([key, value]) => { + expect(iframe.attributes(key)).toBe(value); + }); + }; + + const expectIframeClasses = (iframe, expectedClasses = ['gl-h-full', 'gl-w-full']) => { + expectedClasses.forEach((className) => { + expect(iframe.classes()).toContain(className); + }); + }; - const iframe = wrapper.find('iframe'); - expect(iframe.exists()).toBe(true); - expect(iframe.attributes('title')).toBe('Observability Dashboard'); - expect(iframe.attributes('frameborder')).toBe('0'); - expect(iframe.classes()).toContain('gl-h-full'); - expect(iframe.classes()).toContain('gl-w-full'); + beforeEach(() => { + jest.useFakeTimers({ legacyFakeTimers: true }); + + mockCrypto = createMockCrypto(); + originalCrypto = global.crypto; + global.crypto = mockCrypto; + Object.defineProperty(window, 'crypto', { + value: mockCrypto, + writable: true, + }); }); - it('computes the correct iframe URL', () => { - wrapper = createComponent({ - o11yUrl: 'https://o11y.gitlab.com', - path: 'traces-explorer', + afterEach(() => { + jest.clearAllTimers(); + + global.crypto = originalCrypto; + Object.defineProperty(window, 'crypto', { + value: originalCrypto, + writable: true, }); - expect(wrapper.find('iframe').attributes('src')).toBe( - 'https://o11y.gitlab.com/traces-explorer', + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('Component Rendering', () => { + it('renders the iframe with correct attributes and classes', () => { + wrapper = createComponent(); + const iframe = wrapper.find('iframe'); + + expect(iframe.exists()).toBe(true); + expectIframeAttributes(iframe); + expectIframeClasses(iframe); + }); + + it('displays loading state initially', async () => { + wrapper = createComponent(); + await nextTick(); + + const statusElement = wrapper.find('.o11y-status'); + expect(statusElement.exists()).toBe(true); + expect(statusElement.text()).toBe('Loading metrics...'); + }); + + it('displays only one status message at a time', () => { + wrapper = createComponent(); + expect(wrapper.findAll('.o11y-status')).toHaveLength(1); + + wrapper.vm.isLoading = false; + wrapper.vm.isAuthenticated = false; + expect(wrapper.findAll('.o11y-status')).toHaveLength(1); + }); + }); + + describe('URL Construction', () => { + const urlTestCases = [ + createUrlTestCase( + 'https://o11y.gitlab.com', + 'traces-explorer', + 'https://o11y.gitlab.com/traces-explorer', + ), + createUrlTestCase( + 'https://o11y.gitlab.com', + '/dashboard', + 'https://o11y.gitlab.com/dashboard', + ), + createUrlTestCase( + 'https://o11y.gitlab.com', + 'logs/logs-explorer', + 'https://o11y.gitlab.com/logs/logs-explorer', + ), + createUrlTestCase('https://o11y.gitlab.com/', 'test', 'https://o11y.gitlab.com/test'), + createUrlTestCase('https://o11y.gitlab.com', '/test', 'https://o11y.gitlab.com/test'), + createUrlTestCase('https://o11y.gitlab.com/', '/test/', 'https://o11y.gitlab.com/test/'), + createUrlTestCase('https://o11y.gitlab.com', 'a/b/c', 'https://o11y.gitlab.com/a/b/c'), + ]; + + it.each(urlTestCases)( + 'constructs correct URL for o11yUrl: $o11yUrl and path: $path', + ({ o11yUrl, path, expected }) => { + wrapper = createComponent({ o11yUrl, path }); + + expect(wrapper.vm.iframeUrl).toBe(expected); + expect(wrapper.find('iframe').attributes('src')).toBe(expected); + }, ); }); - it('handles paths with leading slashes correctly', () => { - wrapper = createComponent({ - o11yUrl: 'https://o11y.gitlab.com', - path: '/dashboard', + describe('Component Initialization', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('generates a valid nonce when called', () => { + const nonce = wrapper.vm.generateNonce(); + expect(nonce).toMatch(TEST_CONSTANTS.NONCE_PATTERN); + expect(nonce).toHaveLength(32); + expect(mockCrypto.getRandomValues).toHaveBeenCalled(); + }); + + it('generates unique nonces each time', () => { + const testWrapper = createComponent(); + + const nonce1 = testWrapper.vm.generateNonce(); + const nonce2 = testWrapper.vm.generateNonce(); + + expect(nonce1).not.toBe(nonce2); + expect(nonce1).toMatch(TEST_CONSTANTS.NONCE_PATTERN); + expect(nonce2).toMatch(TEST_CONSTANTS.NONCE_PATTERN); + expect(nonce1).not.toBe('00000000000000000000000000000000'); + expect(nonce2).not.toBe('00000000000000000000000000000000'); + + testWrapper.destroy(); + }); + + it('sets the allowed origin from o11yUrl', () => { + wrapper.destroy(); + wrapper = createComponent({ o11yUrl: 'https://custom.o11y.com/path' }); + + expect(wrapper.vm.allowedOrigin).toBe('https://custom.o11y.com'); + }); + + it('initializes data properties correctly', () => { + const expectedDefaults = { + isLoading: true, + isAuthenticated: false, + messageTimeout: null, + maxRetries: 3, + retryCount: 0, + baseRetryDelay: 2000, + messageCounter: 0, + expectedResponseCounter: null, + maxMessageAge: 60000, + allowedMessageType: 'O11Y_AUTH_STATUS', + }; + + Object.entries(expectedDefaults).forEach(([key, value]) => { + expect(wrapper.vm[key]).toBe(value); + }); + }); + }); + + describe('Authentication Flow', () => { + describe('Initial Authentication', () => { + it('sends auth tokens to iframe after load and displays iframe', async () => { + wrapper = createComponent(); + const { iframe, contentWindow } = triggerIframeLoad(); + + await nextTick(); + + expect(iframe.style.display).toBe('block'); + expect(contentWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'O11Y_JWT_LOGIN', + payload: expect.objectContaining({ + ...TEST_CONSTANTS.TOKENS, + nonce: wrapper.vm.messageNonce, + timestamp: expect.any(Number), + counter: 1, + }), + encrypted: false, + encryptionKey: TEST_CONSTANTS.ENCRYPTION_KEY, + parentOrigin: expect.any(String), + }), + TEST_CONSTANTS.URLS.DEFAULT_O11Y, + ); + }); + + it('handles successful authentication', async () => { + wrapper = createComponent(); + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + wrapper.vm.messageTimeout = 123; + + sendAuthMessage({ authenticated: true }); + await nextTick(); + + expect(wrapper.vm.isLoading).toBe(false); + expect(wrapper.vm.isAuthenticated).toBe(true); + expect(wrapper.vm.messageNonce).toBeNull(); + expect(clearTimeoutSpy).toHaveBeenCalledWith(123); + expect(wrapper.vm.messageTimeout).toBeNull(); + }); + + it('handles authentication failure', async () => { + wrapper = createComponent(); + const iframe = wrapper.find('iframe').element; + + sendAuthMessage({ authenticated: false }); + await nextTick(); + + expect(wrapper.vm.isLoading).toBe(false); + expect(wrapper.vm.isAuthenticated).toBe(false); + expect(wrapper.emitted('authentication-error')).toBeDefined(); + expect(wrapper.emitted('authentication-error')[0]).toEqual([ + { message: 'Authentication failed. Please refresh the page.' }, + ]); + expect(iframe.style.display).toBe('none'); + expect(wrapper.find('.o11y-status').text()).toBe( + 'Authentication failed. Please refresh the page.', + ); + }); }); - expect(wrapper.find('iframe').attributes('src')).toBe('https://o11y.gitlab.com/dashboard'); + describe('Retry Mechanism', () => { + afterEach(() => { + if (wrapper?.vm?.messageTimeout) { + clearTimeout(wrapper.vm.messageTimeout); + wrapper.vm.messageTimeout = null; + } + }); + + it('retries authentication on timeout with incremented counter', async () => { + wrapper = createComponent(); + const { contentWindow } = triggerIframeLoad(); + + await nextTick(); + expect(contentWindow.postMessage).toHaveBeenCalledTimes(1); + expect(wrapper.vm.messageCounter).toBe(1); + + jest.advanceTimersByTime(2000); + await nextTick(); + + expect(contentWindow.postMessage).toHaveBeenCalledTimes(2); + expect(wrapper.vm.messageCounter).toBe(2); + expect(wrapper.vm.expectedResponseCounter).toBe(2); + }); + + it('stops retrying after max retries and emits error', async () => { + wrapper = createComponent(); + triggerIframeLoad(); + + await nextTick(); + + jest.advanceTimersByTime(2000); + await nextTick(); + jest.advanceTimersByTime(4000); + await nextTick(); + jest.advanceTimersByTime(8000); + await nextTick(); + jest.advanceTimersByTime(16000); + await nextTick(); + + expect(wrapper.emitted('authentication-error')).toBeDefined(); + expect(wrapper.emitted('authentication-error')[0]).toEqual([ + { message: 'Authentication timeout' }, + ]); + expect(wrapper.vm.isLoading).toBe(false); + expect(wrapper.vm.isAuthenticated).toBe(false); + }); + + it('stops retrying after successful authentication', async () => { + wrapper = createComponent(); + const { contentWindow } = triggerIframeLoad(); + + await nextTick(); + expect(contentWindow.postMessage).toHaveBeenCalledTimes(1); + + const lastTimestamp = wrapper.vm.lastMessageTimestamp; + contentWindow.postMessage.mockClear(); + + sendAuthMessage({ + authenticated: true, + timestamp: lastTimestamp + 10, + }); + + await nextTick(); + + expect(wrapper.vm.isAuthenticated).toBe(true); + expect(wrapper.vm.messageTimeout).toBeNull(); + + jest.advanceTimersByTime(2000); + await nextTick(); + expect(contentWindow.postMessage).not.toHaveBeenCalled(); + }); + }); }); - it('handles nested paths correctly', () => { - wrapper = createComponent({ - o11yUrl: 'https://o11y.gitlab.com', - path: 'logs/logs-explorer', + describe('Message Validation', () => { + beforeEach(() => { + wrapper = createComponent(); }); - expect(wrapper.find('iframe').attributes('src')).toBe( - 'https://o11y.gitlab.com/logs/logs-explorer', - ); + describe('Valid Messages', () => { + it('updates lastMessageTimestamp during validation', () => { + const messageTime = Date.now() + 1; + setupValidMessageContext(); + + const result = wrapper.vm.isInvalidMessageTimestamp(messageTime); + + expect(result).toBe(false); + expect(wrapper.vm.lastMessageTimestamp).toBe(messageTime); + }); + + it('prevents race condition by updating timestamp atomically', () => { + const baseTime = Date.now(); + const earlierTime = baseTime + 100; + const laterTime = baseTime + 200; + + setupValidMessageContext(); + wrapper.vm.lastMessageTimestamp = 0; + + const result1 = wrapper.vm.isInvalidMessageTimestamp(laterTime); + expect(result1).toBe(false); + expect(wrapper.vm.lastMessageTimestamp).toBe(laterTime); + + const result2 = wrapper.vm.isInvalidMessageTimestamp(earlierTime); + expect(result2).toBe(true); + expect(wrapper.vm.lastMessageTimestamp).toBe(laterTime); + }); + }); + + describe('Invalid Messages', () => { + const invalidMessageTestCases = [ + { name: 'null data', data: null }, + { name: 'string data', data: 'string' }, + { name: 'invalid type', data: { type: 123 } }, + { name: 'missing timestamp', data: { type: 'O11Y_AUTH_STATUS' } }, + { + name: 'invalid timestamp', + data: { type: 'O11Y_AUTH_STATUS', timestamp: 'not-a-number' }, + }, + { name: 'missing counter', data: { type: 'O11Y_AUTH_STATUS', timestamp: Date.now() } }, + { + name: 'invalid counter', + data: { type: 'O11Y_AUTH_STATUS', timestamp: Date.now(), counter: 'not-a-number' }, + }, + ]; + + it.each(invalidMessageTestCases)('ignores message with $name', ({ data }) => { + const messageEvent = new MessageEvent('message', { + data, + origin: TEST_CONSTANTS.URLS.DEFAULT_O11Y, + }); + + window.dispatchEvent(messageEvent); + + expect(wrapper.vm.isLoading).toBe(true); + expect(wrapper.vm.isAuthenticated).toBe(false); + }); + + it('ignores messages from wrong origin', () => { + const originalLoading = wrapper.vm.isLoading; + + sendAuthMessage({ authenticated: true }, TEST_CONSTANTS.URLS.WRONG_ORIGIN); + + expect(wrapper.vm.isLoading).toBe(originalLoading); + expect(wrapper.vm.isAuthenticated).toBe(false); + }); + + it('ignores messages with wrong nonce', () => { + wrapper.vm.isLoading = true; + wrapper.vm.isAuthenticated = false; + setupValidMessageContext(); + + sendAuthMessage({ nonce: 'wrong-nonce' }, TEST_CONSTANTS.URLS.DEFAULT_O11Y, true); + + expect(wrapper.vm.isLoading).toBe(true); + expect(wrapper.vm.isAuthenticated).toBe(false); + }); + + it('ignores messages with wrong counter', () => { + wrapper.vm.isLoading = true; + wrapper.vm.isAuthenticated = false; + setupValidMessageContext(); + wrapper.vm.expectedResponseCounter = 5; + + sendAuthMessage({ counter: 3 }, TEST_CONSTANTS.URLS.DEFAULT_O11Y, true); + + expect(wrapper.vm.isLoading).toBe(true); + expect(wrapper.vm.isAuthenticated).toBe(false); + }); + + it('ignores messages with old timestamps', () => { + wrapper.vm.isLoading = true; + wrapper.vm.isAuthenticated = false; + setupValidMessageContext(); + + const oldTimestamp = Date.now() - 2 * 60 * 1000; + sendAuthMessage({ timestamp: oldTimestamp }, TEST_CONSTANTS.URLS.DEFAULT_O11Y, true); + + expect(wrapper.vm.isLoading).toBe(true); + expect(wrapper.vm.isAuthenticated).toBe(false); + }); + + it('ignores messages with timestamp before last message', () => { + sendAuthMessage({ authenticated: true }); + const firstTimestamp = wrapper.vm.lastMessageTimestamp; + + wrapper.vm.isAuthenticated = false; + wrapper.vm.isLoading = true; + wrapper.vm.messageNonce = wrapper.vm.generateNonce(); + wrapper.vm.expectedResponseCounter = 2; + + sendAuthMessage( + { timestamp: firstTimestamp - 1, counter: 2 }, + TEST_CONSTANTS.URLS.DEFAULT_O11Y, + true, + ); + + expect(wrapper.vm.isLoading).toBe(true); + expect(wrapper.vm.isAuthenticated).toBe(false); + }); + }); + }); + + describe('Error Handling & Edge Cases', () => { + it('handles missing iframe contentWindow gracefully', () => { + wrapper = createComponent(); + const iframe = wrapper.find('iframe').element; + + Object.defineProperty(iframe, 'contentWindow', { + value: null, + writable: true, + }); + + expect(() => { + wrapper.vm.sendAuthMessage(); + }).not.toThrow(); + }); + + it('handles missing iframe ref gracefully', () => { + wrapper = createComponent(); + const originalSendAuthMessage = wrapper.vm.sendAuthMessage; + + wrapper.vm.sendAuthMessage = jest.fn().mockImplementation(originalSendAuthMessage); + + expect(() => { + wrapper.vm.sendAuthMessage(); + }).not.toThrow(); + }); + }); + + describe('Component Cleanup', () => { + it('properly cleans up event listeners and timeouts on unmount', () => { + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + + wrapper = createComponent(); + const boundHandleMessage = wrapper.vm.handleMessage; + wrapper.vm.messageTimeout = 123; + + const beforeUnmountLogic = () => { + if (wrapper.vm.messageTimeout) { + clearTimeout(wrapper.vm.messageTimeout); + } + window.removeEventListener('message', wrapper.vm.handleMessage); + }; + + beforeUnmountLogic(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('message', boundHandleMessage); + expect(clearTimeoutSpy).toHaveBeenCalledWith(123); + + removeEventListenerSpy.mockRestore(); + clearTimeoutSpy.mockRestore(); + }); + + it('has proper cleanup logic defined', () => { + wrapper = createComponent(); + + expect(wrapper.vm.handleMessage).toBeDefined(); + expect(typeof wrapper.vm.handleMessage).toBe('function'); + + const hasCleanupHook = wrapper.vm.$options.beforeUnmount || wrapper.vm.$options.beforeDestroy; + expect(hasCleanupHook).toBeDefined(); + }); }); }); diff --git a/spec/frontend/observability/utils/crypto_spec.js b/spec/frontend/observability/utils/crypto_spec.js new file mode 100644 index 00000000000000..7e67fa749e450c --- /dev/null +++ b/spec/frontend/observability/utils/crypto_spec.js @@ -0,0 +1,253 @@ +/** + * Test suite for cross-frame authentication encryption + * + * These tests validate the security implementation across both Vue and React + * applications, ensuring encryption/decryption works correctly and securely. + */ + +import { + encryptPayload, + decryptPayload, + isCryptoSupported, + generateSecureRandom, +} from '../../../../app/assets/javascripts/observability/utils/crypto'; + +describe('Cross-Frame Authentication Encryption', () => { + const testOrigin = 'https://test-origin.com'; + const testEncryptionKey = 'test-encryption-key-for-unit-tests-only'; + const testPayload = { + userId: 'test-user-123', + accessJwt: 'fake.jwt.token', + refreshJwt: 'fake.refresh.jwt.refresh', + theme: 'dark', + nonce: 'test-nonce-123456789abcdef', + timestamp: Date.now(), + counter: 1, + }; + + beforeAll(async () => { + if (!global.crypto) { + const { webcrypto } = await import('crypto'); + global.crypto = webcrypto; + } + + if (!global.crypto.subtle) { + const { webcrypto } = await import('crypto'); + global.crypto.subtle = webcrypto.subtle; + } + }); + + describe('Crypto Feature Detection', () => { + it('should detect crypto API support', () => { + expect(isCryptoSupported()).toBe(true); + }); + + it('should handle missing crypto API gracefully', () => { + const originalCrypto = global.crypto; + const originalSubtle = global.crypto?.subtle; + + delete global.crypto; + + expect(isCryptoSupported()).toBe(false); + + global.crypto = originalCrypto; + if (originalCrypto && originalSubtle) { + global.crypto.subtle = originalSubtle; + } + }); + }); + + describe('Encryption/Decryption Workflow', () => { + it('should encrypt and decrypt payload successfully', async () => { + const encrypted = await encryptPayload(testPayload, testOrigin, testEncryptionKey); + + expect(encrypted).toHaveProperty('encrypted'); + expect(encrypted).toHaveProperty('salt'); + expect(encrypted).toHaveProperty('iv'); + expect(encrypted).toHaveProperty('algorithm'); + expect(encrypted).toHaveProperty('timestamp'); + + expect(Array.isArray(encrypted.encrypted)).toBe(true); + expect(Array.isArray(encrypted.salt)).toBe(true); + expect(Array.isArray(encrypted.iv)).toBe(true); + expect(encrypted.algorithm).toBe('AES-GCM'); + + const decrypted = await decryptPayload(encrypted, testOrigin, testEncryptionKey); + expect(decrypted).toEqual(testPayload); + }); + + it('should fail decryption with wrong origin', async () => { + const encrypted = await encryptPayload(testPayload, testOrigin, testEncryptionKey); + + await expect( + decryptPayload(encrypted, 'https://wrong-origin.com', testEncryptionKey), + ).rejects.toThrow('Crypto operation failed'); + }); + + it('should fail decryption with tampered data', async () => { + const encrypted = await encryptPayload(testPayload, testOrigin, testEncryptionKey); + + encrypted.encrypted[0] = (encrypted.encrypted[0] + 1) % 256; + + await expect(decryptPayload(encrypted, testOrigin, testEncryptionKey)).rejects.toThrow( + 'Crypto operation failed', + ); + }); + + it('should fail decryption with old timestamp', async () => { + const encrypted = await encryptPayload(testPayload, testOrigin, testEncryptionKey); + + encrypted.timestamp = Date.now() - 10 * 60 * 1000; + + await expect(decryptPayload(encrypted, testOrigin, testEncryptionKey)).rejects.toThrow( + 'Crypto operation failed', + ); + }); + }); + + describe('Security Validations', () => { + it('should generate unique nonces', () => { + const nonce1 = generateSecureRandom(32); + const nonce2 = generateSecureRandom(32); + + expect(nonce1).not.toBe(nonce2); + expect(nonce1).toHaveLength(64); + expect(nonce2).toHaveLength(64); + }); + + it('should handle different payload sizes', async () => { + const smallPayload = { test: 'small' }; + const largePayload = { + test: 'large', + data: 'x'.repeat(10000), + nested: { + deep: { + structure: { + with: ['arrays', 'and', 'objects'], + numbers: [1, 2, 3, 4, 5], + }, + }, + }, + }; + + const encryptedSmall = await encryptPayload(smallPayload, testOrigin, testEncryptionKey); + const encryptedLarge = await encryptPayload(largePayload, testOrigin, testEncryptionKey); + + const decryptedSmall = await decryptPayload(encryptedSmall, testOrigin, testEncryptionKey); + const decryptedLarge = await decryptPayload(encryptedLarge, testOrigin, testEncryptionKey); + + expect(decryptedSmall).toEqual(smallPayload); + expect(decryptedLarge).toEqual(largePayload); + }); + + it('should handle special characters and unicode', async () => { + const unicodePayload = { + emoji: '🔐🛡️🔑', + chinese: '加密数据', + arabic: 'البيانات المشفرة', + special: '!@#$%^&*()_+-=[]{}|;:,.<>?', + quotes: '"\'`', + }; + + const encrypted = await encryptPayload(unicodePayload, testOrigin, testEncryptionKey); + const decrypted = await decryptPayload(encrypted, testOrigin, testEncryptionKey); + + expect(decrypted).toEqual(unicodePayload); + }); + }); + + describe('Error Handling', () => { + it('should handle invalid algorithm gracefully', async () => { + const encrypted = await encryptPayload(testPayload, testOrigin, testEncryptionKey); + encrypted.algorithm = 'INVALID-ALGORITHM'; + + await expect(decryptPayload(encrypted, testOrigin, testEncryptionKey)).rejects.toThrow( + 'Crypto operation failed', + ); + }); + + it('should handle malformed encrypted payload', async () => { + const malformedPayload = { + encrypted: 'not-an-array', + salt: [], + iv: [], + algorithm: 'AES-GCM', + timestamp: Date.now(), + }; + + await expect(decryptPayload(malformedPayload, testOrigin, testEncryptionKey)).rejects.toThrow( + 'Crypto operation failed', + ); + }); + + it('should handle empty payloads', async () => { + const emptyPayload = {}; + + const encrypted = await encryptPayload(emptyPayload, testOrigin, testEncryptionKey); + const decrypted = await decryptPayload(encrypted, testOrigin, testEncryptionKey); + + expect(decrypted).toEqual(emptyPayload); + }); + }); + + describe('Performance Tests', () => { + it('should encrypt/decrypt within reasonable time limits', async () => { + const startTime = performance.now(); + + const encrypted = await encryptPayload(testPayload, testOrigin, testEncryptionKey); + const encryptionTime = performance.now() - startTime; + + const decryptionStart = performance.now(); + await decryptPayload(encrypted, testOrigin, testEncryptionKey); + const decryptionTime = performance.now() - decryptionStart; + + expect(encryptionTime).toBeLessThan(100); + expect(decryptionTime).toBeLessThan(100); + }); + + it('should handle multiple concurrent operations', async () => { + const operations = Array.from({ length: 10 }, async (_, i) => { + const payload = { ...testPayload, counter: i }; + const encrypted = await encryptPayload(payload, testOrigin, testEncryptionKey); + return decryptPayload(encrypted, testOrigin, testEncryptionKey); + }); + + const results = await Promise.all(operations); + + results.forEach((result, index) => { + expect(result.counter).toBe(index); + }); + }); + }); + + describe('Cross-Application Integration', () => { + it('should simulate full message flow', async () => { + const vueMessage = { + type: 'O11Y_JWT_LOGIN_ENCRYPTED', + payload: await encryptPayload(testPayload, testOrigin, testEncryptionKey), + encrypted: true, + }; + + expect(vueMessage.encrypted).toBe(true); + expect(vueMessage.type).toBe('O11Y_JWT_LOGIN_ENCRYPTED'); + + const decryptedInReact = await decryptPayload( + vueMessage.payload, + testOrigin, + testEncryptionKey, + ); + expect(decryptedInReact).toEqual(testPayload); + + const reactResponse = { + type: 'O11Y_AUTH_STATUS', + authenticated: true, + timestamp: Date.now(), + nonce: testPayload.nonce, + counter: testPayload.counter, + }; + + expect(reactResponse.authenticated).toBe(true); + expect(reactResponse.nonce).toBe(testPayload.nonce); + }); + }); +}); -- GitLab From b244287a56bd9a45e8fd5da8cbfa6ec38995aefc Mon Sep 17 00:00:00 2001 From: dakotadux Date: Fri, 25 Jul 2025 10:15:04 -0600 Subject: [PATCH 2/5] Add messageValidator and specs This validates messages from the observability iframe. --- .../javascripts/observability/constants.js | 15 + .../observability/utils/message_validator.js | 144 +++++++ .../utils/message_validator_spec.js | 386 ++++++++++++++++++ 3 files changed, 545 insertions(+) create mode 100644 app/assets/javascripts/observability/utils/message_validator.js create mode 100644 spec/frontend/observability/utils/message_validator_spec.js diff --git a/app/assets/javascripts/observability/constants.js b/app/assets/javascripts/observability/constants.js index a5210991ee6b23..7a75b1740a52ab 100644 --- a/app/assets/javascripts/observability/constants.js +++ b/app/assets/javascripts/observability/constants.js @@ -57,3 +57,18 @@ export const FULL_DATE_TIME_FORMAT = `mmm dd yyyy HH:MM:ss.l Z`; export const SHORT_DATE_TIME_FORMAT = `mmm dd yyyy HH:MM:ss Z`; export const ISSUE_PATH_ID_SEPARATOR = '#'; + + +export const MESSAGE_TYPES = { + // eslint-disable-next-line @gitlab/require-i18n-strings + AUTH_STATUS: 'O11Y_AUTH_STATUS', + // eslint-disable-next-line @gitlab/require-i18n-strings + JWT_LOGIN: 'O11Y_JWT_LOGIN', + // eslint-disable-next-line @gitlab/require-i18n-strings + JWT_LOGIN_ENCRYPTED: 'O11Y_JWT_LOGIN_ENCRYPTED', +}; +export const TIMEOUTS = { + MAX_MESSAGE_AGE: 1 * 60 * 1000, + MAX_CLOCK_SKEW: 30 * 1000, + BASE_RETRY_DELAY: 2000, +}; diff --git a/app/assets/javascripts/observability/utils/message_validator.js b/app/assets/javascripts/observability/utils/message_validator.js new file mode 100644 index 00000000000000..798a3f40951e86 --- /dev/null +++ b/app/assets/javascripts/observability/utils/message_validator.js @@ -0,0 +1,144 @@ +import { s__ } from '~/locale'; + +import { MESSAGE_TYPES, TIMEOUTS } from '../constants'; + +export class MessageValidator { + constructor(allowedOrigin, allowedMessageType, { + maxMessageAge = TIMEOUTS.MAX_MESSAGE_AGE, + maxClockSkew = TIMEOUTS.MAX_CLOCK_SKEW, + maxNonceHistory = 1000 + } = {}) { + if (!allowedOrigin || typeof allowedOrigin !== 'string') { + throw new Error('allowedOrigin must be a non-empty string'); + } + if (!allowedMessageType || typeof allowedMessageType !== 'string') { + throw new Error('allowedMessageType must be a non-empty string'); + } + + this.allowedOrigin = allowedOrigin; + this.allowedMessageType = allowedMessageType; + this.maxMessageAge = maxMessageAge; + this.maxClockSkew = maxClockSkew; + this.maxNonceHistory = maxNonceHistory; + this.lastMessageTimestamp = 0; + this.processedNonces = new Set(); + } + + validateMessage(event, expectedNonce, expectedCounter) { + const { origin, data } = event; + + if (!this.validateOrigin(origin) || + !this.validateStructure(data) || + !this.validateType(data.type) || + !this.validateNonce(data.nonce, expectedNonce) || + !this.validateCounter(data.counter, expectedCounter) || + !this.validateTimestamp(data.timestamp)) { + return { valid: false, error: s__('Observability|Message validation failed') }; + } + + this.trackNonce(data.nonce); + return { valid: true }; + } + + validateOrigin(origin) { + return origin === this.allowedOrigin; + } + + // eslint-disable-next-line class-methods-use-this + validateStructure(data) { + if (!data || typeof data !== 'object') { + return false; + } + + const requiredFields = [ + { field: 'type', type: 'string' }, + { field: 'timestamp', type: 'number' }, + { field: 'nonce', type: 'string' }, + { field: 'counter', type: 'number' } + ]; + + for (const { field, type } of requiredFields) { + if (typeof data[field] !== type) { + return false; + } + } + + if (data.nonce.length === 0) { + return false; + } + + if (data.counter < 0) { + return false; + } + + return true; + } + + validateType(type) { + return type === this.allowedMessageType; + } + + validateNonce(nonce, expectedNonce) { + if (nonce !== expectedNonce) { + return false; + } + + if (this.processedNonces.has(nonce)) { + return false; + } + + return true; + } + + // eslint-disable-next-line class-methods-use-this + validateCounter(counter, expectedCounter) { + return counter === expectedCounter; + } + + validateTimestamp(timestamp) { + const now = Date.now(); + const messageAge = now - timestamp; + + if (messageAge > this.maxMessageAge) { + return false; + } + + if (messageAge < -this.maxClockSkew) { + return false; + } + + if (this.lastMessageTimestamp > 0 && timestamp <= this.lastMessageTimestamp) { + return false; + } + + this.lastMessageTimestamp = timestamp; + return true; + } + + trackNonce(nonce) { + this.processedNonces.add(nonce); + + if (this.processedNonces.size > this.maxNonceHistory) { + const noncesToDelete = Array.from(this.processedNonces).slice(0, 100); + noncesToDelete.forEach(n => this.processedNonces.delete(n)); + } + } + + reset() { + this.lastMessageTimestamp = 0; + this.processedNonces.clear(); + } + + getValidationStats() { + return { + lastMessageTimestamp: this.lastMessageTimestamp, + processedNoncesCount: this.processedNonces.size, + allowedOrigin: this.allowedOrigin, + allowedMessageType: this.allowedMessageType + }; + } +} + +export const createMessageValidator = (allowedOrigin, options = {}) => { + return new MessageValidator(allowedOrigin, MESSAGE_TYPES.AUTH_STATUS, options); +}; diff --git a/spec/frontend/observability/utils/message_validator_spec.js b/spec/frontend/observability/utils/message_validator_spec.js new file mode 100644 index 00000000000000..cdc0f625f7fdc2 --- /dev/null +++ b/spec/frontend/observability/utils/message_validator_spec.js @@ -0,0 +1,386 @@ +import { MessageValidator, createMessageValidator } from '~/observability/utils/message_validator'; +import { MESSAGE_TYPES, TIMEOUTS } from '~/observability/constants'; + +jest.mock('~/locale', () => ({ + s__: jest.fn(() => 'Message validation failed'), +})); + +describe('MessageValidator', () => { + let validator; + const allowedOrigin = 'https://trusted-origin.com'; + const allowedMessageType = MESSAGE_TYPES.AUTH_STATUS; + const maxMessageAge = TIMEOUTS.MAX_MESSAGE_AGE; + + beforeEach(() => { + validator = new MessageValidator(allowedOrigin, allowedMessageType, { maxMessageAge }); + jest.spyOn(Date, 'now').mockReturnValue(1000000); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('constructor', () => { + it('initializes with provided values', () => { + expect(validator.allowedOrigin).toBe(allowedOrigin); + expect(validator.allowedMessageType).toBe(allowedMessageType); + expect(validator.maxMessageAge).toBe(maxMessageAge); + expect(validator.lastMessageTimestamp).toBe(0); + }); + + it('uses default maxMessageAge when not provided', () => { + const defaultValidator = new MessageValidator(allowedOrigin, allowedMessageType); + expect(defaultValidator.maxMessageAge).toBe(TIMEOUTS.MAX_MESSAGE_AGE); + }); + }); + + describe('validateMessage', () => { + const createValidEvent = (overrides = {}) => ({ + origin: allowedOrigin, + data: { + type: allowedMessageType, + timestamp: 999000, + nonce: 'valid-nonce', + counter: 1, + ...overrides, + }, + }); + + it('returns valid: true for valid message', () => { + const event = createValidEvent(); + const result = validator.validateMessage(event, 'valid-nonce', 1); + expect(result).toEqual({ valid: true }); + }); + + it('returns valid: false when origin validation fails', () => { + const event = createValidEvent(); + event.origin = 'https://malicious-origin.com'; + const result = validator.validateMessage(event, 'valid-nonce', 1); + expect(result).toEqual({ valid: false, error: 'Message validation failed' }); + }); + + it('returns valid: false when structure validation fails', () => { + const event = { origin: allowedOrigin, data: null }; + const result = validator.validateMessage(event, 'valid-nonce', 1); + expect(result).toEqual({ valid: false, error: 'Message validation failed' }); + }); + + it('returns valid: false when type validation fails', () => { + const event = createValidEvent({ type: 'INVALID_TYPE' }); + const result = validator.validateMessage(event, 'valid-nonce', 1); + expect(result).toEqual({ valid: false, error: 'Message validation failed' }); + }); + + it('returns valid: false when nonce validation fails', () => { + const event = createValidEvent(); + const result = validator.validateMessage(event, 'wrong-nonce', 1); + expect(result).toEqual({ valid: false, error: 'Message validation failed' }); + }); + + it('returns valid: false when counter validation fails', () => { + const event = createValidEvent(); + const result = validator.validateMessage(event, 'valid-nonce', 2); + expect(result).toEqual({ valid: false, error: 'Message validation failed' }); + }); + + it('returns valid: false when timestamp validation fails', () => { + const event = createValidEvent({ timestamp: 1 }); + const result = validator.validateMessage(event, 'valid-nonce', 1); + expect(result).toEqual({ valid: false, error: 'Message validation failed' }); + }); + }); + + describe('validateOrigin', () => { + it('returns true for matching origin', () => { + expect(validator.validateOrigin(allowedOrigin)).toBe(true); + }); + + it('returns false for non-matching origin', () => { + expect(validator.validateOrigin('https://malicious-origin.com')).toBe(false); + }); + + it('returns false for null origin', () => { + expect(validator.validateOrigin(null)).toBe(false); + }); + + it('returns false for undefined origin', () => { + expect(validator.validateOrigin(undefined)).toBe(false); + }); + }); + + describe('validateStructure', () => { + it('returns true for valid data structure', () => { + const validData = { + type: 'string-type', + timestamp: 123456789, + nonce: 'string-nonce', + counter: 42, + }; + expect(validator.validateStructure(validData)).toBe(true); + }); + + it('returns false for null data', () => { + expect(validator.validateStructure(null)).toBe(false); + }); + + it('returns false for undefined data', () => { + expect(validator.validateStructure(undefined)).toBe(false); + }); + + it('returns false for non-object data', () => { + expect(validator.validateStructure('string')).toBe(false); + expect(validator.validateStructure(123)).toBe(false); + expect(validator.validateStructure([])).toBe(false); + }); + + it('returns false when type is not a string', () => { + const invalidData = { + type: 123, + timestamp: 123456789, + nonce: 'string-nonce', + counter: 42, + }; + expect(validator.validateStructure(invalidData)).toBe(false); + }); + + it('returns false when timestamp is not a number', () => { + const invalidData = { + type: 'string-type', + timestamp: '123456789', + nonce: 'string-nonce', + counter: 42, + }; + expect(validator.validateStructure(invalidData)).toBe(false); + }); + + it('returns false when nonce is not a string', () => { + const invalidData = { + type: 'string-type', + timestamp: 123456789, + nonce: 123, + counter: 42, + }; + expect(validator.validateStructure(invalidData)).toBe(false); + }); + + it('returns false when counter is not a number', () => { + const invalidData = { + type: 'string-type', + timestamp: 123456789, + nonce: 'string-nonce', + counter: '42', + }; + expect(validator.validateStructure(invalidData)).toBe(false); + }); + + it('returns false when required fields are missing', () => { + expect(validator.validateStructure({ type: 'test' })).toBe(false); + expect(validator.validateStructure({ timestamp: 123 })).toBe(false); + expect(validator.validateStructure({ nonce: 'test' })).toBe(false); + expect(validator.validateStructure({ counter: 1 })).toBe(false); + }); + }); + + describe('validateType', () => { + it('returns true for matching type', () => { + expect(validator.validateType(allowedMessageType)).toBe(true); + }); + + it('returns false for non-matching type', () => { + expect(validator.validateType('WRONG_TYPE')).toBe(false); + }); + + it('returns false for null type', () => { + expect(validator.validateType(null)).toBe(false); + }); + + it('returns false for undefined type', () => { + expect(validator.validateType(undefined)).toBe(false); + }); + }); + + describe('validateNonce', () => { + it('returns true for matching nonce', () => { + expect(validator.validateNonce('test-nonce', 'test-nonce')).toBe(true); + }); + + it('returns false for non-matching nonce', () => { + expect(validator.validateNonce('nonce1', 'nonce2')).toBe(false); + }); + + it('returns false when nonces are null/undefined', () => { + expect(validator.validateNonce(null, 'test')).toBe(false); + expect(validator.validateNonce('test', null)).toBe(false); + expect(validator.validateNonce(undefined, 'test')).toBe(false); + expect(validator.validateNonce('test', undefined)).toBe(false); + }); + }); + + describe('validateCounter', () => { + it('returns true for matching counter', () => { + expect(validator.validateCounter(42, 42)).toBe(true); + }); + + it('returns false for non-matching counter', () => { + expect(validator.validateCounter(1, 2)).toBe(false); + }); + + it('returns false when counters are null/undefined', () => { + expect(validator.validateCounter(null, 1)).toBe(false); + expect(validator.validateCounter(1, null)).toBe(false); + expect(validator.validateCounter(undefined, 1)).toBe(false); + expect(validator.validateCounter(1, undefined)).toBe(false); + }); + }); + + describe('validateTimestamp', () => { + beforeEach(() => { + validator.reset(); + }); + + it('returns true for recent timestamp', () => { + const recentTimestamp = 999000; + expect(validator.validateTimestamp(recentTimestamp)).toBe(true); + expect(validator.lastMessageTimestamp).toBe(recentTimestamp); + }); + + it('returns false for timestamp older than maxMessageAge', () => { + const oldTimestamp = 1000000 - maxMessageAge - 1000; + expect(validator.validateTimestamp(oldTimestamp)).toBe(false); + }); + + it('returns true for timestamp equal to maxMessageAge boundary', () => { + const boundaryTimestamp = 1000000 - maxMessageAge; + expect(validator.validateTimestamp(boundaryTimestamp)).toBe(true); + }); + + it('returns true for timestamp just within maxMessageAge', () => { + const validTimestamp = 1000000 - maxMessageAge + 1000; + expect(validator.validateTimestamp(validTimestamp)).toBe(true); + }); + + it('returns false for timestamp equal to last processed timestamp', () => { + const timestamp = 999000; + validator.validateTimestamp(timestamp); + expect(validator.validateTimestamp(timestamp)).toBe(false); + }); + + it('returns false for timestamp older than last processed timestamp', () => { + validator.validateTimestamp(999000); + expect(validator.validateTimestamp(998000)).toBe(false); + }); + + it('accepts progressively newer timestamps', () => { + expect(validator.validateTimestamp(995000)).toBe(true); + expect(validator.validateTimestamp(996000)).toBe(true); + expect(validator.validateTimestamp(997000)).toBe(true); + }); + + it('handles edge case where lastMessageTimestamp is 0', () => { + expect(validator.lastMessageTimestamp).toBe(0); + expect(validator.validateTimestamp(999000)).toBe(true); + }); + + it('rejects future timestamps beyond maxMessageAge', () => { + const futureTimestamp = 1000000 + maxMessageAge + 1000; + expect(validator.validateTimestamp(futureTimestamp)).toBe(false); + }); + }); + + describe('reset', () => { + it('resets lastMessageTimestamp to 0', () => { + validator.validateTimestamp(999000); + expect(validator.lastMessageTimestamp).toBe(999000); + + validator.reset(); + expect(validator.lastMessageTimestamp).toBe(0); + }); + + it('allows reprocessing of timestamps after reset', () => { + const timestamp = 999000; + + expect(validator.validateTimestamp(timestamp)).toBe(true); + expect(validator.validateTimestamp(timestamp)).toBe(false); + + validator.reset(); + expect(validator.validateTimestamp(timestamp)).toBe(true); + }); + }); + + describe('createMessageValidator', () => { + it('creates MessageValidator with correct parameters', () => { + const createdValidator = createMessageValidator('https://example.com'); + + expect(createdValidator).toBeInstanceOf(MessageValidator); + expect(createdValidator.allowedOrigin).toBe('https://example.com'); + expect(createdValidator.allowedMessageType).toBe(MESSAGE_TYPES.AUTH_STATUS); + expect(createdValidator.maxMessageAge).toBe(TIMEOUTS.MAX_MESSAGE_AGE); + }); + }); + + describe('integration scenarios', () => { + it('handles complete message validation workflow', () => { + const event1 = { + origin: allowedOrigin, + data: { + type: allowedMessageType, + timestamp: 995000, + nonce: 'nonce-1', + counter: 1, + }, + }; + + const event2 = { + origin: allowedOrigin, + data: { + type: allowedMessageType, + timestamp: 996000, + nonce: 'nonce-2', + counter: 2, + }, + }; + + expect(validator.validateMessage(event1, 'nonce-1', 1)).toEqual({ valid: true }); + + expect(validator.validateMessage(event2, 'nonce-2', 2)).toEqual({ valid: true }); + + expect(validator.validateMessage(event1, 'nonce-1', 1)).toEqual({ valid: false, error: 'Message validation failed' }); + }); + + it('rejects messages with mixed validation failures', () => { + const invalidEvent = { + origin: 'https://malicious-origin.com', + data: { + type: 'WRONG_TYPE', + timestamp: 1, + nonce: 'wrong-nonce', + counter: 999, + }, + }; + + expect(validator.validateMessage(invalidEvent, 'expected-nonce', 1)).toEqual({ valid: false, error: 'Message validation failed' }); + }); + + it('handles validator state across multiple message validations', () => { + const messages = [ + { timestamp: 995000, nonce: 'n1', counter: 1 }, + { timestamp: 996000, nonce: 'n2', counter: 2 }, + { timestamp: 997000, nonce: 'n3', counter: 3 }, + ]; + + messages.forEach((msg) => { + const event = { + origin: allowedOrigin, + data: { + type: allowedMessageType, + ...msg, + }, + }; + + expect(validator.validateMessage(event, msg.nonce, msg.counter)).toEqual({ valid: true }); + }); + + expect(validator.lastMessageTimestamp).toBe(997000); + }); + }); +}); -- GitLab From eb86ef45ca1e4d9d88b754083c754518d3d8bb07 Mon Sep 17 00:00:00 2001 From: dakotadux Date: Sun, 27 Jul 2025 22:47:20 -0600 Subject: [PATCH 3/5] Update app.vue to use message validator This is a refactor of the message validator to use a class-based approach. --- .../observability/components/app.vue | 175 +++++++----------- .../javascripts/observability/constants.js | 8 + .../javascripts/observability/utils/crypto.js | 2 + .../observability/components/app_spec.js | 112 +++++++---- 4 files changed, 148 insertions(+), 149 deletions(-) diff --git a/app/assets/javascripts/observability/components/app.vue b/app/assets/javascripts/observability/components/app.vue index 05b02810d90889..8b1d0901a3ce06 100644 --- a/app/assets/javascripts/observability/components/app.vue +++ b/app/assets/javascripts/observability/components/app.vue @@ -1,6 +1,8 @@