Skip to content

Commit f9446fa

Browse files
committed
Encode distribution paths to prevent package owners escaping storage containment
1 parent bc2807d commit f9446fa

2 files changed

Lines changed: 85 additions & 15 deletions

File tree

‎src/Package/PackageDistributionResolver.php‎

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use CodedMonkey\Dirigent\Message\ResolveDistribution;
1414
use Symfony\Component\DependencyInjection\Attribute\Autowire;
1515
use Symfony\Component\Filesystem\Filesystem;
16+
use Symfony\Component\Filesystem\Path;
1617
use Symfony\Component\Lock\LockFactory;
1718
use Symfony\Component\Lock\SharedLockInterface;
1819
use Symfony\Component\Messenger\MessageBusInterface;
@@ -34,7 +35,7 @@ public function __construct(
3435
string $storagePath,
3536
) {
3637
$this->filesystem = new Filesystem();
37-
$this->storagePath = "$storagePath/distribution";
38+
$this->storagePath = Path::canonicalize("$storagePath/distribution");
3839
}
3940

4041
public function exists(Metadata $metadata, string $type): bool
@@ -44,12 +45,20 @@ public function exists(Metadata $metadata, string $type): bool
4445

4546
public function path(Metadata $metadata, string $type): string
4647
{
47-
$packageName = $metadata->getPackage()->getName();
48-
$versionName = $metadata->getNormalizedVersionName();
48+
$packageName = explode('/', $metadata->getPackage()->getName(), 2)
49+
|> (fn($x) => array_map($this->encodePathComponent(...), $x))
50+
|> (fn($x) => implode('/', $x));
51+
$versionName = $this->encodePathComponent($metadata->getNormalizedVersionName());
4952
$revision = $metadata->getRevision();
50-
$reference = $metadata->getReference();
53+
$reference = $this->encodePathComponent($metadata->getReference());
54+
$type = $this->encodePathComponent($type);
5155

52-
return "{$this->storagePath}/{$packageName}/{$versionName}-r{$revision}-{$reference}.{$type}";
56+
$path = Path::canonicalize("{$this->storagePath}/{$packageName}/{$versionName}-r{$revision}-{$reference}.{$type}");
57+
if (!Path::isBasePath($this->storagePath, $path) || $this->storagePath === $path) {
58+
throw new \RuntimeException('Distribution path is outside the configured storage directory.');
59+
}
60+
61+
return $path;
5362
}
5463

5564
public function remove(Distribution $distribution): void
@@ -60,16 +69,11 @@ public function remove(Distribution $distribution): void
6069
try {
6170
$this->filesystem->remove($path);
6271

63-
// Remove the package directory if it's empty
64-
$packageDirectory = dirname($path);
65-
if (is_dir($packageDirectory) && !new \FilesystemIterator($packageDirectory)->valid()) {
66-
$this->filesystem->remove($packageDirectory);
67-
}
68-
69-
// Remove the vendor directory if it's empty
70-
$vendorDirectory = dirname($packageDirectory);
71-
if (is_dir($vendorDirectory) && !new \FilesystemIterator($vendorDirectory)->valid()) {
72-
$this->filesystem->remove($vendorDirectory);
72+
// Remove parent directories that aren't empty
73+
$directory = dirname($path);
74+
while ($this->storagePath !== $directory && Path::isBasePath($this->storagePath, $directory) && is_dir($directory) && !new \FilesystemIterator($directory)->valid()) {
75+
$this->filesystem->remove($directory);
76+
$directory = dirname($directory);
7377
}
7478
} finally {
7579
$lock->release();
@@ -165,4 +169,16 @@ private function fileExists(string $path): bool
165169
{
166170
return $this->filesystem->exists($path);
167171
}
172+
173+
private function encodePathComponent(string $component): string
174+
{
175+
$encodedComponent = rawurlencode($component);
176+
177+
return match ($encodedComponent) {
178+
'' => '%00',
179+
'.' => '%2E',
180+
'..' => '%2E%2E',
181+
default => $encodedComponent,
182+
};
183+
}
168184
}

‎tests/UnitTests/Package/PackageDistributionResolverTest.php‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use PHPUnit\Framework\Attributes\CoversClass;
1313
use PHPUnit\Framework\TestCase;
1414
use Symfony\Component\Filesystem\Filesystem;
15+
use Symfony\Component\Filesystem\Path;
1516
use Symfony\Component\Lock\LockFactory;
1617
use Symfony\Component\Lock\SharedLockInterface;
1718
use Symfony\Component\Messenger\MessageBusInterface;
@@ -35,6 +36,30 @@ protected function tearDown(): void
3536
new Filesystem()->remove($this->storagePath);
3637
}
3738

39+
public function testPathEncodesMetadataAndKeepsTraversalReferenceInsideStorage(): void
40+
{
41+
[$package, , $metadata] = $this->createMockPackageWithMetadata();
42+
$package->setName('../outside');
43+
$metadata->setNormalizedVersionName('../../version');
44+
$metadata->setDistributionReference('../../../archive');
45+
46+
$resolver = new PackageDistributionResolver(
47+
$this->createStub(MessageBusInterface::class),
48+
$this->createStub(ComposerClient::class),
49+
$this->createStub(DistributionRepository::class),
50+
$this->createStub(LockFactory::class),
51+
true,
52+
$this->storagePath,
53+
);
54+
55+
$path = $resolver->path($metadata, '../zip');
56+
57+
self::assertTrue(Path::isBasePath($this->storagePath . '/distribution', $path));
58+
self::assertStringNotContainsString('../', $path);
59+
self::assertStringNotContainsString('../../../archive', $path);
60+
self::assertStringContainsString('..%2F..%2F..%2Farchive', $path);
61+
}
62+
3863
public function testRemoveDeletesDistributionFile(): void
3964
{
4065
[, , $metadata] = $this->createMockPackageWithMetadata();
@@ -92,6 +117,35 @@ public function testRemoveKeepsNonEmptyPackageDirectory(): void
92117
self::assertDirectoryExists(dirname($path));
93118
}
94119

120+
public function testRemoveWithTraversalReferenceDoesNotDeleteOutsideDistributionStorage(): void
121+
{
122+
[, , $metadata] = $this->createMockPackageWithMetadata();
123+
$metadata->setDistributionReference('../../../../outside');
124+
$distribution = new Distribution($metadata, 'zip');
125+
126+
$lock = $this->createStub(SharedLockInterface::class);
127+
$lockFactory = $this->createStub(LockFactory::class);
128+
$lockFactory->method('createLock')->willReturn($lock);
129+
130+
$resolver = new PackageDistributionResolver(
131+
$this->createStub(MessageBusInterface::class),
132+
$this->createStub(ComposerClient::class),
133+
$this->createStub(DistributionRepository::class),
134+
$lockFactory,
135+
true,
136+
$this->storagePath,
137+
);
138+
139+
$distributionPath = $this->dumpStubDistribution($resolver, $distribution);
140+
$outsidePath = $this->storagePath . '/outside.zip';
141+
new Filesystem()->dumpFile($outsidePath, 'outside');
142+
143+
$resolver->remove($distribution);
144+
145+
self::assertFileDoesNotExist($distributionPath);
146+
self::assertFileExists($outsidePath);
147+
}
148+
95149
private function dumpStubDistribution(PackageDistributionResolver $resolver, Distribution $distribution): string
96150
{
97151
$path = $resolver->path($distribution->getMetadata(), $distribution->getType());

0 commit comments

Comments
 (0)