Security context
MediumGHSA-78fx-h6xr-vch4 CVE-2025-27515CWE-155Published Mar 5, 2025

Laravel has a File Validation Bypass

Research this vulnerability

Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.

Affected versions

12.0.0 → fixed in 12.1.111.0.0 → fixed in 11.44.10 → fixed in 10.48.29

Details

When using wildcard validation to validate a given file or image field array (`files.*`), a user-crafted malicious request could potentially bypass the validation rules.

The fix

[11.x] Add valid values to ensure method (#54840)

Lance Pioch· Feb 28, 2025, 04:55 PM+18417fd9681ffbb
src/Illuminate/Collections/Traits/EnumeratesValues.php+1 1
@@ -342,7 +342,7 @@ public function value($key, $default = null)
*
* @template TEnsureOfType
*
- * @param class-string<TEnsureOfType>|array<array-key, class-string<TEnsureOfType>> $type
+ * @param class-string<TEnsureOfType>|array<array-key, class-string<TEnsureOfType>>|scalar|'array'|'null' $type
* @return static<TKey, TEnsureOfType>
*
* @throws \UnexpectedValueException
certain rule classes (#54845)
src/Illuminate/Validation/Validator.php | 41 +++++++++-----
.../Validation/Rules/EmailValidationTest.php | 49 +++++++++++++++++
.../Validation/Rules/FileValidationTest.php | 54 +++++++++++++++++++
.../Rules/PasswordValidationTest.php | 49 +++++++++++++++++
4 files changed, 180 insertions(+), 13 deletions(-)
create mode 100644 tests/Integration/Validation/Rules/EmailValidationTest.php
create mode 100644 tests/Integration/Validation/Rules/FileValidationTest.php
create mode 100644 tests/Integration/Validation/Rules/PasswordValidationTest.php
src/Illuminate/Validation/Validator.php+28 13
@@ -307,11 +307,11 @@ class Validator implements ValidatorContract
protected $defaultNumericRules = ['Numeric', 'Integer', 'Decimal'];
/**
- * The current placeholder for dots in rule keys.
+ * The current random hash for the validator.
*
* @var string
*/
- protected $dotPlaceholder;
+ protected static $placeholderHash;
/**
* The exception to throw upon failure.
@@ -344,7 +344,9 @@ public function __construct(
array $messages = [],
array $attributes = [],
) {
- $this->dotPlaceholder = Str::random();
+ if (! isset(static::$placeholderHash)) {
+ static::$placeholderHash = Str::random();
+ }
$this->initialRules = $rules;
$this->translator = $translator;
@@ -372,7 +374,7 @@ public function parseData(array $data)
$key = str_replace(
['.', '*'],
- [$this->dotPlaceholder, '__asterisk__'],
+ ['__dot__'.static::$placeholderHash, '__asterisk__'.static::$placeholderHash],
$key
);
@@ -410,7 +412,7 @@ protected function replacePlaceholders($data)
protected function replacePlaceholderInString(string $value)
{
return str_replace(
- [$this->dotPlaceholder, '__asterisk__'],
+ ['__dot__'.static::$placeholderHash, '__asterisk__'.static::$placeholderHash],
['.', '*'],
$value
);
@@ -425,7 +427,7 @@ protected function replacePlaceholderInString(string $value)
protected function replaceDotPlaceholderInParameters(array $parameters)
{
return array_map(function ($field) {
- return str_replace($this->dotPlaceholder, '.', $field);
+ return str_replace('__dot__'.static::$placeholderHash, '.', $field);
}, $parameters);
}
@@ -746,7 +748,7 @@ protected function getPrimaryAttribute($attribute)
protected function replaceDotInParameters(array $parameters)
{
return array_map(function ($field) {
- return str_replace('\.', $this->dotPlaceholder, $field);
+ return str_replace('\.', '__dot__'.static::$placeholderHash, $field);
}, $parameters);
}
@@ -872,11 +874,24 @@ protected function hasNotFailedPreviousRuleIfPresenceRule($rule, $attribute)
*/
protected function validateUsingCustomRule($attribute, $value, $rule)
{
- $attribute = $this->replacePlaceholderInString($attribute);
+ $originalAttribute = $this->replacePlaceholderInString($attribute);
+
+ $attribute = match (true) {
+ $rule instanceof Rules\Email => $attribute,
+ $rule instanceof Rules\File => $attribute,
+ $rule instanceof Rules\Password => $attribute,
+ default => $originalAttribute,
+ };
$value = is_array($value) ? $this->replacePlaceholders($value) : $value;
if ($rule instanceof ValidatorAwareRule) {
+ if ($attribute !== $originalAttribute) {
+ $this->addCustomAttributes([
+ $attribute => $this->customAttributes[$originalAttribute] ?? $originalAttribute,
+ ]);
+ }
+
$rule->setValidator($this);
}
@@ -889,14 +904,14 @@ protected function validateUsingCustomRule($attribute, $value, $rule)
get_class($rule->invokable()) :
get_class($rule);
- $this->failedRules[$attribute][$ruleClass] = [];
+ $this->failedRules[$originalAttribute][$ruleClass] = [];
- $messages = $this->getFromLocalArray($attribute, $ruleClass) ?? $rule->message();
+ $messages = $this->getFromLocalArray($originalAttribute, $ruleClass) ?? $rule->message();
$messages = $messages ? (array) $messages : [$ruleClass];
foreach ($messages as $key => $message) {
- $key = is_string($key) ? $key : $attribute;
+ $key = is_string($key) ? $key : $originalAttribute;
$this->messages->add($key, $this->makeReplacements(
$message, $key, $ruleClass, []
@@ -1189,7 +1204,7 @@ public function getRulesWithoutPlaceholders()
{
return (new Collection($this->rules))
->mapWithKeys(fn ($value, $key) => [
- str_replace($this->dotPlaceholder, '\\.', $key) => $value,
+ str_replace('__dot__'.static::$placeholderHash, '\\.', $key) => $value,
])
->all();
}
@@ -1203,7 +1218,7 @@ public function getRulesWithoutPlaceholders()
public function setRules(array $rules)
{
$rules = (new Collection($rules))->mapWithKeys(function ($value, $key) {
- return [str_replace('\.', $this->dotPlaceholder, $key) => $value];
+ return [str_replace('\.', '__dot__'.static::$placeholderHash, $key) => $value];
})->toArray();
$this->initialRules = $rules;
tests/Integration/Validation/Rules/EmailValidationTest.php+49 0
@@ -0,0 +1,49 @@
+<?php
+
+namespace Illuminate\Tests\Integration\Validation\Rules;
+
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rules\Email;
+use Orchestra\Testbench\TestCase;
+use PHPUnit\Framework\Attributes\TestWith;
+
+class EmailValidationTest extends TestCase
+{
+ #[TestWith(['0'])]
+ #[TestWith(['.'])]
+ #[TestWith(['*'])]
+ #[TestWith(['__asterisk__'])]
+ public function test_it_can_validate_attribute_as_array(string $attribute)
+ {
+ $validator = Validator::make([
+ 'emails' => [
+ $attribute => 'taylor@laravel.com',
+ ],
+ ], [
+ 'emails.*' => ['required', Email::default()->rfcCompliant()],
+ ]);
+
+ $this->assertTrue($validator->passes());
+ }
+
+ #[TestWith(['0'])]
+ #[TestWith(['.'])]
+ #[TestWith(['*'])]
+ #[TestWith(['__asterisk__'])]
+ public function test_it_can_validate_attribute_as_array_when_validation_should_fails(string $attribute)
+ {
+ $validator = Validator::make([
+ 'emails' => [
+ $attribute => 'taylor[at]laravel.com',
+ ],
+ ], [
+ 'emails.*' => ['required', Email::default()->rfcCompliant()],
+ ]);
+
+ $this->assertFalse($validator->passes());
+
+ $this->assertSame([
+ 0 => __('validation.email', ['attribute' => sprintf('emails.%s', str_replace('_', ' ', $attribute))]),
+ ], $validator->messages()->all());
+ }
+}
tests/Integration/Validation/Rules/FileValidationTest.php+54 0
@@ -0,0 +1,54 @@
+<?php
+
+namespace Illuminate\Tests\Integration\Validation\Rules;
+
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rules\File;
+use Orchestra\Testbench\TestCase;
+use PHPUnit\Framework\Attributes\TestWith;
+
+class FileValidationTest extends TestCase
+{
+ #[TestWith(['0'])]
+ #[TestWith(['.'])]
+ #[TestWith(['*'])]
+ #[TestWith(['__asterisk__'])]
+ public function test_it_can_validate_attribute_as_array(string $attribute)
+ {
+ $file = UploadedFile::fake()->create('laravel.png', 1, 'image/png');
+
+ $validator = Validator::make([
+ 'files' => [
+ $attribute => $file,
+ ],
+ ], [
+ 'files.*' => ['required', File::types(['image/png', 'image/jpeg'])],
+ ]);
+
+ $this->assertTrue($validator->passes());
+ }
+
+ #[TestWith(['0'])]
+ #[TestWith(['.'])]
+ #[TestWith(['*'])]
+ #[TestWith(['__asterisk__'])]
+ public function test_it_can_validate_attribute_as_array_when_validation_should_fails(string $attribute)
+ {
+ $file = UploadedFile::fake()->create('laravel.php', 1, 'image/php');
+
+ $validator = Validator::make([
+ 'files' => [
+ $attribute => $file,
+ ],
+ ], [
+ 'files.*' => ['required', File::types($mimes = ['image/png', 'image/jpeg'])],
+ ]);
+
+ $this->assertFalse($validator->passes());
+
+ $this->assertSame([
+ 0 => __('validation.mimetypes', ['attribute' => sprintf('files.%s', str_replace('_', ' ', $attribute)), 'values' => implode(', ', $mimes)]),
+ ], $validator->messages()->all());
+ }
+}
tests/Integration/Validation/Rules/PasswordValidationTest.php+49 0
@@ -0,0 +1,49 @@
+<?php
+
+namespace Illuminate\Tests\Integration\Validation\Rules;
+
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rules\Password;
+use Orchestra\Testbench\TestCase;
+use PHPUnit\Framework\Attributes\TestWith;
+
+class PasswordValidationTest extends TestCase
+{
+ #[TestWith(['0'])]
+ #[TestWith(['.'])]
+ #[TestWith(['*'])]
+ #[TestWith(['__asterisk__'])]
+ public function test_it_can_validate_attribute_as_array(string $attribute)
+ {
+ $validator = Validator::make([
+ 'passwords' => [
+ $attribute => 'secret',
+ ],
+ ], [
+ 'passwords.*' => ['required', Password::default()->min(6)],
+ ]);
+
+ $this->assertTrue($validator->passes());
+ }
+
+ #[TestWith(['0'])]
+ #[TestWith(['.'])]
+ #[TestWith(['*'])]
+ #[TestWith(['__asterisk__'])]
+ public function test_it_can_validate_attribute_as_array_when_validation_should_fails(string $attribute)
+ {
+ $validator = Validator::make([
+ 'passwords' => [
+ $attribute => 'secret',
+ ],
+ ], [
+ 'passwords.*' => ['required', Password::default()->min(8)],
+ ]);
+
+ $this->assertFalse($validator->passes());
+
+ $this->assertSame([
+ 0 => sprintf('The passwords.%s field must be at least 8 characters.', str_replace('_', ' ', $attribute)),
+ ], $validator->messages()->all());
+ }
+}
resolve application when project name is "vendor" (#54871)
src/Illuminate/Foundation/Application.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
src/Illuminate/Foundation/Application.php+1 1
@@ -257,7 +257,7 @@ public static function inferBasePath()
isset($_ENV['APP_BASE_PATH']) => $_ENV['APP_BASE_PATH'],
default => dirname(array_values(array_filter(
array_keys(ClassLoader::getRegisteredLoaders()),
- fn ($path) => ! str_contains($path, '/vendor/'),
+ fn ($path) => ! str_starts_with($path, 'phar://'),
))[0]),
};
}
composer.json | 2 +-
tests/Integration/Cache/DynamoDbStoreTest.php | 2 +-
tests/Integration/Cookie/CookieTest.php | 2 +-
...ctionWithAfterCommitUsingDatabaseTransactionsTest.php | 2 +-
.../DatabaseEmulatePreparesMariaDbConnectionTest.php | 4 ++--
.../MySql/DatabaseEmulatePreparesMySqlConnectionTest.php | 4 ++--
.../Database/Postgres/PostgresSchemaBuilderTest.php | 4 ++--
tests/Integration/Mail/SendingMarkdownMailTest.php | 2 +-
tests/Integration/Mail/SendingQueuedMailTest.php | 2 +-
tests/Integration/Queue/JobEncryptionTest.php | 4 ++--
tests/Integration/Queue/ModelSerializationTest.php | 2 +-
tests/Integration/Queue/QueueConnectionTest.php | 9 +++------
tests/Integration/View/BladeAnonymousComponentTest.php | 2 +-
tests/Integration/View/BladeTest.php | 2 +-
tests/Integration/View/RenderableViewExceptionTest.php | 2 +-
15 files changed, 21 insertions(+), 24 deletions(-)
composer.json+1 1
@@ -111,7 +111,7 @@
"league/flysystem-read-only": "^3.25.1",
"league/flysystem-sftp-v3": "^3.25.1",
"mockery/mockery": "^1.6.10",
- "orchestra/testbench-core": "^9.9.4",
+ "orchestra/testbench-core": "^9.11.2",
"pda/pheanstalk": "^5.0.6",
"php-http/discovery": "^1.15",
"phpstan/phpstan": "^2.0",
tests/Integration/Cache/DynamoDbStoreTest.php+1 1
@@ -65,7 +65,7 @@ public function testLocksCanBeAcquired()
* @param \Illuminate\Foundation\Application $app
* @return void
*/
- protected function getEnvironmentSetUp($app)
+ protected function defineEnvironment($app)
{
if (! env('DYNAMODB_CACHE_TABLE')) {
$this->markTestSkipped('DynamoDB not configured.');
More files changed — see the full commit.

[10.x] Fix attribute name used on `Validator` instance within

Mior Muhammad Zaki· Mar 10, 2025, 05:30 PM+12912a4f7a8f9b8
src/Illuminate/Validation/Validator.php+26 12
@@ -302,11 +302,11 @@ class Validator implements ValidatorContract
protected $defaultNumericRules = ['Numeric', 'Integer', 'Decimal'];
/**
- * The current placeholder for dots in rule keys.
+ * The current random hash for the validator.
*
* @var string
*/
- protected $dotPlaceholder;
+ protected static $placeholderHash;
/**
* The exception to throw upon failure.
@@ -335,7 +335,9 @@ class Validator implements ValidatorContract
public function __construct(Translator $translator, array $data, array $rules,
array $messages = [], array $attributes = [])
{
- $this->dotPlaceholder = Str::random();
+ if (! isset(static::$placeholderHash)) {
+ static::$placeholderHash = Str::random();
+ }
$this->initialRules = $rules;
$this->translator = $translator;
@@ -363,7 +365,7 @@ public function parseData(array $data)
$key = str_replace(
['.', '*'],
- [$this->dotPlaceholder, '__asterisk__'],
+ ['__dot__'.static::$placeholderHash, '__asterisk__'.static::$placeholderHash],
$key
);
@@ -401,7 +403,7 @@ protected function replacePlaceholders($data)
protected function replacePlaceholderInString(string $value)
{
return str_replace(
- [$this->dotPlaceholder, '__asterisk__'],
+ ['__dot__'.static::$placeholderHash, '__asterisk__'.static::$placeholderHash],
['.', '*'],
$value
);
@@ -720,7 +722,7 @@ protected function getPrimaryAttribute($attribute)
protected function replaceDotInParameters(array $parameters)
{
return array_map(function ($field) {
- return str_replace('\.', $this->dotPlaceholder, $field);
+ return str_replace('\.', '__dot__'.static::$placeholderHash, $field);
}, $parameters);
}
@@ -846,11 +848,23 @@ protected function hasNotFailedPreviousRuleIfPresenceRule($rule, $attribute)
*/
protected function validateUsingCustomRule($attribute, $value, $rule)
{
- $attribute = $this->replacePlaceholderInString($attribute);
+ $originalAttribute = $this->replacePlaceholderInString($attribute);
+
+ $attribute = match (true) {
+ $rule instanceof Rules\File => $attribute,
+ $rule instanceof Rules\Password => $attribute,
+ default => $originalAttribute,
+ };
$value = is_array($value) ? $this->replacePlaceholders($value) : $value;
if ($rule instanceof ValidatorAwareRule) {
+ if ($attribute !== $originalAttribute) {
+ $this->addCustomAttributes([
+ $attribute => $this->customAttributes[$originalAttribute] ?? $originalAttribute,
+ ]);
+ }
+
$rule->setValidator($this);
}
@@ -863,14 +877,14 @@ protected function validateUsingCustomRule($attribute, $value, $rule)
get_class($rule->invokable()) :
get_class($rule);
- $this->failedRules[$attribute][$ruleClass] = [];
+ $this->failedRules[$originalAttribute][$ruleClass] = [];
- $messages = $this->getFromLocalArray($attribute, $ruleClass) ?? $rule->message();
+ $messages = $this->getFromLocalArray($originalAttribute, $ruleClass) ?? $rule->message();
$messages = $messages ? (array) $messages : [$ruleClass];
foreach ($messages as $key => $message) {
- $key = is_string($key) ? $key : $attribute;
+ $key = is_string($key) ? $key : $originalAttribute;
$this->messages->add($key, $this->makeReplacements(
$message, $key, $ruleClass, []
@@ -1159,7 +1173,7 @@ public function getRulesWithoutPlaceholders()
{
return collect($this->rules)
->mapWithKeys(fn ($value, $key) => [
- str_replace($this->dotPlaceholder, '\\.', $key) => $value,
+ str_replace('__dot__'.static::$placeholderHash, '\\.', $key) => $value,
])
->all();
}
@@ -1173,7 +1187,7 @@ public function getRulesWithoutPlaceholders()
public function setRules(array $rules)
{
$rules = collect($rules)->mapWithKeys(function ($value, $key) {
- return [str_replace('\.', $this->dotPlaceholder, $key) => $value];
+ return [str_replace('\.', '__dot__'.static::$placeholderHash, $key) => $value];
})->toArray();
$this->initialRules = $rules;
tests/Integration/Validation/Rules/FileValidationTest.php+54 0
@@ -0,0 +1,54 @@
+<?php
+
+namespace Illuminate\Tests\Integration\Validation\Rules;
+
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rules\File;
+use Orchestra\Testbench\TestCase;
+use PHPUnit\Framework\Attributes\TestWith;
+
+class FileValidationTest extends TestCase
+{
+ #[TestWith(['0'])]
+ #[TestWith(['.'])]
+ #[TestWith(['*'])]
+ #[TestWith(['__asterisk__'])]
+ public function test_it_can_validate_attribute_as_array(string $attribute)
+ {
+ $file = UploadedFile::fake()->create('laravel.png', 1, 'image/png');
+
+ $validator = Validator::make([
+ 'files' => [
+ $attribute => $file,
+ ],
+ ], [
+ 'files.*' => ['required', File::types(['image/png', 'image/jpeg'])],
+ ]);
+
+ $this->assertTrue($validator->passes());
+ }
+
+ #[TestWith(['0'])]
+ #[TestWith(['.'])]
+ #[TestWith(['*'])]
+ #[TestWith(['__asterisk__'])]
+ public function test_it_can_validate_attribute_as_array_when_validation_should_fails(string $attribute)
+ {
+ $file = UploadedFile::fake()->create('laravel.php', 1, 'image/php');
+
+ $validator = Validator::make([
+ 'files' => [
+ $attribute => $file,
+ ],
+ ], [
+ 'files.*' => ['required', File::types($mimes = ['image/png', 'image/jpeg'])],
+ ]);
+
+ $this->assertFalse($validator->passes());
+
+ $this->assertSame([
+ 0 => __('validation.mimetypes', ['attribute' => sprintf('files.%s', str_replace('_', ' ', $attribute)), 'values' => implode(', ', $mimes)]),
+ ], $validator->messages()->all());
+ }
+}
tests/Integration/Validation/Rules/PasswordValidationTest.php+49 0
@@ -0,0 +1,49 @@
+<?php
+
+namespace Illuminate\Tests\Integration\Validation\Rules;
+
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rules\Password;
+use Orchestra\Testbench\TestCase;
+use PHPUnit\Framework\Attributes\TestWith;
+
+class PasswordValidationTest extends TestCase
+{
+ #[TestWith(['0'])]
+ #[TestWith(['.'])]
+ #[TestWith(['*'])]
+ #[TestWith(['__asterisk__'])]
+ public function test_it_can_validate_attribute_as_array(string $attribute)
+ {
+ $validator = Validator::make([
+ 'passwords' => [
+ $attribute => 'secret',
+ ],
+ ], [
+ 'passwords.*' => ['required', Password::default()->min(6)],
+ ]);
+
+ $this->assertTrue($validator->passes());
+ }
+
+ #[TestWith(['0'])]
+ #[TestWith(['.'])]
+ #[TestWith(['*'])]
+ #[TestWith(['__asterisk__'])]
+ public function test_it_can_validate_attribute_as_array_when_validation_should_fails(string $attribute)
+ {
+ $validator = Validator::make([
+ 'passwords' => [
+ $attribute => 'secret',
+ ],
+ ], [
+ 'passwords.*' => ['required', Password::default()->min(8)],
+ ]);
+
+ $this->assertFalse($validator->passes());
+
+ $this->assertSame([
+ 0 => sprintf('The passwords.%s field must be at least 8 characters.', str_replace('_', ' ', $attribute)),
+ ], $validator->messages()->all());
+ }
+}

References