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(); +});