From 93b3bd6555cfa7747885ebf64f6b66bb6bc1d2d3 Mon Sep 17 00:00:00 2001 From: Andrew Boni Date: Tue, 18 Nov 2025 16:38:30 -0800 Subject: [PATCH 1/3] Use randomUUID for session ID --- src/server.ts | 10 ++- tests/unit/session-id.test.ts | 122 ++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 tests/unit/session-id.test.ts diff --git a/src/server.ts b/src/server.ts index fd4f0db..597ebbb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,5 @@ import { AsyncLocalStorage } from "node:async_hooks"; +import { randomUUID } from "node:crypto"; import { IterableClient } from "@iterable/api"; import { logger } from "@iterable/api"; @@ -234,6 +235,11 @@ export class IterableMcpServer { } } -function createSessionId(): string { - return Math.random().toString(36).slice(2) + Date.now().toString(36); +/** + * Generate a cryptographically secure session ID + * Uses crypto.randomUUID() for guaranteed uniqueness and unpredictability + * Exported for testing purposes + */ +export function createSessionId(): string { + return randomUUID(); } diff --git a/tests/unit/session-id.test.ts b/tests/unit/session-id.test.ts new file mode 100644 index 0000000..be51d29 --- /dev/null +++ b/tests/unit/session-id.test.ts @@ -0,0 +1,122 @@ +/* eslint-disable simple-import-sort/imports */ +import { describe, expect, it } from "@jest/globals"; +import { createSessionId } from "../../src/server"; + +describe("Session ID Generation", () => { + it("generates a valid UUID v4 format", () => { + const sessionId = createSessionId(); + + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + // where y is one of [8, 9, a, b] + const uuidV4Regex = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + + expect(sessionId).toMatch(uuidV4Regex); + }); + + it("generates unique session IDs", () => { + const sessionIds = new Set(); + const iterations = 1000; + + for (let i = 0; i < iterations; i++) { + const sessionId = createSessionId(); + expect(sessionIds.has(sessionId)).toBe(false); + sessionIds.add(sessionId); + } + + expect(sessionIds.size).toBe(iterations); + }); + + it("generates session IDs of expected length", () => { + const sessionId = createSessionId(); + + // Standard UUID format is 36 characters (32 hex + 4 hyphens) + expect(sessionId.length).toBe(36); + }); + + it("generates session IDs without sequential patterns", () => { + const id1 = createSessionId(); + const id2 = createSessionId(); + const id3 = createSessionId(); + + // Ensure they're not incrementing or following a pattern + expect(id1).not.toBe(id2); + expect(id2).not.toBe(id3); + expect(id1).not.toBe(id3); + + // Verify no substring similarity (should be cryptographically random) + const commonPrefix = findCommonPrefix(id1, id2); + expect(commonPrefix.length).toBeLessThan(5); // Allow for some random overlap + }); + + it("does not use predictable values", () => { + const beforeTimestamp = Date.now(); + const sessionId = createSessionId(); + const afterTimestamp = Date.now(); + + // Verify session ID doesn't contain timestamp in base36 + const timestampBase36 = beforeTimestamp.toString(36); + expect(sessionId).not.toContain(timestampBase36); + + const timestampBase36After = afterTimestamp.toString(36); + expect(sessionId).not.toContain(timestampBase36After); + }); + + it("maintains high entropy across multiple generations", () => { + const sessionIds = Array.from({ length: 100 }, () => createSessionId()); + + // Check that each character position has variation + // For a truly random UUID, each hex position should have varied values + const positions = new Array(36).fill(0).map(() => new Set()); + + sessionIds.forEach((id) => { + for (let i = 0; i < id.length; i++) { + positions[i]!.add(id[i]!); + } + }); + + // Positions 8, 13, 18, 23 are hyphens (should all be the same) + expect(positions[8]!.size).toBe(1); + expect(positions[13]!.size).toBe(1); + expect(positions[18]!.size).toBe(1); + expect(positions[23]!.size).toBe(1); + + // Position 14 should be '4' for UUID v4 + expect(positions[14]!.size).toBe(1); + expect(positions[14]!.has("4")).toBe(true); + + // Position 19 should be one of [8, 9, a, b] + expect(positions[19]!.size).toBeGreaterThan(0); + expect(positions[19]!.size).toBeLessThanOrEqual(4); + + // Other hex positions should have good variation (at least 2 different values in 100 samples) + const hexPositions = [0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 15, 16, 17, 20, 21, 22, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35]; + + hexPositions.forEach((pos) => { + expect(positions[pos]!.size).toBeGreaterThanOrEqual(2); + }); + }); + + it("is suitable for concurrent generation", () => { + // Simulate concurrent generation + const promises = Array.from({ length: 100 }, () => + Promise.resolve(createSessionId()) + ); + + return Promise.all(promises).then((sessionIds) => { + const uniqueIds = new Set(sessionIds); + expect(uniqueIds.size).toBe(sessionIds.length); + }); + }); +}); + +/** + * Helper function to find common prefix between two strings + */ +function findCommonPrefix(str1: string, str2: string): string { + let i = 0; + while (i < str1.length && i < str2.length && str1[i] === str2[i]) { + i++; + } + return str1.slice(0, i); +} From 38c16a595cd90519874c661a5adec605b0a2e06e Mon Sep 17 00:00:00 2001 From: Andrew Boni Date: Tue, 18 Nov 2025 16:44:09 -0800 Subject: [PATCH 2/3] Fix formatting --- tests/unit/session-id.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/session-id.test.ts b/tests/unit/session-id.test.ts index be51d29..6522922 100644 --- a/tests/unit/session-id.test.ts +++ b/tests/unit/session-id.test.ts @@ -90,7 +90,10 @@ describe("Session ID Generation", () => { expect(positions[19]!.size).toBeLessThanOrEqual(4); // Other hex positions should have good variation (at least 2 different values in 100 samples) - const hexPositions = [0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 15, 16, 17, 20, 21, 22, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35]; + const hexPositions = [ + 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 15, 16, 17, 20, 21, 22, 24, 25, 26, + 27, 28, 29, 30, 31, 32, 33, 34, 35, + ]; hexPositions.forEach((pos) => { expect(positions[pos]!.size).toBeGreaterThanOrEqual(2); From 5985f54cdc157e4a0c4886e5f34fb7443c7bd6cb Mon Sep 17 00:00:00 2001 From: Andrew Boni Date: Tue, 18 Nov 2025 16:48:03 -0800 Subject: [PATCH 3/3] Updates tests --- tests/unit/session-id.test.ts | 110 ---------------------------------- 1 file changed, 110 deletions(-) diff --git a/tests/unit/session-id.test.ts b/tests/unit/session-id.test.ts index 6522922..deec06f 100644 --- a/tests/unit/session-id.test.ts +++ b/tests/unit/session-id.test.ts @@ -3,123 +3,13 @@ import { describe, expect, it } from "@jest/globals"; import { createSessionId } from "../../src/server"; describe("Session ID Generation", () => { - it("generates a valid UUID v4 format", () => { - const sessionId = createSessionId(); - - // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx - // where y is one of [8, 9, a, b] - const uuidV4Regex = - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - - expect(sessionId).toMatch(uuidV4Regex); - }); - it("generates unique session IDs", () => { - const sessionIds = new Set(); - const iterations = 1000; - - for (let i = 0; i < iterations; i++) { - const sessionId = createSessionId(); - expect(sessionIds.has(sessionId)).toBe(false); - sessionIds.add(sessionId); - } - - expect(sessionIds.size).toBe(iterations); - }); - - it("generates session IDs of expected length", () => { - const sessionId = createSessionId(); - - // Standard UUID format is 36 characters (32 hex + 4 hyphens) - expect(sessionId.length).toBe(36); - }); - - it("generates session IDs without sequential patterns", () => { const id1 = createSessionId(); const id2 = createSessionId(); const id3 = createSessionId(); - // Ensure they're not incrementing or following a pattern expect(id1).not.toBe(id2); expect(id2).not.toBe(id3); expect(id1).not.toBe(id3); - - // Verify no substring similarity (should be cryptographically random) - const commonPrefix = findCommonPrefix(id1, id2); - expect(commonPrefix.length).toBeLessThan(5); // Allow for some random overlap - }); - - it("does not use predictable values", () => { - const beforeTimestamp = Date.now(); - const sessionId = createSessionId(); - const afterTimestamp = Date.now(); - - // Verify session ID doesn't contain timestamp in base36 - const timestampBase36 = beforeTimestamp.toString(36); - expect(sessionId).not.toContain(timestampBase36); - - const timestampBase36After = afterTimestamp.toString(36); - expect(sessionId).not.toContain(timestampBase36After); - }); - - it("maintains high entropy across multiple generations", () => { - const sessionIds = Array.from({ length: 100 }, () => createSessionId()); - - // Check that each character position has variation - // For a truly random UUID, each hex position should have varied values - const positions = new Array(36).fill(0).map(() => new Set()); - - sessionIds.forEach((id) => { - for (let i = 0; i < id.length; i++) { - positions[i]!.add(id[i]!); - } - }); - - // Positions 8, 13, 18, 23 are hyphens (should all be the same) - expect(positions[8]!.size).toBe(1); - expect(positions[13]!.size).toBe(1); - expect(positions[18]!.size).toBe(1); - expect(positions[23]!.size).toBe(1); - - // Position 14 should be '4' for UUID v4 - expect(positions[14]!.size).toBe(1); - expect(positions[14]!.has("4")).toBe(true); - - // Position 19 should be one of [8, 9, a, b] - expect(positions[19]!.size).toBeGreaterThan(0); - expect(positions[19]!.size).toBeLessThanOrEqual(4); - - // Other hex positions should have good variation (at least 2 different values in 100 samples) - const hexPositions = [ - 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 15, 16, 17, 20, 21, 22, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35, - ]; - - hexPositions.forEach((pos) => { - expect(positions[pos]!.size).toBeGreaterThanOrEqual(2); - }); - }); - - it("is suitable for concurrent generation", () => { - // Simulate concurrent generation - const promises = Array.from({ length: 100 }, () => - Promise.resolve(createSessionId()) - ); - - return Promise.all(promises).then((sessionIds) => { - const uniqueIds = new Set(sessionIds); - expect(uniqueIds.size).toBe(sessionIds.length); - }); }); }); - -/** - * Helper function to find common prefix between two strings - */ -function findCommonPrefix(str1: string, str2: string): string { - let i = 0; - while (i < str1.length && i < str2.length && str1[i] === str2[i]) { - i++; - } - return str1.slice(0, i); -}