From 1cad8c70125ca2fef1ce0e90f8a8c674dd15ac76 Mon Sep 17 00:00:00 2001 From: dakotadux Date: Tue, 29 Jul 2025 14:43:08 -0600 Subject: [PATCH 1/2] Add observability message validator component This component is used to validate messages sent from the iframed react app to the main vue app. The component is used to validate the message's origin, type, nonce, and counter. The component is also used to validate the message's timestamp. --- .../javascripts/observability/constants.js | 15 + .../observability/utils/message_validator.js | 192 ++++++++ locale/gitlab.pot | 9 + .../utils/message_validator_spec.js | 426 ++++++++++++++++++ 4 files changed, 642 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..d1795dff51676c 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, + CLEANUP_INTERVAL: 60 * 1000, +}; 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..bf9cb6123925f6 --- /dev/null +++ b/app/assets/javascripts/observability/utils/message_validator.js @@ -0,0 +1,192 @@ +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, + timestampTolerance = 5000, + } = {}, + ) { + if (!allowedOrigin || typeof allowedOrigin !== 'string') { + throw new Error(s__('Observability|allowedOrigin must be a non-empty string')); + } + if (!allowedMessageType || typeof allowedMessageType !== 'string') { + throw new Error(s__('Observability|allowedMessageType must be a non-empty string')); + } + + this.allowedOrigin = allowedOrigin; + this.allowedMessageType = allowedMessageType; + this.maxMessageAge = maxMessageAge; + this.maxClockSkew = maxClockSkew; + this.maxNonceHistory = maxNonceHistory; + this.timestampTolerance = timestampTolerance; + this.lastMessageTimestamp = 0; + this.processedNonces = new Set(); + this.recentTimestamps = new Set(); + this.lastCleanup = 0; + } + + 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; + } + + if ( + typeof data.type !== 'string' || + typeof data.timestamp !== 'number' || + typeof data.nonce !== 'string' || + typeof data.counter !== 'number' + ) { + 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.recentTimestamps.has(timestamp)) { + return false; + } + + if (this.lastMessageTimestamp === 0) { + this.lastMessageTimestamp = timestamp; + this.recentTimestamps.add(timestamp); + this.cleanupOldTimestamps(now); + return true; + } + + const toleranceWindow = this.timestampTolerance; + const isWithinTolerance = Math.abs(timestamp - this.lastMessageTimestamp) <= toleranceWindow; + + if (isWithinTolerance) { + if (timestamp > this.lastMessageTimestamp) { + this.lastMessageTimestamp = timestamp; + } + this.recentTimestamps.add(timestamp); + this.cleanupOldTimestamps(now); + return true; + } + + if (timestamp > this.lastMessageTimestamp) { + this.lastMessageTimestamp = timestamp; + this.recentTimestamps.add(timestamp); + this.cleanupOldTimestamps(now); + return true; + } + + return false; + } + + cleanupOldTimestamps(now) { + const cutoffTime = now - this.maxMessageAge; + if (!this.lastCleanup || now - this.lastCleanup > TIMEOUTS.CLEANUP_INTERVAL) { + for (const timestamp of this.recentTimestamps) { + if (timestamp < cutoffTime) { + this.recentTimestamps.delete(timestamp); + } + } + this.lastCleanup = now; + } + } + + trackNonce(nonce) { + this.processedNonces.add(nonce); + + if (this.processedNonces.size > this.maxNonceHistory) { + const noncesArray = Array.from(this.processedNonces); + const noncesToDelete = noncesArray.slice(0, 100); + noncesToDelete.forEach((n) => this.processedNonces.delete(n)); + } + } + + reset() { + this.lastMessageTimestamp = 0; + this.processedNonces.clear(); + this.recentTimestamps.clear(); + this.lastCleanup = 0; + } + + getValidationStats() { + return { + lastMessageTimestamp: this.lastMessageTimestamp, + processedNoncesCount: this.processedNonces.size, + recentTimestampsCount: this.recentTimestamps.size, + allowedOrigin: this.allowedOrigin, + allowedMessageType: this.allowedMessageType, + timestampTolerance: this.timestampTolerance, + }; + } +} + +export const createMessageValidator = (allowedOrigin, options = {}) => { + return new MessageValidator(allowedOrigin, MESSAGE_TYPES.AUTH_STATUS, options); +}; diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 70b2265af9e8e8..688fba0a3c055f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -43145,6 +43145,9 @@ msgstr "" msgid "Observability|Last 7 days" msgstr "" +msgid "Observability|Message validation failed" +msgstr "" + msgid "Observability|O11y Service Settings" msgstr "" @@ -43199,6 +43202,12 @@ msgstr "" msgid "Observability|View our %{documentation} for further instructions on how to use these features." msgstr "" +msgid "Observability|allowedMessageType must be a non-empty string" +msgstr "" + +msgid "Observability|allowedOrigin must be a non-empty string" +msgstr "" + msgid "Oct" msgstr "" 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..62bd54bee0884e --- /dev/null +++ b/spec/frontend/observability/utils/message_validator_spec.js @@ -0,0 +1,426 @@ +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 mockNow = 1000000; + const config = { + allowedOrigin: 'https://trusted-origin.com', + allowedMessageType: MESSAGE_TYPES.AUTH_STATUS, + maxMessageAge: TIMEOUTS.MAX_MESSAGE_AGE, + }; + + const createEvent = (overrides = {}) => ({ + origin: config.allowedOrigin, + data: { + type: config.allowedMessageType, + timestamp: mockNow - 1000, + nonce: 'valid-nonce', + counter: 1, + ...overrides, + }, + }); + + beforeEach(() => { + validator = new MessageValidator(config.allowedOrigin, config.allowedMessageType, { + maxMessageAge: config.maxMessageAge, + }); + jest.spyOn(Date, 'now').mockReturnValue(mockNow); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('constructor', () => { + it('initializes with provided configuration', () => { + expect(validator.allowedOrigin).toBe(config.allowedOrigin); + expect(validator.allowedMessageType).toBe(config.allowedMessageType); + expect(validator.maxMessageAge).toBe(config.maxMessageAge); + expect(validator.lastMessageTimestamp).toBe(0); + expect(validator.timestampTolerance).toBe(5000); + }); + + it('applies sensible defaults when options are omitted', () => { + const defaultValidator = new MessageValidator( + config.allowedOrigin, + config.allowedMessageType, + ); + expect(defaultValidator.maxMessageAge).toBe(TIMEOUTS.MAX_MESSAGE_AGE); + }); + + it('accepts custom configuration options', () => { + const customValidator = new MessageValidator( + config.allowedOrigin, + config.allowedMessageType, + { + timestampTolerance: 10000, + }, + ); + expect(customValidator.timestampTolerance).toBe(10000); + }); + }); + + describe('validateMessage', () => { + it('accepts completely valid messages', () => { + const event = createEvent(); + const result = validator.validateMessage(event, 'valid-nonce', 1); + expect(result).toEqual({ valid: true }); + }); + + describe('rejects invalid messages', () => { + const invalidCases = [ + { + name: 'wrong origin', + event: () => createEvent(), + modifier: (event) => ({ ...event, origin: 'https://malicious-origin.com' }), + }, + { + name: 'malformed data structure', + event: () => ({ origin: config.allowedOrigin, data: null }), + modifier: () => {}, + }, + { + name: 'incorrect message type', + event: () => createEvent({ type: 'INVALID_TYPE' }), + modifier: () => {}, + }, + { + name: 'nonce mismatch', + event: () => createEvent(), + modifier: () => {}, + nonce: 'wrong-nonce', + }, + { + name: 'counter mismatch', + event: () => createEvent(), + modifier: () => {}, + counter: 2, + }, + { + name: 'stale timestamp', + event: () => createEvent({ timestamp: 1 }), + modifier: () => {}, + }, + ]; + + invalidCases.forEach(({ name, event, modifier, nonce = 'valid-nonce', counter = 1 }) => { + it(`when ${name}`, () => { + const testEvent = event(); + const modifiedEvent = modifier(testEvent) || testEvent; + const result = validator.validateMessage(modifiedEvent, nonce, counter); + expect(result).toEqual({ valid: false, error: 'Message validation failed' }); + }); + }); + }); + }); + + describe('origin validation', () => { + const testCases = [ + { input: config.allowedOrigin, expected: true, description: 'matching origin' }, + { input: 'https://malicious-origin.com', expected: false, description: 'different origin' }, + { input: null, expected: false, description: 'null origin' }, + { input: undefined, expected: false, description: 'undefined origin' }, + ]; + + testCases.forEach(({ input, expected, description }) => { + it(`${expected ? 'accepts' : 'rejects'} ${description}`, () => { + expect(validator.validateOrigin(input)).toBe(expected); + }); + }); + }); + + describe('data structure validation', () => { + const validStructure = { + type: 'string-type', + timestamp: 123456789, + nonce: 'string-nonce', + counter: 42, + }; + + it('accepts well-formed data objects', () => { + expect(validator.validateStructure(validStructure)).toBe(true); + }); + + describe('rejects malformed data', () => { + const invalidCases = [ + { input: null, reason: 'null data' }, + { input: undefined, reason: 'undefined data' }, + { input: 'string', reason: 'string instead of object' }, + { input: 123, reason: 'number instead of object' }, + { input: [], reason: 'array instead of object' }, + { input: { ...validStructure, type: 123 }, reason: 'non-string type' }, + { input: { ...validStructure, timestamp: '123456789' }, reason: 'non-numeric timestamp' }, + { input: { ...validStructure, nonce: 123 }, reason: 'non-string nonce' }, + { input: { ...validStructure, counter: '42' }, reason: 'non-numeric counter' }, + ]; + + invalidCases.forEach(({ input, reason }) => { + it(`when data has ${reason}`, () => { + expect(validator.validateStructure(input)).toBe(false); + }); + }); + + it('when required fields are missing', () => { + const requiredFields = ['type', 'timestamp', 'nonce', 'counter']; + requiredFields.forEach((field) => { + const incompleteData = { ...validStructure }; + delete incompleteData[field]; + expect(validator.validateStructure(incompleteData)).toBe(false); + }); + }); + }); + }); + + describe('message type validation', () => { + const testCases = [ + { input: config.allowedMessageType, expected: true }, + { input: 'WRONG_TYPE', expected: false }, + { input: null, expected: false }, + { input: undefined, expected: false }, + ]; + + testCases.forEach(({ input, expected }) => { + it(`${expected ? 'accepts' : 'rejects'} ${input || 'falsy values'}`, () => { + expect(validator.validateType(input)).toBe(expected); + }); + }); + }); + + describe('nonce validation', () => { + it('accepts matching nonces', () => { + expect(validator.validateNonce('test-nonce', 'test-nonce')).toBe(true); + }); + + const invalidCases = [ + { actual: 'nonce1', expected: 'nonce2', reason: 'mismatched nonces' }, + { actual: null, expected: 'test', reason: 'null actual nonce' }, + { actual: 'test', expected: null, reason: 'null expected nonce' }, + { actual: undefined, expected: 'test', reason: 'undefined actual nonce' }, + { actual: 'test', expected: undefined, reason: 'undefined expected nonce' }, + ]; + + invalidCases.forEach(({ actual, expected, reason }) => { + it(`rejects ${reason}`, () => { + expect(validator.validateNonce(actual, expected)).toBe(false); + }); + }); + }); + + describe('counter validation', () => { + it('accepts matching counters', () => { + expect(validator.validateCounter(42, 42)).toBe(true); + }); + + const invalidCases = [ + { actual: 1, expected: 2, reason: 'mismatched counters' }, + { actual: null, expected: 1, reason: 'null actual counter' }, + { actual: 1, expected: null, reason: 'null expected counter' }, + { actual: undefined, expected: 1, reason: 'undefined actual counter' }, + { actual: 1, expected: undefined, reason: 'undefined expected counter' }, + ]; + + invalidCases.forEach(({ actual, expected, reason }) => { + it(`rejects ${reason}`, () => { + expect(validator.validateCounter(actual, expected)).toBe(false); + }); + }); + }); + + describe('timestamp validation', () => { + beforeEach(() => { + validator.reset(); + }); + + describe('message age validation', () => { + it('accepts recent timestamps', () => { + const recentTimestamp = mockNow - 1000; + expect(validator.validateTimestamp(recentTimestamp)).toBe(true); + expect(validator.lastMessageTimestamp).toBe(recentTimestamp); + }); + + it('rejects timestamps older than maximum age', () => { + const staleTimestamp = mockNow - config.maxMessageAge - 1000; + expect(validator.validateTimestamp(staleTimestamp)).toBe(false); + }); + + it('accepts timestamps at the exact age boundary', () => { + const boundaryTimestamp = mockNow - config.maxMessageAge; + expect(validator.validateTimestamp(boundaryTimestamp)).toBe(true); + }); + + it('rejects future timestamps beyond clock skew tolerance', () => { + const futureTimestamp = mockNow + config.maxMessageAge + 1000; + expect(validator.validateTimestamp(futureTimestamp)).toBe(false); + }); + }); + + describe('duplicate timestamp detection', () => { + it('rejects exact duplicate timestamps', () => { + const timestamp = mockNow - 1000; + expect(validator.validateTimestamp(timestamp)).toBe(true); + expect(validator.validateTimestamp(timestamp)).toBe(false); + }); + }); + + describe('out-of-order message handling', () => { + const baseTimestamp = mockNow - 1000; + const tolerance = 5000; + + beforeEach(() => { + validator.validateTimestamp(baseTimestamp); + }); + + it('allows messages within tolerance window', () => { + const withinTolerance = [ + baseTimestamp - 2000, + baseTimestamp - 4000, + baseTimestamp + 2000, + baseTimestamp + 4000, + ]; + + withinTolerance.forEach((timestamp) => { + expect(validator.validateTimestamp(timestamp)).toBe(true); + }); + }); + + it('rejects messages outside tolerance window', () => { + const outsideTolerance = [ + baseTimestamp - (tolerance + 1000), + baseTimestamp - (tolerance + 2000), + ]; + + outsideTolerance.forEach((timestamp) => { + expect(validator.validateTimestamp(timestamp)).toBe(false); + }); + }); + + it('updates last timestamp when receiving newer messages within tolerance', () => { + const newerTimestamp = baseTimestamp + 3000; + expect(validator.validateTimestamp(newerTimestamp)).toBe(true); + expect(validator.lastMessageTimestamp).toBe(newerTimestamp); + }); + + it('preserves last timestamp when receiving older messages within tolerance', () => { + const olderTimestamp = baseTimestamp - 3000; + expect(validator.validateTimestamp(olderTimestamp)).toBe(true); + expect(validator.lastMessageTimestamp).toBe(baseTimestamp); + }); + }); + + it('handles progressive timestamp advancement correctly', () => { + const timestamps = [995000, 1005000, 1015000]; + timestamps.forEach((timestamp) => { + expect(validator.validateTimestamp(timestamp)).toBe(true); + }); + }); + + it('manages cleanup of old timestamps from tracking set', () => { + const recentTimestamp = mockNow - 1000; + validator.validateTimestamp(recentTimestamp); + expect(validator.recentTimestamps.has(recentTimestamp)).toBe(true); + + validator.cleanupOldTimestamps(mockNow + config.maxMessageAge + 2000); + expect(validator.recentTimestamps.has(recentTimestamp)).toBe(false); + }); + }); + + describe('validator state management', () => { + it('properly resets all state when reset() is called', () => { + validator.validateTimestamp(mockNow - 1000); + validator.trackNonce('test-nonce'); + + expect(validator.lastMessageTimestamp).not.toBe(0); + expect(validator.recentTimestamps.size).toBeGreaterThan(0); + + validator.reset(); + expect(validator.lastMessageTimestamp).toBe(0); + expect(validator.recentTimestamps.size).toBe(0); + }); + + it('allows reprocessing previously seen timestamps after reset', () => { + const timestamp = mockNow - 1000; + + expect(validator.validateTimestamp(timestamp)).toBe(true); + expect(validator.validateTimestamp(timestamp)).toBe(false); + + validator.reset(); + expect(validator.validateTimestamp(timestamp)).toBe(true); + }); + + it('provides comprehensive validation statistics', () => { + validator.validateTimestamp(mockNow - 1000); + validator.trackNonce('test-nonce'); + + const stats = validator.getValidationStats(); + expect(stats).toEqual({ + lastMessageTimestamp: mockNow - 1000, + processedNoncesCount: 1, + recentTimestampsCount: 1, + allowedOrigin: config.allowedOrigin, + allowedMessageType: config.allowedMessageType, + timestampTolerance: 5000, + }); + }); + }); + + describe('factory function', () => { + it('creates properly configured MessageValidator instances', () => { + 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('processes sequential valid messages correctly', () => { + const messages = [ + { timestamp: 995000, nonce: 'nonce-1', counter: 1 }, + { timestamp: 996000, nonce: 'nonce-2', counter: 2 }, + { timestamp: 997000, nonce: 'nonce-3', counter: 3 }, + ]; + + messages.forEach(({ timestamp, nonce, counter }) => { + const event = createEvent({ timestamp, nonce, counter }); + expect(validator.validateMessage(event, nonce, counter)).toEqual({ valid: true }); + }); + + expect(validator.lastMessageTimestamp).toBe(997000); + }); + + it('maintains state consistency during mixed valid/invalid message processing', () => { + const validEvent = createEvent({ timestamp: 995000, nonce: 'valid-nonce', counter: 1 }); + const invalidEvent = createEvent({ + timestamp: 996000, + nonce: 'invalid-nonce', + counter: 2, + }); + + expect(validator.validateMessage(validEvent, 'valid-nonce', 1)).toEqual({ valid: true }); + expect(validator.validateMessage(invalidEvent, 'expected-nonce', 2)).toEqual({ + valid: false, + error: 'Message validation failed', + }); + + const nextValidEvent = createEvent({ timestamp: 997000, nonce: 'next-nonce', counter: 3 }); + expect(validator.validateMessage(nextValidEvent, 'next-nonce', 3)).toEqual({ valid: true }); + }); + + it('handles attempt to replay previously processed messages', () => { + const event = createEvent({ timestamp: 995000, nonce: 'replay-nonce', counter: 1 }); + + expect(validator.validateMessage(event, 'replay-nonce', 1)).toEqual({ valid: true }); + expect(validator.validateMessage(event, 'replay-nonce', 1)).toEqual({ + valid: false, + error: 'Message validation failed', + }); + }); + }); +}); -- GitLab From 61843807cb6cd9bc03346d98329938f8fab531b0 Mon Sep 17 00:00:00 2001 From: dakotadux Date: Tue, 29 Jul 2025 14:53:06 -0600 Subject: [PATCH 2/2] Add observability crypto utils This commit adds the crypto utils for the observability app. The crypto utils are used to encrypt and decrypt messages between the main vue app and the iframed react app. The crypto utils are also used to generate and validate nonces. --- .../javascripts/observability/utils/crypto.js | 165 +++++++++ locale/gitlab.pot | 12 + .../observability/utils/crypto_spec.js | 329 ++++++++++++++++++ 3 files changed, 506 insertions(+) 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/utils/crypto.js b/app/assets/javascripts/observability/utils/crypto.js new file mode 100644 index 00000000000000..f33e1df7ffe2a2 --- /dev/null +++ b/app/assets/javascripts/observability/utils/crypto.js @@ -0,0 +1,165 @@ +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 MAX_MESSAGE_AGE_MS = 5 * 60 * 1000; +const MIN_ENCRYPTION_KEY_LENGTH = 32; + +const ENCRYPTION_CONFIG = { + algorithm: 'AES-GCM', + keyLength: 256, + ivLength: 12, + tagLength: 128, + keyDerivationIterations: 100000, + saltLength: 16, + // eslint-disable-next-line @gitlab/require-i18n-strings -- Algorithm name + keyDerivationAlgorithm: 'PBKDF2', + hashAlgorithm: 'SHA-256', +}; + +const getSharedSecret = (encryptionKey) => { + if (!encryptionKey || encryptionKey.length < MIN_ENCRYPTION_KEY_LENGTH) { + throw new Error(s__('Observability|Valid encryption key is required for secure operation')); + } + + return new TextEncoder().encode(encryptionKey); +}; + +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 = window.crypto.getRandomValues(new Uint8Array(ENCRYPTION_CONFIG.saltLength)); + const iv = window.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); + } + + if (Date.now() - timestamp > MAX_MESSAGE_AGE_MS) { + 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 window.crypto.subtle.encrypt === 'function' && + typeof window.crypto.subtle.decrypt === 'function' && + typeof window.crypto.subtle.deriveKey === 'function', + ); + } catch { + return false; + } +}; + +export const generateSecureRandom = (length = 32) => { + if (length <= 0 || !Number.isInteger(length)) { + throw new Error(s__('Observability|Length must be a positive integer')); + } + if (!isCryptoSupported()) { + throw new Error(s__('Observability|Crypto API not supported')); + } + + const array = new Uint8Array(length); + window.crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); +}; + +export const generateNonce = () => generateSecureRandom(16); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 688fba0a3c055f..8cf307c2761a15 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -43082,6 +43082,12 @@ msgstr "" msgid "Observability|Creates alerts automatically for Observability-related errors." msgstr "" +msgid "Observability|Crypto API not supported" +msgstr "" + +msgid "Observability|Crypto operation failed" +msgstr "" + msgid "Observability|Danger Zone" msgstr "" @@ -43145,6 +43151,9 @@ msgstr "" msgid "Observability|Last 7 days" msgstr "" +msgid "Observability|Length must be a positive integer" +msgstr "" + msgid "Observability|Message validation failed" msgstr "" @@ -43199,6 +43208,9 @@ msgstr "" msgid "Observability|User Email" msgstr "" +msgid "Observability|Valid encryption key is required for secure operation" +msgstr "" + msgid "Observability|View our %{documentation} for further instructions on how to use these features." msgstr "" diff --git a/spec/frontend/observability/utils/crypto_spec.js b/spec/frontend/observability/utils/crypto_spec.js new file mode 100644 index 00000000000000..911836e17f7aa8 --- /dev/null +++ b/spec/frontend/observability/utils/crypto_spec.js @@ -0,0 +1,329 @@ +/** + * 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, + generateNonce, +} from '../../../../app/assets/javascripts/observability/utils/crypto'; + +describe('Cross-Frame Authentication Encryption', () => { + const fixtures = { + origin: 'https://test-origin.com', + encryptionKey: 'test-encryption-key-for-unit-tests-only', + payload: { + userId: 'test-user-123', + accessJwt: 'fake.jwt.token', + refreshJwt: 'fake.refresh.jwt.refresh', + theme: 'dark', + nonce: 'test-nonce-123456789abcdef', + timestamp: Date.now(), + counter: 1, + }, + }; + + const testHelpers = { + async setupCrypto() { + 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; + } + }, + + withoutCrypto(testFn) { + const originalCrypto = global.crypto; + const originalSubtle = global.crypto?.subtle; + + return () => { + delete global.crypto; + + try { + return testFn(); + } finally { + global.crypto = originalCrypto; + if (originalCrypto && originalSubtle) { + global.crypto.subtle = originalSubtle; + } + } + }; + }, + + encryptWithFixtures(payload = fixtures.payload) { + return encryptPayload(payload, fixtures.origin, fixtures.encryptionKey); + }, + + decryptWithFixtures(encrypted) { + return decryptPayload(encrypted, fixtures.origin, fixtures.encryptionKey); + }, + }; + + beforeAll(testHelpers.setupCrypto); + + describe('Crypto Feature Detection', () => { + it('detects crypto API support', () => { + expect(isCryptoSupported()).toBe(true); + }); + + it( + 'handles missing crypto API gracefully', + testHelpers.withoutCrypto(() => { + expect(isCryptoSupported()).toBe(false); + }), + ); + + it('generates nonce when crypto is supported', () => { + const nonce = generateNonce(); + expect(nonce).toMatch(/^[a-f0-9]{32}$/); + }); + + it( + 'throws error when crypto is not supported', + testHelpers.withoutCrypto(() => { + expect(() => generateNonce()).toThrow('Crypto API not supported'); + }), + ); + }); + + describe('Encryption/Decryption Workflow', () => { + it('encrypts and decrypts payload successfully', async () => { + const encrypted = await testHelpers.encryptWithFixtures(); + expect(encrypted).toEqual( + expect.objectContaining({ + encrypted: expect.any(Array), + salt: expect.any(Array), + iv: expect.any(Array), + algorithm: 'AES-GCM', + timestamp: expect.any(Number), + }), + ); + + const decrypted = await testHelpers.decryptWithFixtures(encrypted); + expect(decrypted).toEqual(fixtures.payload); + }); + + it('fails decryption with wrong origin', async () => { + const encrypted = await testHelpers.encryptWithFixtures(); + + await expect( + decryptPayload(encrypted, 'https://wrong-origin.com', fixtures.encryptionKey), + ).rejects.toThrow('Crypto operation failed'); + }); + + it('fails decryption with tampered data', async () => { + const encrypted = await testHelpers.encryptWithFixtures(); + encrypted.encrypted[0] = (encrypted.encrypted[0] + 1) % 256; + + await expect(testHelpers.decryptWithFixtures(encrypted)).rejects.toThrow( + 'Crypto operation failed', + ); + }); + + it('fails decryption with old timestamp', async () => { + const encrypted = await testHelpers.encryptWithFixtures(); + encrypted.timestamp = Date.now() - 10 * 60 * 1000; + + await expect(testHelpers.decryptWithFixtures(encrypted)).rejects.toThrow( + 'Crypto operation failed', + ); + }); + }); + + describe('Security Validations', () => { + it('generates unique nonces', () => { + const nonce1 = generateSecureRandom(32); + const nonce2 = generateSecureRandom(32); + + expect(nonce1).not.toBe(nonce2); + expect(nonce1).toHaveLength(64); + expect(nonce2).toHaveLength(64); + }); + + describe('generateSecureRandom validation', () => { + const validInputs = [1, 32, 100, undefined]; + const invalidInputs = [ + [0, 'Length must be a positive integer'], + [-1, 'Length must be a positive integer'], + [1.5, 'Length must be a positive integer'], + ['32', 'Length must be a positive integer'], + [NaN, 'Length must be a positive integer'], + ]; + + validInputs.forEach((input) => { + it(`accepts valid input: ${input}`, () => { + expect(() => generateSecureRandom(input)).not.toThrow(); + }); + }); + + invalidInputs.forEach(([input, expectedError]) => { + it(`rejects invalid input: ${input}`, () => { + expect(() => generateSecureRandom(input)).toThrow(expectedError); + }); + }); + }); + + const payloadTestCases = [ + { + name: 'small payload', + payload: { test: 'small' }, + }, + { + name: 'large payload', + payload: { + test: 'large', + data: 'x'.repeat(10000), + nested: { + deep: { + structure: { + with: ['arrays', 'and', 'objects'], + numbers: [1, 2, 3, 4, 5], + }, + }, + }, + }, + }, + { + name: 'unicode payload', + payload: { + emoji: '🔐🛡️🔑', + chinese: '加密数据', + arabic: 'البيانات المشفرة', + special: '!@#$%^&*()_+-=[]{}|;:,.<>?', + quotes: '"\'`', + }, + }, + { + name: 'empty payload', + payload: {}, + }, + ]; + + payloadTestCases.forEach(({ name, payload }) => { + it(`handles ${name}`, async () => { + const encrypted = await testHelpers.encryptWithFixtures(payload); + expect(encrypted).toEqual( + expect.objectContaining({ + encrypted: expect.any(Array), + salt: expect.any(Array), + iv: expect.any(Array), + algorithm: 'AES-GCM', + timestamp: expect.any(Number), + }), + ); + + const decrypted = await testHelpers.decryptWithFixtures(encrypted); + expect(decrypted).toEqual(payload); + }); + }); + }); + + describe('Error Handling', () => { + const errorTestCases = [ + { + name: 'invalid algorithm', + setup: (encrypted) => { + const modified = { ...encrypted, algorithm: 'INVALID-ALGORITHM' }; + return modified; + }, + }, + { + name: 'malformed encrypted data', + setup: (encrypted) => { + const modified = { ...encrypted, encrypted: 'not-an-array' }; + return modified; + }, + }, + ]; + + errorTestCases.forEach(({ name, setup }) => { + it(`handles ${name}`, async () => { + const encrypted = await testHelpers.encryptWithFixtures(); + const modifiedEncrypted = setup(encrypted); + + await expect(testHelpers.decryptWithFixtures(modifiedEncrypted)).rejects.toThrow( + 'Crypto operation failed', + ); + }); + }); + }); + + describe('Performance Tests', () => { + const PERFORMANCE_THRESHOLD_MS = 100; + + async function measureTime(operation) { + const startTime = performance.now(); + await operation(); + return performance.now() - startTime; + } + + it('encrypts and decrypts within reasonable time limits', async () => { + const encryptionTime = await measureTime(() => testHelpers.encryptWithFixtures()); + + const encrypted = await testHelpers.encryptWithFixtures(); + const decryptionTime = await measureTime(() => testHelpers.decryptWithFixtures(encrypted)); + + expect(encryptionTime).toBeLessThan(PERFORMANCE_THRESHOLD_MS); + expect(decryptionTime).toBeLessThan(PERFORMANCE_THRESHOLD_MS); + }); + + it('handles multiple concurrent operations', async () => { + const operations = Array.from({ length: 10 }, async (_, i) => { + const payload = { ...fixtures.payload, counter: i }; + const encrypted = await testHelpers.encryptWithFixtures(payload); + return testHelpers.decryptWithFixtures(encrypted); + }); + + const results = await Promise.all(operations); + + results.forEach((result, index) => { + expect(result.counter).toBe(index); + }); + }); + }); + + describe('Cross-Application Integration', () => { + it('simulates full message flow between Vue and React', async () => { + const vueMessage = { + type: 'O11Y_JWT_LOGIN_ENCRYPTED', + payload: await testHelpers.encryptWithFixtures(), + encrypted: true, + }; + + expect(vueMessage).toEqual( + expect.objectContaining({ + encrypted: true, + type: 'O11Y_JWT_LOGIN_ENCRYPTED', + payload: expect.any(Object), + }), + ); + + const decryptedInReact = await testHelpers.decryptWithFixtures(vueMessage.payload); + expect(decryptedInReact).toEqual(fixtures.payload); + + const reactResponse = { + type: 'O11Y_AUTH_STATUS', + authenticated: true, + timestamp: Date.now(), + nonce: fixtures.payload.nonce, + counter: fixtures.payload.counter, + }; + + expect(reactResponse).toEqual( + expect.objectContaining({ + authenticated: true, + nonce: fixtures.payload.nonce, + counter: fixtures.payload.counter, + }), + ); + }); + }); +}); -- GitLab