diff --git a/packages/cli-kit/src/public/node/path.test.ts b/packages/cli-kit/src/public/node/path.test.ts index 4972b3ac27..1cb801b5a9 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,27 @@ describe('sniffForPath', () => { expect(path).toStrictEqual('/path/to/project') }) }) + +describe('sanitizeRelativePath', () => { + test('returns the path if it is relative and has no traversal', () => { + // Given + const path = 'some/path' + const warn = vi.fn() + + // When + const got = sanitizeRelativePath(path, warn) + + // Then + expect(got).toBe('some/path') + expect(warn).not.toHaveBeenCalled() + }) + + test('strips traversal and absolute path segments and warns', () => { + const warn = vi.fn() + expect(sanitizeRelativePath('some/../path', warn)).toBe('path') + expect(sanitizeRelativePath('/etc/passwd', warn)).toBe('etc/passwd') + expect(sanitizeRelativePath('\\some\\path', warn)).toBe('some/path') + expect(sanitizeRelativePath('C:\\Windows', warn)).toBe('Windows') + expect(warn).toHaveBeenCalledTimes(4) + }) +}) diff --git a/packages/cli-kit/src/public/node/path.ts b/packages/cli-kit/src/public/node/path.ts index f372178011..c29b4d94c1 100644 --- a/packages/cli-kit/src/public/node/path.ts +++ b/packages/cli-kit/src/public/node/path.ts @@ -217,20 +217,26 @@ export function sniffForJson(argv = process.argv): boolean { * @returns The sanitized path (may be an empty string if all segments were traversal). */ export function sanitizeRelativePath(input: string, warn: (msg: string) => void): string { - const segments = input.replace(/\\/g, '/').split('/') + const normalized = input.replace(/\\/g, '/') + const segments = normalized.split('/') const stack: string[] = [] let stripped = false + + if (normalized.startsWith('/') || /^[a-zA-Z]:/.test(normalized)) { + stripped = true + } + for (const seg of segments) { if (seg === '..') { stripped = true stack.pop() - } else if (seg !== '.') { + } else if (seg !== '.' && seg !== '' && !/^[a-zA-Z]:$/.test(seg)) { stack.push(seg) } } const result = stack.join('/') if (stripped) { - warn(`Warning: path '${input}' contains '..' traversal — sanitized to '${result || '.'}'\n`) + warn(`Warning: path '${input}' is insecure (contains '..' or is absolute) — sanitized to '${result || '.'}'\n`) } return result }