From 4dc27cb86f3f37dd30930719b53f7f87a075a4e1 Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Tue, 5 May 2026 09:50:55 +0200 Subject: [PATCH] feat: validate script mapping paths --- src/Service/PathNormalizer.php | 44 +++++++++++++++++++++++++ tests/Service/PathNormalizerTest.php | 48 ++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/Service/PathNormalizer.php create mode 100644 tests/Service/PathNormalizerTest.php diff --git a/src/Service/PathNormalizer.php b/src/Service/PathNormalizer.php new file mode 100644 index 0000000..3cc3eed --- /dev/null +++ b/src/Service/PathNormalizer.php @@ -0,0 +1,44 @@ +relativePath($path); + + if (!str_ends_with($normalized, '.sh')) { + throw new InvalidArgumentException('Public path must end with .sh.'); + } + + return $normalized; + } + + public function repositoryPath(string $path): string + { + return $this->relativePath($path); + } + + private function relativePath(string $path): string + { + $path = trim(str_replace('\\', '/', $path)); + $path = ltrim($path, '/'); + + if ($path === '') { + throw new InvalidArgumentException('Path cannot be empty.'); + } + + $segments = explode('/', $path); + + foreach ($segments as $segment) { + if ($segment === '' || $segment === '.' || $segment === '..') { + throw new InvalidArgumentException('Path contains an invalid segment.'); + } + } + + return implode('/', $segments); + } +} diff --git a/tests/Service/PathNormalizerTest.php b/tests/Service/PathNormalizerTest.php new file mode 100644 index 0000000..a3376b8 --- /dev/null +++ b/tests/Service/PathNormalizerTest.php @@ -0,0 +1,48 @@ +normalizer = new PathNormalizer(); + } + + public function testPublicPathIsNormalized(): void + { + self::assertSame('mcp/graylog/install.sh', $this->normalizer->publicPath('/mcp/graylog/install.sh')); + } + + public function testPublicPathMustEndWithShellExtension(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->normalizer->publicPath('mcp/graylog/install.txt'); + } + + public function testPublicPathRejectsTraversal(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->normalizer->publicPath('mcp/../install.sh'); + } + + public function testRepositoryPathAllowsNestedFileWithoutShellRequirement(): void + { + self::assertSame('scripts/install', $this->normalizer->repositoryPath('/scripts/install')); + } + + public function testRepositoryPathRejectsTraversal(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->normalizer->repositoryPath('../install.sh'); + } +}