From 028fb5c22cb7d0c0c90fe82979ae8318ff40c180 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:12:45 +0100 Subject: [PATCH] Add ValidProxyConfigFilename rule for dynamic proxy config validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new Laravel validation rule to prevent path traversal, hidden files, and invalid filenames in the dynamic proxy configuration feature. Validates filenames to ensure they contain only safe characters, don't exceed filesystem limits, and don't use reserved names. - New Rule: ValidProxyConfigFilename with comprehensive validation - Updated: NewDynamicConfiguration to use the new rule - Added: 13 unit tests covering all validation scenarios 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Server/Proxy/NewDynamicConfiguration.php | 5 +- app/Rules/ValidProxyConfigFilename.php | 73 +++++++ tests/Unit/ValidProxyConfigFilenameTest.php | 185 ++++++++++++++++++ 3 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 app/Rules/ValidProxyConfigFilename.php create mode 100644 tests/Unit/ValidProxyConfigFilenameTest.php diff --git a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php index baf7b6b50..31a1dfc7e 100644 --- a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php +++ b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php @@ -4,6 +4,7 @@ use App\Enums\ProxyTypes; use App\Models\Server; +use App\Rules\ValidProxyConfigFilename; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Symfony\Component\Yaml\Yaml; @@ -38,11 +39,11 @@ public function addDynamicConfiguration() try { $this->authorize('update', $this->server); $this->validate([ - 'fileName' => 'required', + 'fileName' => ['required', new ValidProxyConfigFilename], 'value' => 'required', ]); - // Validate filename to prevent command injection + // Additional security validation to prevent command injection validateShellSafePath($this->fileName, 'proxy configuration filename'); if (data_get($this->parameters, 'server_uuid')) { diff --git a/app/Rules/ValidProxyConfigFilename.php b/app/Rules/ValidProxyConfigFilename.php new file mode 100644 index 000000000..871cc6eeb --- /dev/null +++ b/app/Rules/ValidProxyConfigFilename.php @@ -0,0 +1,73 @@ + 255) { + $fail('The :attribute must not exceed 255 characters.'); + + return; + } + + // Check for path separators (prevent path traversal) + if (str_contains($filename, '/') || str_contains($filename, '\\')) { + $fail('The :attribute cannot contain path separators.'); + + return; + } + + // Check for hidden files (starting with dot) + if (str_starts_with($filename, '.')) { + $fail('The :attribute cannot start with a dot (hidden files not allowed).'); + + return; + } + + // Check for valid characters only: alphanumeric, dashes, underscores, dots + if (! preg_match('/^[a-zA-Z0-9._-]+$/', $filename)) { + $fail('The :attribute may only contain letters, numbers, dashes, underscores, and dots.'); + + return; + } + + // Check for reserved filenames (case-sensitive for coolify.yaml/yml, case-insensitive check not needed as Caddyfile is exact) + if (in_array($filename, self::RESERVED_FILENAMES, true)) { + $fail('The :attribute uses a reserved filename.'); + + return; + } + } +} diff --git a/tests/Unit/ValidProxyConfigFilenameTest.php b/tests/Unit/ValidProxyConfigFilenameTest.php new file mode 100644 index 000000000..c326d69cf --- /dev/null +++ b/tests/Unit/ValidProxyConfigFilenameTest.php @@ -0,0 +1,185 @@ +validate('fileName', $filename, function ($message) use (&$failures, $filename) { + $failures[] = "{$filename}: {$message}"; + }); + } + + expect($failures)->toBeEmpty(); +}); + +test('blocks path traversal with forward slash', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $rule->validate('fileName', '../etc/passwd', function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue(); +}); + +test('blocks path traversal with backslash', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $rule->validate('fileName', '..\\windows\\system32', function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue(); +}); + +test('blocks hidden files starting with dot', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $rule->validate('fileName', '.hidden.yaml', function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue(); +}); + +test('blocks reserved filename coolify.yaml', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $rule->validate('fileName', 'coolify.yaml', function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue(); +}); + +test('blocks reserved filename coolify.yml', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $rule->validate('fileName', 'coolify.yml', function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue(); +}); + +test('blocks reserved filename Caddyfile', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $rule->validate('fileName', 'Caddyfile', function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue(); +}); + +test('blocks filenames with invalid characters', function () { + $invalidFilenames = [ + 'file;rm.yaml', + 'file|test.yaml', + 'config$var.yaml', + 'test`cmd`.yaml', + 'name with spaces.yaml', + 'fileoutput.yaml', + 'config&background.yaml', + "file\nnewline.yaml", + ]; + + $rule = new ValidProxyConfigFilename; + + foreach ($invalidFilenames as $filename) { + $failed = false; + $rule->validate('fileName', $filename, function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue("Expected '{$filename}' to be rejected"); + } +}); + +test('blocks filenames exceeding 255 characters', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $longFilename = str_repeat('a', 256); + $rule->validate('fileName', $longFilename, function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue(); +}); + +test('allows filenames at exactly 255 characters', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $exactFilename = str_repeat('a', 255); + $rule->validate('fileName', $exactFilename, function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeFalse(); +}); + +test('allows empty values without failing', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $rule->validate('fileName', '', function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeFalse(); +}); + +test('blocks nested path traversal', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $rule->validate('fileName', 'foo/bar/../../etc/passwd', function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue(); +}); + +test('allows similar but not reserved filenames', function () { + $validFilenames = [ + 'coolify-custom.yaml', + 'my-coolify.yaml', + 'coolify2.yaml', + 'Caddyfile.backup', + 'my-Caddyfile', + ]; + + $rule = new ValidProxyConfigFilename; + $failures = []; + + foreach ($validFilenames as $filename) { + $rule->validate('fileName', $filename, function ($message) use (&$failures, $filename) { + $failures[] = "{$filename}: {$message}"; + }); + } + + expect($failures)->toBeEmpty(); +});