Root cause
Validation & fail-open logic introduced
feat: Add comprehensive Journey Recording & Replay System
How AI contributed: Direct introductionPublished Aug 8, 2026 in
hulupeep/mcp-ui-probe
MCP tool arguments reach filesystem paths with no validation. get_journey and analyze_journey pass params.journeyId through handleGetJourney/handleAnalyzeJourney (src/server/MCPServer.ts:2435,2498) into JourneyStorage.loadJourney, and delete_journey passes it into deleteJourney (MCPServer.ts:2458); those build yamlPath/jsonPath with path.join(this.config.baseDir, ${journeyId}.yml) and ${journeyId}.json (src/journey/JourneyStorage.ts:118-119 for load, 163-164 for delete) and then call fs.readFile / fs.unlink, so a journeyId holding ../ segments escapes ~/.ui-probe/journeys to read or delete arbitrary .yml/.json files reachable by the server process. Separately, usage_stats with export=true forwards params.filename unchanged through exportUsageStats (MCPServer.ts:2603) to UsageTracker.exportToFile, which computes path.join(this.dataDir, filename || defaultFilename) and writes attacker-controlled bytes there with fs.writeFile (src/monitoring/usageTracker.ts:351,369), yielding an arbitrary file write for the local caller driving the MCP client. No basename extraction, normalization, or '..' rejection exists on any of these arguments.
Root cause
feat: Add comprehensive Journey Recording & Replay System
Fix status
No minimum fix commit has been established for this finding.
Fix status unresolved
Case facts
@@ -14,6 +14,12 @@ import { WorkflowDecomposer } from '../llm/workflowDecomposer.js'; import { AdaptiveExecutor } from '../llm/adaptiveExecutor.js'; import { ErrorEnhancer } from '../llm/errorEnhancer.js'; import { FieldNamer } from '../utils/fieldNamer.js';+import { JourneyRecorder } from '../journey/JourneyRecorder.js';+import { JourneyPlayer } from '../journey/JourneyPlayer.js';+import { JourneyStorage } from '../journey/JourneyStorage.js';+import { JourneyValidator } from '../journey/JourneyValidator.js';+import { JourneyAnalyzer } from '../journey/JourneyAnalyzer.js';+import { JourneyDiscovery } from '../journey/JourneyDiscovery.js'; import { MCPToolResult, NavigateParams,@@ -26,6 +32,12 @@ import { ExportReportParams, TestRun } from '../types/index.js';+import {+ Journey,+ JourneyExecutionResult,+ RecordingConfig,+ PlaybackConfig+} from '../types/journey.js'; import { MCPUIError, NavigationError, FormInferenceError } from '../utils/errors.js'; import { verifyPage, VerifyPageParams } from '../tools/verify_page.js'; import logger from '../utils/logger.js';@@ -39,6 +51,14 @@ export class MCPServer { private adaptiveExecutor: AdaptiveExecutor; private errorEnhancer: ErrorEnhancer; + // Journey system components+ private journeyStorage: JourneyStorage;+ private journeyValidator: JourneyValidator;+ private journeyAnalyzer: JourneyAnalyzer;+ private journeyDiscovery: JourneyDiscovery;+ private journeyRecorder: JourneyRecorder;+ private journeyPlayer: JourneyPlayer;+ constructor() { this.server = new Server( {@@ -60,6 +80,36 @@ export class MCPServer { this.adaptiveExecutor = new AdaptiveExecutor(); this.errorEnhancer = new ErrorEnhancer(this.llmStrategy); + // Initialize journey system components+ this.journeyStorage = new JourneyStorage();+ this.journeyValidator = new JourneyValidator(this.journeyStorage);+ this.journeyAnalyzer = new JourneyAnalyzer();+ this.journeyDiscovery = new JourneyDiscovery(this.journeyStorage, this.journeyAnalyzer, this.journeyValidator);++ // Initialize recorder and player with default configs+ const recordingConfig: RecordingConfig = {+ captureScreenshots: true,+ captureNetworkRequests: false,+ ignoredSelectors: ['.loading', '.spinner', '.ads'],+ minimumActionDelay: 500,+ autoGenerateName: true,+ autoGenerateDescription: true,+ smartSelectorGeneration: true+ };++ const playbackConfig: PlaybackConfig = {+ speed: 1.0,+ pauseOnError: true,+ maxRetries: 3,+ screenshotOnFailure: true,+ continueOnNonCriticalErrors: true,+ validateContext: true,+ timeoutMs: 30000+ };++ this.journeyRecorder = new JourneyRecorder(recordingConfig, this.journeyStorage, this.journeyAnalyzer);+ this.journeyPlayer = new JourneyPlayer(playbackConfig, this.journeyValidator, this.journeyStorage);+ this.setupToolHandlers(); } @@ -268,6 +318,251 @@ export class MCPServer { required: ['runId', 'format'], }, },+ // Journey Recording and Replay Tools+ {+ name: 'record_journey',+ description: 'Start recording user interactions as a journey',+ inputSchema: {+ type: 'object',+ properties: {+ name: {@@ -0,0 +1,732 @@+import { promises as fs } from 'fs';+import path from 'path';+import os from 'os';+import { z } from 'zod';+import yaml from 'js-yaml';+import {+ Journey,+ JourneyCollection,+ JourneyTemplate,+ JourneyStorageConfig,+ JourneySearchCriteria,+ JourneySearchResult,+ JourneySchema+} from '../types/journey.js';+import logger from '../utils/logger.js';++export class JourneyStorage {+ private config: JourneyStorageConfig;+ private indexCache: Map<string, Journey> = new Map();+ private collectionsCache: Map<string, JourneyCollection> = new Map();+ private lastIndexUpdate = 0;++ constructor(customConfig?: Partial<JourneyStorageConfig>) {+ this.config = {+ baseDir: path.join(os.homedir(), '.ui-probe', 'journeys'),+ collectionsDir: 'collections',+ templatesDir: 'templates',+ backupsDir: 'backups',+ indexFile: 'index.json',+ maxBackups: 10,+ compressionEnabled: true,+ ...customConfig+ };+ }++ async initialize(): Promise<void> {+ try {+ // Create directory structure+ await this.ensureDirectoryStructure();++ // Load index cache+ await this.loadIndexCache();++ // Load collections cache+ await this.loadCollectionsCache();++ logger.info('Journey storage initialized', {+ baseDir: this.config.baseDir,+ journeyCount: this.indexCache.size+ });+ } catch (error) {+ logger.error('Failed to initialize journey storage', { error });+ throw error;+ }+ }++ async saveJourney(journey: Journey, format: 'yaml' | 'json' = 'yaml'): Promise<void> {+ try {+ // Validate journey schema+ const validatedJourney = JourneySchema.parse(journey);++ // Ensure directories exist+ await this.ensureDirectoryStructure();++ // Determine file path and format+ const extension = format === 'yaml' ? '.yml' : '.json';+ const filename = `${validatedJourney.id}${extension}`;+ const filePath = path.join(this.config.baseDir, filename);++ // Serialize journey data+ let content: string;+ if (format === 'yaml') {+ content = yaml.dump(validatedJourney, {+ indent: 2,+ lineWidth: 120,+ noRefs: true+ });+ } else {+ content = JSON.stringify(validatedJourney, null, 2);+ }++ // Write to file+ await fs.writeFile(filePath, content, 'utf8');++ // Update index cache+ this.indexCache.set(validatedJourney.id, validatedJourney);++ // Update persistent index+ await this.updateIndex();