Add ValidProxyConfigFilename rule for dynamic proxy config validation
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 <noreply@anthropic.com>
This commit is contained in:
parent
b3289aff71
commit
028fb5c22c
3 changed files with 261 additions and 2 deletions
|
|
@ -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')) {
|
||||
|
|
|
|||
73
app/Rules/ValidProxyConfigFilename.php
Normal file
73
app/Rules/ValidProxyConfigFilename.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class ValidProxyConfigFilename implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* Reserved filenames that cannot be used.
|
||||
*/
|
||||
private const RESERVED_FILENAMES = [
|
||||
'coolify.yaml',
|
||||
'coolify.yml',
|
||||
'Caddyfile',
|
||||
];
|
||||
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* Validates proxy configuration filename:
|
||||
* - Must be 1-255 characters
|
||||
* - No path separators (/, \) to prevent path traversal
|
||||
* - Cannot start with a dot (hidden files)
|
||||
* - Only alphanumeric characters, dashes, underscores, and dots allowed
|
||||
* - Must have a basename before any extension
|
||||
* - Cannot use reserved filenames
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (empty($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$filename = trim($value);
|
||||
|
||||
// Check length (filesystem limit is typically 255 bytes)
|
||||
if (strlen($filename) > 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
185
tests/Unit/ValidProxyConfigFilenameTest.php
Normal file
185
tests/Unit/ValidProxyConfigFilenameTest.php
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<?php
|
||||
|
||||
use App\Rules\ValidProxyConfigFilename;
|
||||
|
||||
test('allows valid proxy config filenames', function () {
|
||||
$validFilenames = [
|
||||
'my-config',
|
||||
'service_name.yaml',
|
||||
'router-1.yml',
|
||||
'traefik-config',
|
||||
'my.service.yaml',
|
||||
'config_v2.caddy',
|
||||
'API-Gateway.yaml',
|
||||
'load-balancer_prod.yml',
|
||||
];
|
||||
|
||||
$rule = new ValidProxyConfigFilename;
|
||||
$failures = [];
|
||||
|
||||
foreach ($validFilenames as $filename) {
|
||||
$rule->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',
|
||||
'file<redirect.yaml',
|
||||
'file>output.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();
|
||||
});
|
||||
Loading…
Reference in a new issue