Merge "Reject unsafe qcow and vmdk files" into stable/2023.2

This commit is contained in:
Zuul 2024-07-05 08:26:37 +00:00 committed by Gerrit Code Review
commit ab4d6a3e62
2 changed files with 87 additions and 14 deletions

View File

@ -25,6 +25,7 @@ from taskflow.patterns import linear_flow as lf
from taskflow import task from taskflow import task
from glance.async_ import utils from glance.async_ import utils
from glance.common import format_inspector
from glance.i18n import _, _LI from glance.i18n import _, _LI
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@ -87,8 +88,40 @@ class _ConvertImage(task.Task):
'target': target_format} 'target': target_format}
self.dest_path = dest_path self.dest_path = dest_path
source_format = action.image_disk_format
inspector_cls = format_inspector.get_inspector(source_format)
if not inspector_cls:
# We cannot convert from disk_format types that qemu-img doesn't
# support (like iso, ploop, etc). The ones it supports overlaps
# with the ones we have inspectors for, so reject conversion for
# any format we don't have an inspector for.
raise RuntimeError(
'Unable to convert from format %s' % source_format)
# Use our own cautious inspector module (if we have one for this
# format) to make sure a file is the format the submitter claimed
# it is and that it passes some basic safety checks _before_ we run
# qemu-img on it.
# See https://bugs.launchpad.net/nova/+bug/2059809 for details.
try:
inspector = inspector_cls.from_file(src_path)
if not inspector.safety_check():
LOG.error('Image failed %s safety check; aborting conversion',
source_format)
raise RuntimeError('Image has disallowed configuration')
except RuntimeError:
raise
except format_inspector.ImageFormatError as e:
LOG.error('Image claimed to be %s format failed format '
'inspection: %s', source_format, e)
raise RuntimeError('Image format detection failed')
except Exception as e:
LOG.exception('Unknown error inspecting image format: %s', e)
raise RuntimeError('Unable to inspect image')
try: try:
stdout, stderr = putils.trycmd("qemu-img", "info", stdout, stderr = putils.trycmd("qemu-img", "info",
"-f", source_format,
"--output=json", "--output=json",
src_path, src_path,
prlimit=utils.QEMU_IMG_PROC_LIMITS, prlimit=utils.QEMU_IMG_PROC_LIMITS,
@ -105,13 +138,10 @@ class _ConvertImage(task.Task):
raise RuntimeError(stderr) raise RuntimeError(stderr)
metadata = json.loads(stdout) metadata = json.loads(stdout)
try: if metadata.get('format') != source_format:
source_format = metadata['format'] LOG.error('Image claiming to be %s reported as %s by qemu-img',
except KeyError: source_format, metadata.get('format', 'unknown'))
msg = ("Failed to do introspection as part of image " raise RuntimeError('Image metadata disagrees about format')
"conversion for %(iid)s: Source format not reported")
LOG.error(msg, {'iid': self.image_id})
raise RuntimeError(msg)
virtual_size = metadata.get('virtual-size', 0) virtual_size = metadata.get('virtual-size', 0)
action.set_image_attribute(virtual_size=virtual_size) action.set_image_attribute(virtual_size=virtual_size)

View File

@ -13,6 +13,7 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import fixtures
import json import json
import os import os
from unittest import mock from unittest import mock
@ -24,6 +25,7 @@ from oslo_config import cfg
import glance.async_.flows.api_image_import as import_flow import glance.async_.flows.api_image_import as import_flow
import glance.async_.flows.plugins.image_conversion as image_conversion import glance.async_.flows.plugins.image_conversion as image_conversion
from glance.async_ import utils as async_utils from glance.async_ import utils as async_utils
from glance.common import format_inspector
from glance.common import utils from glance.common import utils
from glance import domain from glance import domain
from glance import gateway from glance import gateway
@ -90,6 +92,11 @@ class TestConvertImageTask(test_utils.BaseTestCase):
self.image_id, self.image_id,
self.task.task_id) self.task.task_id)
self.inspector_mock = mock.MagicMock()
self.useFixture(fixtures.MockPatch('glance.common.format_inspector.'
'get_inspector',
self.inspector_mock))
@mock.patch.object(os, 'stat') @mock.patch.object(os, 'stat')
@mock.patch.object(os, 'remove') @mock.patch.object(os, 'remove')
def test_image_convert_success(self, mock_os_remove, mock_os_stat): def test_image_convert_success(self, mock_os_remove, mock_os_stat):
@ -104,7 +111,7 @@ class TestConvertImageTask(test_utils.BaseTestCase):
image = mock.MagicMock(image_id=self.image_id, virtual_size=None, image = mock.MagicMock(image_id=self.image_id, virtual_size=None,
extra_properties={ extra_properties={
'os_glance_import_task': self.task.task_id}, 'os_glance_import_task': self.task.task_id},
disk_format='qcow2') disk_format='raw')
self.img_repo.get.return_value = image self.img_repo.get.return_value = image
with mock.patch.object(processutils, 'execute') as exc_mock: with mock.patch.object(processutils, 'execute') as exc_mock:
@ -126,7 +133,7 @@ class TestConvertImageTask(test_utils.BaseTestCase):
self.assertEqual(456, image.virtual_size) self.assertEqual(456, image.virtual_size)
self.assertEqual(123, image.size) self.assertEqual(123, image.size)
def _setup_image_convert_info_fail(self): def _setup_image_convert_info_fail(self, disk_format='qcow2'):
image_convert = image_conversion._ConvertImage(self.context, image_convert = image_conversion._ConvertImage(self.context,
self.task.task_id, self.task.task_id,
self.task_type, self.task_type,
@ -136,7 +143,7 @@ class TestConvertImageTask(test_utils.BaseTestCase):
image = mock.MagicMock(image_id=self.image_id, virtual_size=None, image = mock.MagicMock(image_id=self.image_id, virtual_size=None,
extra_properties={ extra_properties={
'os_glance_import_task': self.task.task_id}, 'os_glance_import_task': self.task.task_id},
disk_format='qcow2') disk_format=disk_format)
self.img_repo.get.return_value = image self.img_repo.get.return_value = image
return image_convert return image_convert
@ -148,6 +155,7 @@ class TestConvertImageTask(test_utils.BaseTestCase):
convert.execute, 'file:///test/path.raw') convert.execute, 'file:///test/path.raw')
exc_mock.assert_called_once_with( exc_mock.assert_called_once_with(
'qemu-img', 'info', 'qemu-img', 'info',
'-f', 'qcow2',
'--output=json', '--output=json',
'/test/path.raw', '/test/path.raw',
prlimit=async_utils.QEMU_IMG_PROC_LIMITS, prlimit=async_utils.QEMU_IMG_PROC_LIMITS,
@ -164,6 +172,7 @@ class TestConvertImageTask(test_utils.BaseTestCase):
convert.execute, 'file:///test/path.raw') convert.execute, 'file:///test/path.raw')
exc_mock.assert_called_once_with( exc_mock.assert_called_once_with(
'qemu-img', 'info', 'qemu-img', 'info',
'-f', 'qcow2',
'--output=json', '--output=json',
'/test/path.raw', '/test/path.raw',
prlimit=async_utils.QEMU_IMG_PROC_LIMITS, prlimit=async_utils.QEMU_IMG_PROC_LIMITS,
@ -200,6 +209,36 @@ class TestConvertImageTask(test_utils.BaseTestCase):
self.assertEqual('QCOW images with data-file set are not allowed', self.assertEqual('QCOW images with data-file set are not allowed',
str(e)) str(e))
def test_image_convert_no_inspector_match(self):
convert = self._setup_image_convert_info_fail()
self.inspector_mock.return_value = None
self.assertRaisesRegex(RuntimeError,
'Unable to convert from format',
convert.execute, 'file:///test/path.hpfs')
def test_image_convert_fails_inspection_safety_check(self):
convert = self._setup_image_convert_info_fail()
inspector = self.inspector_mock.return_value.from_file.return_value
inspector.safety_check.return_value = False
self.assertRaisesRegex(RuntimeError,
'Image has disallowed configuration',
convert.execute, 'file:///test/path.qcow')
def test_image_convert_fails_inspection_format_check(self):
convert = self._setup_image_convert_info_fail()
self.inspector_mock.return_value.from_file.side_effect = (
format_inspector.ImageFormatError())
self.assertRaisesRegex(RuntimeError,
'Image format detection failed',
convert.execute, 'file:///test/path.qcow')
def test_image_convert_fails_inspection_error(self):
convert = self._setup_image_convert_info_fail()
self.inspector_mock.return_value.from_file.side_effect = ValueError
self.assertRaisesRegex(RuntimeError,
'Unable to inspect image',
convert.execute, 'file:///test/path.qcow')
def _test_image_convert_invalid_vmdk(self): def _test_image_convert_invalid_vmdk(self):
data = {'format': 'vmdk', data = {'format': 'vmdk',
'format-specific': { 'format-specific': {
@ -207,7 +246,7 @@ class TestConvertImageTask(test_utils.BaseTestCase):
'create-type': 'monolithicFlat', 'create-type': 'monolithicFlat',
}}} }}}
convert = self._setup_image_convert_info_fail() convert = self._setup_image_convert_info_fail(disk_format='vmdk')
with mock.patch.object(processutils, 'execute') as exc_mock: with mock.patch.object(processutils, 'execute') as exc_mock:
exc_mock.return_value = json.dumps(data), '' exc_mock.return_value = json.dumps(data), ''
convert.execute('file:///test/path.vmdk') convert.execute('file:///test/path.vmdk')
@ -236,7 +275,7 @@ class TestConvertImageTask(test_utils.BaseTestCase):
self._test_image_convert_invalid_vmdk) self._test_image_convert_invalid_vmdk)
def test_image_convert_fails(self): def test_image_convert_fails(self):
convert = self._setup_image_convert_info_fail() convert = self._setup_image_convert_info_fail(disk_format='raw')
with mock.patch.object(processutils, 'execute') as exc_mock: with mock.patch.object(processutils, 'execute') as exc_mock:
exc_mock.side_effect = [('{"format":"raw"}', ''), exc_mock.side_effect = [('{"format":"raw"}', ''),
OSError('convert_fail')] OSError('convert_fail')]
@ -244,6 +283,7 @@ class TestConvertImageTask(test_utils.BaseTestCase):
convert.execute, 'file:///test/path.raw') convert.execute, 'file:///test/path.raw')
exc_mock.assert_has_calls( exc_mock.assert_has_calls(
[mock.call('qemu-img', 'info', [mock.call('qemu-img', 'info',
'-f', 'raw',
'--output=json', '--output=json',
'/test/path.raw', '/test/path.raw',
prlimit=async_utils.QEMU_IMG_PROC_LIMITS, prlimit=async_utils.QEMU_IMG_PROC_LIMITS,
@ -256,7 +296,7 @@ class TestConvertImageTask(test_utils.BaseTestCase):
self.img_repo.save.assert_not_called() self.img_repo.save.assert_not_called()
def test_image_convert_reports_fail(self): def test_image_convert_reports_fail(self):
convert = self._setup_image_convert_info_fail() convert = self._setup_image_convert_info_fail(disk_format='raw')
with mock.patch.object(processutils, 'execute') as exc_mock: with mock.patch.object(processutils, 'execute') as exc_mock:
exc_mock.side_effect = [('{"format":"raw"}', ''), exc_mock.side_effect = [('{"format":"raw"}', ''),
('', 'some error')] ('', 'some error')]
@ -264,6 +304,7 @@ class TestConvertImageTask(test_utils.BaseTestCase):
convert.execute, 'file:///test/path.raw') convert.execute, 'file:///test/path.raw')
exc_mock.assert_has_calls( exc_mock.assert_has_calls(
[mock.call('qemu-img', 'info', [mock.call('qemu-img', 'info',
'-f', 'raw',
'--output=json', '--output=json',
'/test/path.raw', '/test/path.raw',
prlimit=async_utils.QEMU_IMG_PROC_LIMITS, prlimit=async_utils.QEMU_IMG_PROC_LIMITS,
@ -281,9 +322,10 @@ class TestConvertImageTask(test_utils.BaseTestCase):
exc_mock.return_value = ('{}', '') exc_mock.return_value = ('{}', '')
exc = self.assertRaises(RuntimeError, exc = self.assertRaises(RuntimeError,
convert.execute, 'file:///test/path.raw') convert.execute, 'file:///test/path.raw')
self.assertIn('Source format not reported', str(exc)) self.assertIn('Image metadata disagrees about format', str(exc))
exc_mock.assert_called_once_with( exc_mock.assert_called_once_with(
'qemu-img', 'info', 'qemu-img', 'info',
'-f', 'qcow2',
'--output=json', '--output=json',
'/test/path.raw', '/test/path.raw',
prlimit=async_utils.QEMU_IMG_PROC_LIMITS, prlimit=async_utils.QEMU_IMG_PROC_LIMITS,
@ -301,6 +343,7 @@ class TestConvertImageTask(test_utils.BaseTestCase):
# Make sure we only called qemu-img for inspection, not conversion # Make sure we only called qemu-img for inspection, not conversion
exc_mock.assert_called_once_with( exc_mock.assert_called_once_with(
'qemu-img', 'info', 'qemu-img', 'info',
'-f', 'qcow2',
'--output=json', '--output=json',
'/test/path.qcow', '/test/path.qcow',
prlimit=async_utils.QEMU_IMG_PROC_LIMITS, prlimit=async_utils.QEMU_IMG_PROC_LIMITS,