From 909ca76e18f81dfdf3272e10244fbac2871aa990 Mon Sep 17 00:00:00 2001 From: gonzaloriestra <14979109+gonzaloriestra@users.noreply.github.com> Date: Fri, 22 May 2026 01:03:07 +0000 Subject: [PATCH 1/2] [Security] Harden relative path sanitization Hardens sanitizeRelativePath to strip leading slashes, multiple slashes, and Windows drive letters. This prevents absolute path injection from escaping intended base directories. Added regression tests in path.test.ts. --- packages/cli-kit/src/public/node/path.test.ts | 54 ++++++++++++++++++- packages/cli-kit/src/public/node/path.ts | 11 +++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/cli-kit/src/public/node/path.test.ts b/packages/cli-kit/src/public/node/path.test.ts index 4972b3ac276..3f0b1b44c5a 100644 --- a/packages/cli-kit/src/public/node/path.test.ts +++ b/packages/cli-kit/src/public/node/path.test.ts @@ -1,5 +1,5 @@ -import {relativizePath, normalizePath, cwd, sniffForPath, commonParentDirectory} from './path.js' -import {describe, test, expect} from 'vitest' +import {relativizePath, normalizePath, cwd, sniffForPath, commonParentDirectory, sanitizeRelativePath} from './path.js' +import {describe, test, expect, vi} from 'vitest' describe('relativize', () => { test('relativizes the path', () => { @@ -93,3 +93,53 @@ describe('sniffForPath', () => { expect(path).toStrictEqual('/path/to/project') }) }) + +describe('sanitizeRelativePath', () => { + test('strips traversal segments', () => { + const warn = vi.fn() + expect(sanitizeRelativePath('a/../../b', warn)).toBe('b') + expect(warn).toHaveBeenCalled() + }) + + test('strips leading slashes (absolute Unix paths)', () => { + const warn = vi.fn() + expect(sanitizeRelativePath('/etc/passwd', warn)).toBe('etc/passwd') + expect(warn).toHaveBeenCalled() + }) + + test('strips multiple leading slashes', () => { + const warn = vi.fn() + expect(sanitizeRelativePath('//etc/passwd', warn)).toBe('etc/passwd') + expect(warn).toHaveBeenCalled() + }) + + test('strips Windows drive letters', () => { + const warn = vi.fn() + expect(sanitizeRelativePath('C:/Windows/System32', warn)).toBe('Windows/System32') + expect(warn).toHaveBeenCalled() + }) + + test('handles Windows backslashes', () => { + const warn = vi.fn() + expect(sanitizeRelativePath('C:\\Windows\\System32', warn)).toBe('Windows/System32') + expect(warn).toHaveBeenCalled() + }) + + test('collapses internal current directory markers', () => { + const warn = vi.fn() + expect(sanitizeRelativePath('a/./b', warn)).toBe('a/b') + expect(warn).not.toHaveBeenCalled() + }) + + test('returns empty string for empty result', () => { + const warn = vi.fn() + expect(sanitizeRelativePath('..', warn)).toBe('') + expect(warn).toHaveBeenCalled() + }) + + test('returns empty string for single slash', () => { + const warn = vi.fn() + expect(sanitizeRelativePath('/', warn)).toBe('') + expect(warn).toHaveBeenCalled() + }) +}) diff --git a/packages/cli-kit/src/public/node/path.ts b/packages/cli-kit/src/public/node/path.ts index f3721780110..93349a59957 100644 --- a/packages/cli-kit/src/public/node/path.ts +++ b/packages/cli-kit/src/public/node/path.ts @@ -224,13 +224,20 @@ export function sanitizeRelativePath(input: string, warn: (msg: string) => void) if (seg === '..') { stripped = true stack.pop() - } else if (seg !== '.') { + } else if (seg === '.' || seg === '') { + // Skip empty segments (leading/multiple slashes) and current directory markers. + // This prevents absolute paths like '/etc/passwd' from being treated as such. + if (seg === '') stripped = true + } else if (/^[a-zA-Z]:$/.test(seg)) { + // Skip Windows drive letters (e.g. 'C:') to prevent escaping to other drives. + stripped = true + } else { stack.push(seg) } } const result = stack.join('/') if (stripped) { - warn(`Warning: path '${input}' contains '..' traversal — sanitized to '${result || '.'}'\n`) + warn(`Warning: path '${input}' contains traversal or absolute segments — sanitized to '${result || '.'}'\n`) } return result } From 0066ba69b77f220bd45aebf5f5f4eac237f851df Mon Sep 17 00:00:00 2001 From: gonzaloriestra <14979109+gonzaloriestra@users.noreply.github.com> Date: Mon, 25 May 2026 11:30:27 +0000 Subject: [PATCH 2/2] [Security] Harden relative path sanitization Hardens sanitizeRelativePath to prevent potential absolute path escapes and directory traversal. - Strips leading and multiple slashes (Unix absolute paths). - Strips Windows drive letters (e.g., C:). - Strips empty segments. - Added comprehensive unit tests for traversal and absolute addressing edge cases. --- .jules/sentinel.md | 7 +++++++ packages/cli-kit/src/public/node/path.test.ts | 17 +++++------------ 2 files changed, 12 insertions(+), 12 deletions(-) create mode 100644 .jules/sentinel.md diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 00000000000..a23f313effe --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,7 @@ +## 2026-05-25 - Harden sanitizeRelativePath + +**Context**: Identified a directory traversal vulnerability in `sanitizeRelativePath` (`packages/cli-kit/src/public/node/path.ts`) where absolute paths and Windows drive letters were not stripped, potentially allowing escapes when joined. + +**Action**: Hardened `sanitizeRelativePath` to strip empty segments (leading/multiple slashes) and Windows drive letters. Updated JSDoc and the warning message to reflect these changes. + +**Learning**: When sanitizing paths intended to be relative, always strip leading slashes and platform-specific root markers (like drive letters) to prevent absolute path escapes. diff --git a/packages/cli-kit/src/public/node/path.test.ts b/packages/cli-kit/src/public/node/path.test.ts index 3f0b1b44c5a..767ac479798 100644 --- a/packages/cli-kit/src/public/node/path.test.ts +++ b/packages/cli-kit/src/public/node/path.test.ts @@ -97,49 +97,42 @@ describe('sniffForPath', () => { describe('sanitizeRelativePath', () => { test('strips traversal segments', () => { const warn = vi.fn() - expect(sanitizeRelativePath('a/../../b', warn)).toBe('b') - expect(warn).toHaveBeenCalled() + expect(sanitizeRelativePath('../../etc/passwd', warn)).toBe('etc/passwd') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('contains traversal or absolute segments')) }) test('strips leading slashes (absolute Unix paths)', () => { const warn = vi.fn() expect(sanitizeRelativePath('/etc/passwd', warn)).toBe('etc/passwd') - expect(warn).toHaveBeenCalled() }) test('strips multiple leading slashes', () => { const warn = vi.fn() expect(sanitizeRelativePath('//etc/passwd', warn)).toBe('etc/passwd') - expect(warn).toHaveBeenCalled() }) test('strips Windows drive letters', () => { const warn = vi.fn() expect(sanitizeRelativePath('C:/Windows/System32', warn)).toBe('Windows/System32') - expect(warn).toHaveBeenCalled() }) test('handles Windows backslashes', () => { const warn = vi.fn() - expect(sanitizeRelativePath('C:\\Windows\\System32', warn)).toBe('Windows/System32') - expect(warn).toHaveBeenCalled() + expect(sanitizeRelativePath('..\\..\\etc\\passwd', warn)).toBe('etc/passwd') }) test('collapses internal current directory markers', () => { const warn = vi.fn() - expect(sanitizeRelativePath('a/./b', warn)).toBe('a/b') - expect(warn).not.toHaveBeenCalled() + expect(sanitizeRelativePath('foo/./bar', warn)).toBe('foo/bar') }) test('returns empty string for empty result', () => { const warn = vi.fn() - expect(sanitizeRelativePath('..', warn)).toBe('') - expect(warn).toHaveBeenCalled() + expect(sanitizeRelativePath('../..', warn)).toBe('') }) test('returns empty string for single slash', () => { const warn = vi.fn() expect(sanitizeRelativePath('/', warn)).toBe('') - expect(warn).toHaveBeenCalled() }) })