Additional qemu safety checking on base images
There is an additional way we can be fooled into using a qcow2 file with a data-file, which is uploading it as raw to glance and then booting an instance from it. Because when we go to create the ephemeral disk from a cached base image, we've lost the information about the original source's format, we probe the image's file type without a strict format specified. If a qcow2 file is listed in glance as a raw, we won't notice it until it is too late. This brings over another piece of code (proposed against) glance's format inspector which provides a safe format detection routine. This patch uses that to detect the format of and run a safety check on the base image each time we go to use it to create an ephemeral disk image from it. This also detects QED files and always marks them as unsafe as we do not support that format at all. Since we could be fooled into downloading one and passing it to qemu-img if we don't recognize it, we need to detect and reject it as unsafe. Conflicts: nova/tests/unit/virt/libvirt/test_utils.py nova/virt/libvirt/utils.py NOTE(elod.illes): conflicts are due to patch to consolidate image creation functions (I111cfc8a5eae27b15c6312957255fcf973038ddf) is only introduced in zed. Change-Id: I4881c8cbceb30c1ff2d2b859c554e0d02043f1f5 (cherry picked from commitb1b88bf001
) (cherry picked from commit8a0d5f2afa
) (cherry picked from commit0269234dc4
) (cherry picked from commit9e10ac2549
) (cherry picked from commit303c2c9644
)
This commit is contained in:
@@ -368,6 +368,23 @@ class QcowInspector(FileInspector):
|
||||
not self.has_unknown_features)
|
||||
|
||||
|
||||
class QEDInspector(FileInspector):
|
||||
def __init__(self, tracing=False):
|
||||
super().__init__(tracing)
|
||||
self.new_region('header', CaptureRegion(0, 512))
|
||||
|
||||
@property
|
||||
def format_match(self):
|
||||
if not self.region('header').complete:
|
||||
return False
|
||||
return self.region('header').data.startswith(b'QED\x00')
|
||||
|
||||
def safety_check(self):
|
||||
# QED format is not supported by anyone, but we want to detect it
|
||||
# and mark it as just always unsafe.
|
||||
return False
|
||||
|
||||
|
||||
# The VHD (or VPC as QEMU calls it) format consists of a big-endian
|
||||
# 512-byte "footer" at the beginning of the file with various
|
||||
# information, most of which does not matter to us:
|
||||
@@ -871,19 +888,52 @@ class InfoWrapper(object):
|
||||
self._source.close()
|
||||
|
||||
|
||||
ALL_FORMATS = {
|
||||
'raw': FileInspector,
|
||||
'qcow2': QcowInspector,
|
||||
'vhd': VHDInspector,
|
||||
'vhdx': VHDXInspector,
|
||||
'vmdk': VMDKInspector,
|
||||
'vdi': VDIInspector,
|
||||
'qed': QEDInspector,
|
||||
}
|
||||
|
||||
|
||||
def get_inspector(format_name):
|
||||
"""Returns a FormatInspector class based on the given name.
|
||||
|
||||
:param format_name: The name of the disk_format (raw, qcow2, etc).
|
||||
:returns: A FormatInspector or None if unsupported.
|
||||
"""
|
||||
formats = {
|
||||
'raw': FileInspector,
|
||||
'qcow2': QcowInspector,
|
||||
'vhd': VHDInspector,
|
||||
'vhdx': VHDXInspector,
|
||||
'vmdk': VMDKInspector,
|
||||
'vdi': VDIInspector,
|
||||
}
|
||||
|
||||
return formats.get(format_name)
|
||||
return ALL_FORMATS.get(format_name)
|
||||
|
||||
|
||||
def detect_file_format(filename):
|
||||
"""Attempts to detect the format of a file.
|
||||
|
||||
This runs through a file one time, running all the known inspectors in
|
||||
parallel. It stops reading the file once one of them matches or all of
|
||||
them are sure they don't match.
|
||||
|
||||
Returns the FileInspector that matched, if any. None if 'raw'.
|
||||
"""
|
||||
inspectors = {k: v() for k, v in ALL_FORMATS.items()}
|
||||
with open(filename, 'rb') as f:
|
||||
for chunk in chunked_reader(f):
|
||||
for format, inspector in list(inspectors.items()):
|
||||
try:
|
||||
inspector.eat_chunk(chunk)
|
||||
except ImageFormatError:
|
||||
# No match, so stop considering this format
|
||||
inspectors.pop(format)
|
||||
continue
|
||||
if (inspector.format_match and inspector.complete and
|
||||
format != 'raw'):
|
||||
# First complete match (other than raw) wins
|
||||
return inspector
|
||||
if all(i.complete for i in inspectors.values()):
|
||||
# If all the inspectors are sure they are not a match, avoid
|
||||
# reading to the end of the file to settle on 'raw'.
|
||||
break
|
||||
return inspectors['raw']
|
||||
|
@@ -13793,10 +13793,11 @@ class LibvirtConnTestCase(test.NoDBTestCase,
|
||||
'/fake/instance/dir', disk_info)
|
||||
self.assertFalse(mock_fetch_image.called)
|
||||
|
||||
@mock.patch('nova.image.format_inspector.detect_file_format')
|
||||
@mock.patch('nova.privsep.path.utime')
|
||||
@mock.patch('nova.virt.libvirt.utils.create_cow_image')
|
||||
def test_create_images_and_backing_ephemeral_gets_created(
|
||||
self, mock_create_cow_image, mock_utime):
|
||||
self, mock_create_cow_image, mock_utime, mock_detect):
|
||||
drvr = libvirt_driver.LibvirtDriver(fake.FakeVirtAPI(), False)
|
||||
|
||||
base_dir = os.path.join(CONF.instances_path,
|
||||
@@ -15532,11 +15533,13 @@ class LibvirtConnTestCase(test.NoDBTestCase,
|
||||
fake_mkfs.assert_has_calls([mock.call('ext4', '/dev/something',
|
||||
'myVol')])
|
||||
|
||||
@mock.patch('nova.image.format_inspector.detect_file_format')
|
||||
@mock.patch('nova.privsep.path.utime')
|
||||
@mock.patch('nova.virt.libvirt.utils.fetch_image')
|
||||
@mock.patch('nova.virt.libvirt.utils.create_cow_image')
|
||||
def test_create_ephemeral_specified_fs_not_valid(
|
||||
self, mock_create_cow_image, mock_fetch_image, mock_utime):
|
||||
self, mock_create_cow_image, mock_fetch_image, mock_utime,
|
||||
mock_detect):
|
||||
CONF.set_override('default_ephemeral_format', 'ext4')
|
||||
ephemerals = [{'device_type': 'disk',
|
||||
'disk_bus': 'virtio',
|
||||
|
@@ -522,13 +522,15 @@ class Qcow2TestCase(_ImageTestCase, test.NoDBTestCase):
|
||||
|
||||
mock_exists.assert_has_calls(exist_calls)
|
||||
|
||||
@mock.patch('nova.image.format_inspector.detect_file_format')
|
||||
@mock.patch.object(imagebackend.utils, 'synchronized')
|
||||
@mock.patch('nova.virt.libvirt.utils.create_cow_image')
|
||||
@mock.patch.object(os.path, 'exists', side_effect=[])
|
||||
@mock.patch.object(imagebackend.Image, 'verify_base_size')
|
||||
@mock.patch('nova.privsep.path.utime')
|
||||
def test_create_image(
|
||||
self, mock_utime, mock_verify, mock_exist, mock_create, mock_sync
|
||||
self, mock_utime, mock_verify, mock_exist, mock_create, mock_sync,
|
||||
mock_detect_format
|
||||
):
|
||||
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
|
||||
fn = mock.MagicMock()
|
||||
@@ -549,7 +551,10 @@ class Qcow2TestCase(_ImageTestCase, test.NoDBTestCase):
|
||||
mock_exist.assert_has_calls(exist_calls)
|
||||
self.assertTrue(mock_sync.called)
|
||||
mock_utime.assert_called()
|
||||
mock_detect_format.assert_called_once()
|
||||
mock_detect_format.return_value.safety_check.assert_called_once_with()
|
||||
|
||||
@mock.patch('nova.image.format_inspector.detect_file_format')
|
||||
@mock.patch.object(imagebackend.utils, 'synchronized')
|
||||
@mock.patch('nova.virt.libvirt.utils.create_cow_image')
|
||||
@mock.patch.object(imagebackend.disk, 'extend')
|
||||
@@ -557,7 +562,8 @@ class Qcow2TestCase(_ImageTestCase, test.NoDBTestCase):
|
||||
@mock.patch.object(imagebackend.Qcow2, 'get_disk_size')
|
||||
@mock.patch('nova.privsep.path.utime')
|
||||
def test_create_image_too_small(self, mock_utime, mock_get, mock_exist,
|
||||
mock_extend, mock_create, mock_sync):
|
||||
mock_extend, mock_create, mock_sync,
|
||||
mock_detect_format):
|
||||
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
|
||||
mock_get.return_value = self.SIZE
|
||||
fn = mock.MagicMock()
|
||||
@@ -574,7 +580,9 @@ class Qcow2TestCase(_ImageTestCase, test.NoDBTestCase):
|
||||
self.assertTrue(mock_sync.called)
|
||||
self.assertFalse(mock_create.called)
|
||||
self.assertFalse(mock_extend.called)
|
||||
mock_detect_format.assert_called_once()
|
||||
|
||||
@mock.patch('nova.image.format_inspector.detect_file_format')
|
||||
@mock.patch.object(imagebackend.utils, 'synchronized')
|
||||
@mock.patch('nova.virt.libvirt.utils.create_cow_image')
|
||||
@mock.patch('nova.virt.libvirt.utils.get_disk_backing_file')
|
||||
@@ -586,7 +594,8 @@ class Qcow2TestCase(_ImageTestCase, test.NoDBTestCase):
|
||||
def test_generate_resized_backing_files(self, mock_utime, mock_copy,
|
||||
mock_verify, mock_exist,
|
||||
mock_extend, mock_get,
|
||||
mock_create, mock_sync):
|
||||
mock_create, mock_sync,
|
||||
mock_detect_format):
|
||||
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
|
||||
mock_get.return_value = self.QCOW2_BASE
|
||||
fn = mock.MagicMock()
|
||||
@@ -613,7 +622,9 @@ class Qcow2TestCase(_ImageTestCase, test.NoDBTestCase):
|
||||
self.assertTrue(mock_sync.called)
|
||||
self.assertFalse(mock_create.called)
|
||||
mock_utime.assert_called()
|
||||
mock_detect_format.assert_called_once()
|
||||
|
||||
@mock.patch('nova.image.format_inspector.detect_file_format')
|
||||
@mock.patch.object(imagebackend.utils, 'synchronized')
|
||||
@mock.patch('nova.virt.libvirt.utils.create_cow_image')
|
||||
@mock.patch('nova.virt.libvirt.utils.get_disk_backing_file')
|
||||
@@ -624,7 +635,8 @@ class Qcow2TestCase(_ImageTestCase, test.NoDBTestCase):
|
||||
def test_qcow2_exists_and_has_no_backing_file(self, mock_utime,
|
||||
mock_verify, mock_exist,
|
||||
mock_extend, mock_get,
|
||||
mock_create, mock_sync):
|
||||
mock_create, mock_sync,
|
||||
mock_detect_format):
|
||||
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
|
||||
mock_get.return_value = None
|
||||
fn = mock.MagicMock()
|
||||
@@ -645,6 +657,31 @@ class Qcow2TestCase(_ImageTestCase, test.NoDBTestCase):
|
||||
self.assertTrue(mock_sync.called)
|
||||
self.assertFalse(mock_create.called)
|
||||
self.assertFalse(mock_extend.called)
|
||||
mock_detect_format.assert_called_once()
|
||||
|
||||
@mock.patch('nova.image.format_inspector.detect_file_format')
|
||||
@mock.patch.object(imagebackend.utils, 'synchronized')
|
||||
@mock.patch('nova.virt.libvirt.utils.create_image')
|
||||
@mock.patch('nova.virt.libvirt.utils.get_disk_backing_file')
|
||||
@mock.patch.object(imagebackend.disk, 'extend')
|
||||
@mock.patch.object(os.path, 'exists', side_effect=[])
|
||||
@mock.patch.object(imagebackend.Image, 'verify_base_size')
|
||||
def test_qcow2_exists_and_fails_safety_check(self,
|
||||
mock_verify, mock_exist,
|
||||
mock_extend, mock_get,
|
||||
mock_create, mock_sync,
|
||||
mock_detect_format):
|
||||
mock_detect_format.return_value.safety_check.return_value = False
|
||||
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
|
||||
mock_get.return_value = None
|
||||
fn = mock.MagicMock()
|
||||
mock_exist.side_effect = [False, True, False, True, True]
|
||||
image = self.image_class(self.INSTANCE, self.NAME)
|
||||
|
||||
self.assertRaises(exception.InvalidDiskInfo,
|
||||
image.create_image, fn, self.TEMPLATE_PATH,
|
||||
self.SIZE)
|
||||
mock_verify.assert_not_called()
|
||||
|
||||
def test_resolve_driver_format(self):
|
||||
image = self.image_class(self.INSTANCE, self.NAME)
|
||||
|
@@ -117,11 +117,27 @@ class LibvirtUtilsTestCase(test.NoDBTestCase):
|
||||
@mock.patch('os.path.exists', return_value=True)
|
||||
@mock.patch('oslo_concurrency.processutils.execute')
|
||||
@mock.patch('nova.virt.images.qemu_img_info')
|
||||
def test_create_cow_image(self, mock_info, mock_execute, mock_exists):
|
||||
@mock.patch('nova.image.format_inspector.detect_file_format')
|
||||
def _test_create_cow_image(
|
||||
self, mock_detect, mock_info, mock_execute,
|
||||
mock_exists, backing_file=None, safety_check=True
|
||||
):
|
||||
if isinstance(backing_file, dict):
|
||||
backing_info = backing_file
|
||||
backing_file = backing_info.pop('file', None)
|
||||
else:
|
||||
backing_info = {}
|
||||
backing_backing_file = backing_info.pop('backing_file', None)
|
||||
|
||||
mock_execute.return_value = ('stdout', None)
|
||||
mock_info.return_value = mock.Mock(
|
||||
file_format=mock.sentinel.backing_fmt,
|
||||
cluster_size=mock.sentinel.cluster_size)
|
||||
cluster_size=mock.sentinel.cluster_size,
|
||||
backing_file=backing_backing_file,
|
||||
format_specific=backing_info)
|
||||
|
||||
mock_detect.return_value.safety_check.return_value = safety_check
|
||||
|
||||
libvirt_utils.create_cow_image(mock.sentinel.backing_path,
|
||||
mock.sentinel.new_path)
|
||||
mock_info.assert_called_once_with(mock.sentinel.backing_path)
|
||||
@@ -131,6 +147,33 @@ class LibvirtUtilsTestCase(test.NoDBTestCase):
|
||||
mock.sentinel.backing_path, mock.sentinel.backing_fmt,
|
||||
mock.sentinel.cluster_size),
|
||||
mock.sentinel.new_path)])
|
||||
if backing_file:
|
||||
mock_detect.return_value.safety_check.assert_called_once_with()
|
||||
|
||||
def test_create_image_qcow2(self):
|
||||
self._test_create_cow_image()
|
||||
|
||||
def test_create_image_backing_file(self):
|
||||
self._test_create_cow_image(
|
||||
backing_file=mock.sentinel.backing_file
|
||||
)
|
||||
|
||||
def test_create_image_base_has_backing_file(self):
|
||||
self.assertRaises(
|
||||
exception.InvalidDiskInfo,
|
||||
self._test_create_cow_image,
|
||||
backing_file={'file': mock.sentinel.backing_file,
|
||||
'backing_file': mock.sentinel.backing_backing_file},
|
||||
)
|
||||
|
||||
def test_create_image_base_has_data_file(self):
|
||||
self.assertRaises(
|
||||
exception.InvalidDiskInfo,
|
||||
self._test_create_cow_image,
|
||||
backing_file={'file': mock.sentinel.backing_file,
|
||||
'backing_file': mock.sentinel.backing_backing_file,
|
||||
'data': {'data-file': mock.sentinel.data_file}},
|
||||
)
|
||||
|
||||
@ddt.unpack
|
||||
@ddt.data({'fs_type': 'some_fs_type',
|
||||
|
@@ -34,6 +34,7 @@ from oslo_utils import units
|
||||
import nova.conf
|
||||
from nova import exception
|
||||
from nova.i18n import _
|
||||
from nova.image import format_inspector
|
||||
from nova.image import glance
|
||||
import nova.privsep.libvirt
|
||||
import nova.privsep.path
|
||||
@@ -637,6 +638,20 @@ class Qcow2(Image):
|
||||
if not os.path.exists(base):
|
||||
prepare_template(target=base, *args, **kwargs)
|
||||
|
||||
# NOTE(danms): We need to perform safety checks on the base image
|
||||
# before we inspect it for other attributes. We do this each time
|
||||
# because additional safety checks could have been added since we
|
||||
# downloaded the image.
|
||||
if not CONF.workarounds.disable_deep_image_inspection:
|
||||
inspector = format_inspector.detect_file_format(base)
|
||||
if not inspector.safety_check():
|
||||
LOG.warning('Base image %s failed safety check', base)
|
||||
# NOTE(danms): This is the same exception as would be raised
|
||||
# by qemu_img_info() if the disk format was unreadable or
|
||||
# otherwise unsuitable.
|
||||
raise exception.InvalidDiskInfo(
|
||||
reason=_('Base image failed safety check'))
|
||||
|
||||
# NOTE(ankit): Update the mtime of the base file so the image
|
||||
# cache manager knows it is in use.
|
||||
_update_utime_ignore_eacces(base)
|
||||
|
@@ -34,6 +34,7 @@ import nova.conf
|
||||
from nova import context as nova_context
|
||||
from nova import exception
|
||||
from nova.i18n import _
|
||||
from nova.image import format_inspector
|
||||
from nova import objects
|
||||
from nova.objects import fields as obj_fields
|
||||
import nova.privsep.fs
|
||||
@@ -139,7 +140,35 @@ def create_cow_image(
|
||||
base_cmd = ['qemu-img', 'create', '-f', 'qcow2']
|
||||
cow_opts = []
|
||||
if backing_file:
|
||||
# NOTE(danms): We need to perform safety checks on the base image
|
||||
# before we inspect it for other attributes. We do this each time
|
||||
# because additional safety checks could have been added since we
|
||||
# downloaded the image.
|
||||
if not CONF.workarounds.disable_deep_image_inspection:
|
||||
inspector = format_inspector.detect_file_format(backing_file)
|
||||
if not inspector.safety_check():
|
||||
LOG.warning('Base image %s failed safety check', backing_file)
|
||||
# NOTE(danms): This is the same exception as would be raised
|
||||
# by qemu_img_info() if the disk format was unreadable or
|
||||
# otherwise unsuitable.
|
||||
raise exception.InvalidDiskInfo(
|
||||
reason=_('Base image failed safety check'))
|
||||
|
||||
base_details = images.qemu_img_info(backing_file)
|
||||
|
||||
if base_details.backing_file is not None:
|
||||
LOG.warning('Base image %s failed safety check', backing_file)
|
||||
raise exception.InvalidDiskInfo(
|
||||
reason=_('Base image failed safety check'))
|
||||
try:
|
||||
data_file = base_details.format_specific['data']['data-file']
|
||||
except (KeyError, TypeError, AttributeError):
|
||||
data_file = None
|
||||
if data_file is not None:
|
||||
LOG.warning('Base image %s failed safety check', backing_file)
|
||||
raise exception.InvalidDiskInfo(
|
||||
reason=_('Base image failed safety check'))
|
||||
|
||||
cow_opts += ['backing_file=%s' % backing_file]
|
||||
cow_opts += ['backing_fmt=%s' % base_details.file_format]
|
||||
else:
|
||||
|
Reference in New Issue
Block a user