From 1340c960714932bdfa52d74e9bc7dcf6e329ecaa Mon Sep 17 00:00:00 2001 From: Coco Sheng Date: Mon, 11 May 2026 16:19:01 -0400 Subject: [PATCH] fix(core): handle malformed projects.json in ProjectRegistry (#26885) --- .../core/src/config/projectRegistry.test.ts | 61 +++++++++++++++++++ packages/core/src/config/projectRegistry.ts | 21 ++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/core/src/config/projectRegistry.test.ts b/packages/core/src/config/projectRegistry.test.ts index e7e532bb2b..84d266b278 100644 --- a/packages/core/src/config/projectRegistry.test.ts +++ b/packages/core/src/config/projectRegistry.test.ts @@ -374,4 +374,65 @@ describe('ProjectRegistry', () => { readFileSpy.mockRestore(); }); + + it('recovers gracefully if registry is an empty object (invalid schema)', async () => { + // 1. Write an empty object which is valid JSON but invalid schema + fs.writeFileSync(registryPath, '{}'); + + const registry = new ProjectRegistry(registryPath); + await registry.initialize(); + + // 2. It should not crash and should allow adding new projects + const projectPath = path.join(tempDir, 'my-project'); + const shortId = await registry.getShortId(projectPath); + + expect(shortId).toBe('my-project'); + + // 3. Verify it healed the file + const data = JSON.parse(fs.readFileSync(registryPath, 'utf8')); + expect(data.projects).toBeDefined(); + expect(data.projects[normalizePath(projectPath)]).toBe('my-project'); + }); + + it('recovers gracefully if registry projects property is an array (invalid schema)', async () => { + // 1. Write an object where 'projects' is an array + fs.writeFileSync(registryPath, JSON.stringify({ projects: [] })); + + const registry = new ProjectRegistry(registryPath); + await registry.initialize(); + + // 2. It should reset and allow adding new projects correctly + const projectPath = path.join(tempDir, 'my-project'); + const shortId = await registry.getShortId(projectPath); + + expect(shortId).toBe('my-project'); + + // 3. Verify it healed the file to an object + const data = JSON.parse(fs.readFileSync(registryPath, 'utf8')); + expect(data.projects).toBeDefined(); + expect(Array.isArray(data.projects)).toBe(false); + expect(data.projects[normalizePath(projectPath)]).toBe('my-project'); + }); + + it('recovers gracefully if registry contains malicious slugs (path traversal)', async () => { + // 1. Write a registry with a path traversal slug + fs.writeFileSync( + registryPath, + JSON.stringify({ projects: { '/some/path': '../../etc/passwd' } }), + ); + + const registry = new ProjectRegistry(registryPath); + await registry.initialize(); + + // 2. It should identify as invalid and reset + const projectPath = path.join(tempDir, 'my-project'); + const shortId = await registry.getShortId(projectPath); + + expect(shortId).toBe('my-project'); + + // 3. Verify it healed the file and didn't preserve the malicious entry + const data = JSON.parse(fs.readFileSync(registryPath, 'utf8')); + expect(data.projects[normalizePath(projectPath)]).toBe('my-project'); + expect(Object.values(data.projects)).not.toContain('../../etc/passwd'); + }); }); diff --git a/packages/core/src/config/projectRegistry.ts b/packages/core/src/config/projectRegistry.ts index 1aec0b7ad2..9b816583eb 100644 --- a/packages/core/src/config/projectRegistry.ts +++ b/packages/core/src/config/projectRegistry.ts @@ -9,6 +9,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { lock } from 'proper-lockfile'; +import { z } from 'zod'; import { debugLogger } from '../utils/debugLogger.js'; import { isNodeError } from '../utils/errors.js'; @@ -16,6 +17,10 @@ export interface RegistryData { projects: Record; } +const registryDataSchema = z.object({ + projects: z.record(z.string(), z.string().regex(/^[a-z0-9-]+$/)), +}); + const PROJECT_ROOT_FILE = '.project_root'; const LOCK_TIMEOUT_MS = 10000; const LOCK_RETRY_DELAY_MS = 100; @@ -57,8 +62,16 @@ export class ProjectRegistry { private async loadData(): Promise { try { const content = await fs.promises.readFile(this.registryPath, 'utf8'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return JSON.parse(content); + const parsed: unknown = JSON.parse(content); + + if (this.isValidRegistryData(parsed)) { + return parsed; + } + + debugLogger.warn( + `Project registry at ${this.registryPath} has an invalid schema, resetting to empty.`, + ); + return { projects: {} }; } catch (error: unknown) { if (isNodeError(error) && error.code === 'ENOENT') { return { projects: {} }; // Normal first run @@ -407,4 +420,8 @@ export class ProjectRegistry { .replace(/^-|-$/g, '') || 'project' ); } + + private isValidRegistryData(data: unknown): data is RegistryData { + return registryDataSchema.safeParse(data).success; + } }