From 7c72c21476e5b133f1348e3183e4ab4ad7b9b66f Mon Sep 17 00:00:00 2001 From: josstei <48696594+josstei@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:53:37 -0400 Subject: [PATCH] fix: reject cross-platform absolute paths --- claude/src/lib/validation/index.js | 6 +++++- claude/src/state/session-state.js | 6 +++++- plugins/maestro/src/lib/validation/index.js | 6 +++++- plugins/maestro/src/state/session-state.js | 6 +++++- src/lib/validation/index.js | 6 +++++- src/state/session-state.js | 6 +++++- tests/unit/lib-validation.test.js | 21 +++++++++++++++++++++ tests/unit/session-state.test.js | 14 ++++++++++++++ 8 files changed, 65 insertions(+), 6 deletions(-) diff --git a/claude/src/lib/validation/index.js b/claude/src/lib/validation/index.js index ba9a74d7..a842baaf 100644 --- a/claude/src/lib/validation/index.js +++ b/claude/src/lib/validation/index.js @@ -6,6 +6,10 @@ const { ValidationError } = require('../errors'); const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; +function isAbsolutePath(p) { + return path.posix.isAbsolute(p) || path.win32.isAbsolute(p); +} + /** * @param {*} value * @param {string} label @@ -68,7 +72,7 @@ function assertRelativePath(p) { }); } - if (path.isAbsolute(p)) { + if (isAbsolutePath(p)) { throw new ValidationError('Path must be relative', { details: { value: p }, }); diff --git a/claude/src/state/session-state.js b/claude/src/state/session-state.js index 4d17d13f..e0b9d980 100644 --- a/claude/src/state/session-state.js +++ b/claude/src/state/session-state.js @@ -6,8 +6,12 @@ const { atomicWriteSync } = require('../lib/io'); const DEFAULT_STATE_DIR = 'docs/maestro'; +function isAbsolutePath(filePath) { + return path.posix.isAbsolute(filePath) || path.win32.isAbsolute(filePath); +} + function validateRelativePath(filePath) { - if (path.isAbsolute(filePath)) { + if (isAbsolutePath(filePath)) { throw new Error('Path must be relative'); } const segments = filePath.split(/[/\\]/); diff --git a/plugins/maestro/src/lib/validation/index.js b/plugins/maestro/src/lib/validation/index.js index ba9a74d7..a842baaf 100644 --- a/plugins/maestro/src/lib/validation/index.js +++ b/plugins/maestro/src/lib/validation/index.js @@ -6,6 +6,10 @@ const { ValidationError } = require('../errors'); const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; +function isAbsolutePath(p) { + return path.posix.isAbsolute(p) || path.win32.isAbsolute(p); +} + /** * @param {*} value * @param {string} label @@ -68,7 +72,7 @@ function assertRelativePath(p) { }); } - if (path.isAbsolute(p)) { + if (isAbsolutePath(p)) { throw new ValidationError('Path must be relative', { details: { value: p }, }); diff --git a/plugins/maestro/src/state/session-state.js b/plugins/maestro/src/state/session-state.js index 4d17d13f..e0b9d980 100644 --- a/plugins/maestro/src/state/session-state.js +++ b/plugins/maestro/src/state/session-state.js @@ -6,8 +6,12 @@ const { atomicWriteSync } = require('../lib/io'); const DEFAULT_STATE_DIR = 'docs/maestro'; +function isAbsolutePath(filePath) { + return path.posix.isAbsolute(filePath) || path.win32.isAbsolute(filePath); +} + function validateRelativePath(filePath) { - if (path.isAbsolute(filePath)) { + if (isAbsolutePath(filePath)) { throw new Error('Path must be relative'); } const segments = filePath.split(/[/\\]/); diff --git a/src/lib/validation/index.js b/src/lib/validation/index.js index ba9a74d7..a842baaf 100644 --- a/src/lib/validation/index.js +++ b/src/lib/validation/index.js @@ -6,6 +6,10 @@ const { ValidationError } = require('../errors'); const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; +function isAbsolutePath(p) { + return path.posix.isAbsolute(p) || path.win32.isAbsolute(p); +} + /** * @param {*} value * @param {string} label @@ -68,7 +72,7 @@ function assertRelativePath(p) { }); } - if (path.isAbsolute(p)) { + if (isAbsolutePath(p)) { throw new ValidationError('Path must be relative', { details: { value: p }, }); diff --git a/src/state/session-state.js b/src/state/session-state.js index 4d17d13f..e0b9d980 100644 --- a/src/state/session-state.js +++ b/src/state/session-state.js @@ -6,8 +6,12 @@ const { atomicWriteSync } = require('../lib/io'); const DEFAULT_STATE_DIR = 'docs/maestro'; +function isAbsolutePath(filePath) { + return path.posix.isAbsolute(filePath) || path.win32.isAbsolute(filePath); +} + function validateRelativePath(filePath) { - if (path.isAbsolute(filePath)) { + if (isAbsolutePath(filePath)) { throw new Error('Path must be relative'); } const segments = filePath.split(/[/\\]/); diff --git a/tests/unit/lib-validation.test.js b/tests/unit/lib-validation.test.js index 88f6aed4..515d283d 100644 --- a/tests/unit/lib-validation.test.js +++ b/tests/unit/lib-validation.test.js @@ -269,6 +269,27 @@ describe('assertRelativePath', () => { ); }); + it('throws for an absolute Windows drive path', () => { + assertThrowsValidation( + () => assertRelativePath('C:\\Temp\\file.txt'), + 'Path must be relative' + ); + }); + + it('throws for an absolute Windows UNC path', () => { + assertThrowsValidation( + () => assertRelativePath('\\\\server\\share\\file.txt'), + 'Path must be relative' + ); + }); + + it('throws for an absolute Windows rooted path', () => { + assertThrowsValidation( + () => assertRelativePath('\\Temp\\file.txt'), + 'Path must be relative' + ); + }); + it('throws for path traversal with leading ..', () => { assertThrowsValidation( () => assertRelativePath('../outside'), diff --git a/tests/unit/session-state.test.js b/tests/unit/session-state.test.js index e1454a95..46b30489 100644 --- a/tests/unit/session-state.test.js +++ b/tests/unit/session-state.test.js @@ -153,6 +153,13 @@ describe('session-state', () => { ); }); + it('readState throws for absolute Windows paths', () => { + assert.throws( + () => readState('C:\\Temp\\state.md', tmpRoot), + /Path must be relative/ + ); + }); + it('readState throws for paths with ..', () => { assert.throws( () => readState('foo/../bar', tmpRoot), @@ -174,6 +181,13 @@ describe('session-state', () => { ); }); + it('writeState throws for absolute Windows UNC paths', () => { + assert.throws( + () => writeState('\\\\server\\share\\state.md', 'content', tmpRoot), + /Path must be relative/ + ); + }); + it('writeState throws for paths with ..', () => { assert.throws( () => writeState('foo/../bar', 'content', tmpRoot),