Skip to content

Commit f231f15

Browse files
committed
bug #7554 Improve the security of the File upload filed (javiereguiluz)
This PR was merged into the 4.x branch. Discussion ---------- Improve the security of the File upload filed Similar to #7551, #7552 and #7553. This hardens `FileUploadType` against path-traversal risks flagged by the Claude security audit. Commits ------- cff061a Improve the security of the File upload filed
2 parents 5641b29 + cff061a commit f231f15

3 files changed

Lines changed: 123 additions & 1 deletion

File tree

src/Form/DataTransformer/StringToFileTransformer.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,40 @@ private function doTransform(mixed $value): ?File
7575
throw new TransformationFailedException('Expected a string or null.');
7676
}
7777

78+
if (self::isUnsafeStoredPath($value)) {
79+
return null;
80+
}
81+
7882
if (is_file($this->uploadDir.$value)) {
7983
return new File($this->uploadDir.$value);
8084
}
8185

8286
return null;
8387
}
8488

89+
private static function isUnsafeStoredPath(string $value): bool
90+
{
91+
// reject empty values or null bytes
92+
if ('' === $value || str_contains($value, "\0")) {
93+
return true;
94+
}
95+
96+
// reject absolute paths (Unix, UNC, Windows drive letter)
97+
$normalized = str_replace('\\', '/', $value);
98+
if (str_starts_with($normalized, '/') || 1 === preg_match('#^[a-zA-Z]:/#', $normalized)) {
99+
return true;
100+
}
101+
102+
// reject any `..` segment anywhere in the path
103+
foreach (explode('/', $normalized) as $segment) {
104+
if ('..' === $segment) {
105+
return true;
106+
}
107+
}
108+
109+
return false;
110+
}
111+
85112
private function doReverseTransform(mixed $value): ?string
86113
{
87114
if (null === $value) {

src/Form/Type/FileUploadType.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,16 @@ public function configureOptions(OptionsResolver $resolver): void
8787
unlink($file->getPathname());
8888
};
8989

90-
$uploadFilename = static fn (UploadedFile $file): string => $file->getClientOriginalName();
90+
// the return value MUST be a safe relative path:
91+
// * no ".." segments
92+
// * no leading "/" or "\"
93+
// * no Windows drive letters
94+
// * no null bytes
95+
//
96+
// values that violate this contract are rejected on read (see
97+
// StringToFileTransformer::doTransform) and the form behaves as if
98+
// no file were stored. Overrides must preserve this contract.
99+
$uploadFilename = static fn (UploadedFile $file): string => basename(str_replace('\\', '/', $file->getClientOriginalName()));
91100

92101
$uploadValidate = static function (string $filename): string {
93102
if (!file_exists($filename)) {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Unit\Form\DataTransformer;
4+
5+
use EasyCorp\Bundle\EasyAdminBundle\Form\DataTransformer\StringToFileTransformer;
6+
use PHPUnit\Framework\TestCase;
7+
use Symfony\Component\Filesystem\Filesystem;
8+
use Symfony\Component\HttpFoundation\File\File;
9+
10+
class StringToFileTransformerTest extends TestCase
11+
{
12+
private string $uploadDir;
13+
private Filesystem $filesystem;
14+
15+
protected function setUp(): void
16+
{
17+
$this->filesystem = new Filesystem();
18+
$this->uploadDir = sys_get_temp_dir().'/ea_stf_'.bin2hex(random_bytes(6)).'/';
19+
$this->filesystem->mkdir($this->uploadDir.'subdir');
20+
file_put_contents($this->uploadDir.'normal.txt', 'hello');
21+
file_put_contents($this->uploadDir.'subdir/normal.txt', 'hello');
22+
}
23+
24+
protected function tearDown(): void
25+
{
26+
$this->filesystem->remove($this->uploadDir);
27+
}
28+
29+
public function testTransformReturnsNullForMissingFile(): void
30+
{
31+
$transformer = $this->createTransformer();
32+
33+
self::assertNull($transformer->transform('does-not-exist.txt'));
34+
}
35+
36+
/**
37+
* @dataProvider cleanRelativePathProvider
38+
*/
39+
public function testTransformReturnsFileForCleanRelativePath(string $value): void
40+
{
41+
$transformer = $this->createTransformer();
42+
43+
$result = $transformer->transform($value);
44+
45+
self::assertInstanceOf(File::class, $result);
46+
}
47+
48+
public static function cleanRelativePathProvider(): iterable
49+
{
50+
yield 'bare filename' => ['normal.txt'];
51+
yield 'relative subpath' => ['subdir/normal.txt'];
52+
}
53+
54+
/**
55+
* @dataProvider unsafeStoredPathProvider
56+
*/
57+
public function testTransformRejectsUnsafeStoredPath(string $value): void
58+
{
59+
$transformer = $this->createTransformer();
60+
61+
self::assertNull($transformer->transform($value));
62+
}
63+
64+
public static function unsafeStoredPathProvider(): iterable
65+
{
66+
yield 'empty string' => [''];
67+
yield 'parent traversal' => ['../../../etc/passwd'];
68+
yield 'parent traversal inside subpath' => ['subdir/../../../etc/passwd'];
69+
yield 'unix absolute path' => ['/etc/passwd'];
70+
yield 'windows drive absolute' => ['C:/Windows/win.ini'];
71+
yield 'windows backslash drive absolute' => ['C:\\Windows\\win.ini'];
72+
yield 'backslash parent traversal' => ['sub\\..\\..\\etc'];
73+
yield 'null byte' => ["file\0.txt"];
74+
yield 'leading backslash' => ['\\etc\\passwd'];
75+
}
76+
77+
private function createTransformer(): StringToFileTransformer
78+
{
79+
return new StringToFileTransformer(
80+
$this->uploadDir,
81+
static fn ($file) => 'noop',
82+
static fn (string $filename) => $filename,
83+
false,
84+
);
85+
}
86+
}

0 commit comments

Comments
 (0)