From 2aac2d64b15b869dacc08e599f8259a4da9acfe1 Mon Sep 17 00:00:00 2001 From: dakotadux Date: Mon, 28 Jul 2025 15:31:30 -0600 Subject: [PATCH] Add messageValidator and specs This validates messages from the observability iframe. --- .../javascripts/observability/constants.js | 15 + .../observability/utils/message_validator.js | 192 ++++++++ locale/gitlab.pot | 3 + .../utils/message_validator_spec.js | 426 ++++++++++++++++++ 4 files changed, 636 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..dc6212153ce673 --- /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('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.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 bffbc7381cead2..2a5cafb04ffa2f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -43197,6 +43197,9 @@ msgstr "" msgid "Observability|Last 7 days" msgstr "" +msgid "Observability|Message validation failed" +msgstr "" + msgid "Observability|O11y Service Settings" 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