Skip to content

Commit c72a8ba

Browse files
kk7dspriteau
authored andcommitted
Check images with format_inspector for safety
It has been asserted that we should not be calling qemu-img info on untrusted files. That means we need to know if they have a backing_file, data_file or other unsafe configuration *before* we use qemu-img to probe or convert them. This grafts glance's format_inspector module into nova/images so we can use it to check the file early for safety. The expectation is that this will be moved to oslo.utils (or something) later and thus we will just delete the file from nova and change our import when that happens. NOTE: This includes whitespace changes from the glance version of format_inspector.py because of autopep8 demands. Conflicts: nova/conf/workarounds.py NOTE(elod.illes): conflict is due to the following patch that is only present in zed: Iab92124b5776a799c7f90d07281d28fcf191c8fe Change-Id: Iaefbe41b4c4bf0cf95d8f621653fdf65062aaa59 Closes-Bug: #2059809 (cherry picked from commit 9cdce71) (cherry picked from commit f07fa55) (cherry picked from commit 0acf5ee) (cherry picked from commit 67e5376) (cherry picked from commit da352ed) (cherry picked from commit b8a3d56)
1 parent 79e6840 commit c72a8ba

File tree

3 files changed

+143
-65
lines changed

3 files changed

+143
-65
lines changed

nova/image/format_inspector.py

Lines changed: 10 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -368,23 +368,6 @@ def safety_check(self):
368368
not self.has_unknown_features)
369369

370370

371-
class QEDInspector(FileInspector):
372-
def __init__(self, tracing=False):
373-
super().__init__(tracing)
374-
self.new_region('header', CaptureRegion(0, 512))
375-
376-
@property
377-
def format_match(self):
378-
if not self.region('header').complete:
379-
return False
380-
return self.region('header').data.startswith(b'QED\x00')
381-
382-
def safety_check(self):
383-
# QED format is not supported by anyone, but we want to detect it
384-
# and mark it as just always unsafe.
385-
return False
386-
387-
388371
# The VHD (or VPC as QEMU calls it) format consists of a big-endian
389372
# 512-byte "footer" at the beginning of the file with various
390373
# information, most of which does not matter to us:
@@ -888,52 +871,19 @@ def close(self):
888871
self._source.close()
889872

890873

891-
ALL_FORMATS = {
892-
'raw': FileInspector,
893-
'qcow2': QcowInspector,
894-
'vhd': VHDInspector,
895-
'vhdx': VHDXInspector,
896-
'vmdk': VMDKInspector,
897-
'vdi': VDIInspector,
898-
'qed': QEDInspector,
899-
}
900-
901-
902874
def get_inspector(format_name):
903875
"""Returns a FormatInspector class based on the given name.
904876
905877
:param format_name: The name of the disk_format (raw, qcow2, etc).
906878
:returns: A FormatInspector or None if unsupported.
907879
"""
908-
909-
return ALL_FORMATS.get(format_name)
910-
911-
912-
def detect_file_format(filename):
913-
"""Attempts to detect the format of a file.
914-
915-
This runs through a file one time, running all the known inspectors in
916-
parallel. It stops reading the file once one of them matches or all of
917-
them are sure they don't match.
918-
919-
Returns the FileInspector that matched, if any. None if 'raw'.
920-
"""
921-
inspectors = {k: v() for k, v in ALL_FORMATS.items()}
922-
with open(filename, 'rb') as f:
923-
for chunk in chunked_reader(f):
924-
for format, inspector in list(inspectors.items()):
925-
try:
926-
inspector.eat_chunk(chunk)
927-
except ImageFormatError:
928-
# No match, so stop considering this format
929-
inspectors.pop(format)
930-
continue
931-
if (inspector.format_match and inspector.complete and
932-
format != 'raw'):
933-
# First complete match (other than raw) wins
934-
return inspector
935-
if all(i.complete for i in inspectors.values()):
936-
# If all the inspectors are sure they are not a match, avoid
937-
# reading to the end of the file to settle on 'raw'.
938-
break
939-
return inspectors['raw']
880+
formats = {
881+
'raw': FileInspector,
882+
'qcow2': QcowInspector,
883+
'vhd': VHDInspector,
884+
'vhdx': VHDXInspector,
885+
'vmdk': VMDKInspector,
886+
'vdi': VDIInspector,
887+
}
888+
889+
return formats.get(format_name)

nova/tests/unit/virt/test_images.py

Lines changed: 132 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from nova.compute import utils as compute_utils
2323
from nova import exception
24+
from nova.image import format_inspector
2425
from nova import test
2526
from nova.virt import images
2627

@@ -99,11 +100,17 @@ def test_qemu_img_info_with_disk_not_found(self, exists, mocked_execute):
99100
exists.assert_called_once_with(path)
100101
mocked_execute.assert_called_once()
101102

103+
@mock.patch.object(images, 'IMAGE_API')
104+
@mock.patch('nova.image.format_inspector.get_inspector')
102105
@mock.patch.object(images, 'convert_image',
103106
side_effect=exception.ImageUnacceptable)
104107
@mock.patch.object(images, 'qemu_img_info')
105108
@mock.patch.object(images, 'fetch')
106-
def test_fetch_to_raw_errors(self, convert_image, qemu_img_info, fetch):
109+
def test_fetch_to_raw_errors(self, convert_image, qemu_img_info, fetch,
110+
get_inspector, glance):
111+
inspector = get_inspector.return_value.from_file.return_value
112+
inspector.safety_check.return_value = True
113+
glance.get.return_value = {'disk_format': 'qcow2'}
107114
qemu_img_info.backing_file = None
108115
qemu_img_info.file_format = 'qcow2'
109116
qemu_img_info.virtual_size = 20
@@ -112,12 +119,17 @@ def test_fetch_to_raw_errors(self, convert_image, qemu_img_info, fetch):
112119
images.fetch_to_raw,
113120
None, 'href123', '/no/path')
114121

122+
@mock.patch.object(images, 'IMAGE_API')
123+
@mock.patch('nova.image.format_inspector.get_inspector')
115124
@mock.patch.object(images, 'convert_image',
116125
side_effect=exception.ImageUnacceptable)
117126
@mock.patch.object(images, 'qemu_img_info')
118127
@mock.patch.object(images, 'fetch')
119128
def test_fetch_to_raw_data_file(self, convert_image, qemu_img_info_fn,
120-
fetch):
129+
fetch, mock_gi, mock_glance):
130+
mock_glance.get.return_value = {'disk_format': 'qcow2'}
131+
inspector = mock_gi.return_value.from_file.return_value
132+
inspector.safety_check.return_value = True
121133
# NOTE(danms): the above test needs the following line as well, as it
122134
# is broken without it.
123135
qemu_img_info = qemu_img_info_fn.return_value
@@ -130,12 +142,16 @@ def test_fetch_to_raw_data_file(self, convert_image, qemu_img_info_fn,
130142
images.fetch_to_raw,
131143
None, 'href123', '/no/path')
132144

145+
@mock.patch('nova.image.format_inspector.get_inspector')
146+
@mock.patch.object(images, 'IMAGE_API')
133147
@mock.patch('os.rename')
134148
@mock.patch.object(images, 'qemu_img_info')
135149
@mock.patch.object(images, 'fetch')
136-
def test_fetch_to_raw_from_raw(self, fetch, qemu_img_info_fn, mock_rename):
150+
def test_fetch_to_raw_from_raw(self, fetch, qemu_img_info_fn, mock_rename,
151+
mock_glance, mock_gi):
137152
# Make sure we support a case where we fetch an already-raw image and
138153
# qemu-img returns None for "format_specific".
154+
mock_glance.get.return_value = {'disk_format': 'raw'}
139155
qemu_img_info = qemu_img_info_fn.return_value
140156
qemu_img_info.file_format = 'raw'
141157
qemu_img_info.backing_file = None
@@ -198,9 +214,15 @@ def test_convert_image_vmdk_allowed_list_checking(self):
198214
imageutils.QemuImgInfo(jsonutils.dumps(info),
199215
format='json'))
200216

217+
@mock.patch.object(images, 'IMAGE_API')
218+
@mock.patch('nova.image.format_inspector.get_inspector')
201219
@mock.patch.object(images, 'fetch')
202220
@mock.patch('nova.privsep.qemu.unprivileged_qemu_img_info')
203-
def test_fetch_checks_vmdk_rules(self, mock_info, mock_fetch):
221+
def test_fetch_checks_vmdk_rules(self, mock_info, mock_fetch, mock_gi,
222+
mock_glance):
223+
mock_glance.get.return_value = {'disk_format': 'vmdk'}
224+
inspector = mock_gi.return_value.from_file.return_value
225+
inspector.safety_check.return_value = True
204226
info = {'format': 'vmdk',
205227
'format-specific': {
206228
'type': 'vmdk',
@@ -212,3 +234,109 @@ def test_fetch_checks_vmdk_rules(self, mock_info, mock_fetch):
212234
e = self.assertRaises(exception.ImageUnacceptable,
213235
images.fetch_to_raw, None, 'foo', 'anypath')
214236
self.assertIn('Invalid VMDK create-type specified', str(e))
237+
238+
@mock.patch.object(images, 'IMAGE_API')
239+
@mock.patch('nova.image.format_inspector.get_inspector')
240+
@mock.patch.object(images, 'qemu_img_info')
241+
@mock.patch.object(images, 'fetch')
242+
def test_fetch_to_raw_inspector(self, fetch, qemu_img_info, mock_gi,
243+
mock_glance):
244+
# Image claims to be qcow2, is qcow2, but fails safety check, so we
245+
# abort before qemu-img-info
246+
mock_glance.get.return_value = {'disk_format': 'qcow2'}
247+
inspector = mock_gi.return_value.from_file.return_value
248+
inspector.safety_check.return_value = False
249+
self.assertRaises(exception.ImageUnacceptable,
250+
images.fetch_to_raw, None, 'href123', '/no.path')
251+
qemu_img_info.assert_not_called()
252+
mock_gi.assert_called_once_with('qcow2')
253+
mock_gi.return_value.from_file.assert_called_once_with('/no.path.part')
254+
inspector.safety_check.assert_called_once_with()
255+
mock_glance.get.assert_called_once_with(None, 'href123')
256+
257+
# Image claims to be qcow2, is qcow2, passes safety check, so we make
258+
# it all the way to qemu-img-info
259+
inspector.safety_check.return_value = True
260+
qemu_img_info.side_effect = test.TestingException
261+
self.assertRaises(test.TestingException,
262+
images.fetch_to_raw, None, 'href123', '/no.path')
263+
264+
# Image claims to be qcow2 in glance, but the image is something else,
265+
# so we abort before qemu-img-info
266+
qemu_img_info.reset_mock()
267+
mock_gi.reset_mock()
268+
inspector.safety_check.reset_mock()
269+
mock_gi.return_value.from_file.side_effect = (
270+
format_inspector.ImageFormatError)
271+
self.assertRaises(exception.ImageUnacceptable,
272+
images.fetch_to_raw, None, 'href123', '/no.path')
273+
mock_gi.assert_called_once_with('qcow2')
274+
inspector.safety_check.assert_not_called()
275+
qemu_img_info.assert_not_called()
276+
277+
@mock.patch.object(images, 'IMAGE_API')
278+
@mock.patch('nova.image.format_inspector.get_inspector')
279+
@mock.patch.object(images, 'qemu_img_info')
280+
@mock.patch.object(images, 'fetch')
281+
def test_fetch_to_raw_inspector_disabled(self, fetch, qemu_img_info,
282+
mock_gi, mock_glance):
283+
self.flags(disable_deep_image_inspection=True,
284+
group='workarounds')
285+
qemu_img_info.side_effect = test.TestingException
286+
self.assertRaises(test.TestingException,
287+
images.fetch_to_raw, None, 'href123', '/no.path')
288+
# If deep inspection is disabled, we should never call the inspector
289+
mock_gi.assert_not_called()
290+
# ... and we let qemu-img detect the format itself.
291+
qemu_img_info.assert_called_once_with('/no.path.part',
292+
format=None)
293+
mock_glance.get.assert_not_called()
294+
295+
@mock.patch.object(images, 'IMAGE_API')
296+
@mock.patch.object(images, 'qemu_img_info')
297+
def test_fetch_inspect_ami(self, imginfo, glance):
298+
glance.get.return_value = {'disk_format': 'ami'}
299+
self.assertRaises(exception.ImageUnacceptable,
300+
images.fetch_to_raw, None, 'href123', '/no.path')
301+
# Make sure 'ami was translated into 'raw' before we call qemu-img
302+
imginfo.assert_called_once_with('/no.path.part', format='raw')
303+
304+
@mock.patch.object(images, 'IMAGE_API')
305+
@mock.patch.object(images, 'qemu_img_info')
306+
def test_fetch_inspect_aki(self, imginfo, glance):
307+
glance.get.return_value = {'disk_format': 'aki'}
308+
self.assertRaises(exception.ImageUnacceptable,
309+
images.fetch_to_raw, None, 'href123', '/no.path')
310+
# Make sure 'aki was translated into 'raw' before we call qemu-img
311+
imginfo.assert_called_once_with('/no.path.part', format='raw')
312+
313+
@mock.patch.object(images, 'IMAGE_API')
314+
@mock.patch.object(images, 'qemu_img_info')
315+
def test_fetch_inspect_ari(self, imginfo, glance):
316+
glance.get.return_value = {'disk_format': 'ari'}
317+
self.assertRaises(exception.ImageUnacceptable,
318+
images.fetch_to_raw, None, 'href123', '/no.path')
319+
# Make sure 'aki was translated into 'raw' before we call qemu-img
320+
imginfo.assert_called_once_with('/no.path.part', format='raw')
321+
322+
@mock.patch.object(images, 'IMAGE_API')
323+
@mock.patch.object(images, 'qemu_img_info')
324+
def test_fetch_inspect_unknown_format(self, imginfo, glance):
325+
glance.get.return_value = {'disk_format': 'commodore-64-disk'}
326+
self.assertRaises(exception.ImageUnacceptable,
327+
images.fetch_to_raw, None, 'href123', '/no.path')
328+
# Unsupported formats do not make it past deep inspection
329+
imginfo.assert_not_called()
330+
331+
@mock.patch.object(images, 'IMAGE_API')
332+
@mock.patch.object(images, 'qemu_img_info')
333+
@mock.patch('nova.image.format_inspector.get_inspector')
334+
def test_fetch_inspect_disagrees_qemu(self, mock_gi, imginfo, glance):
335+
glance.get.return_value = {'disk_format': 'qcow2'}
336+
# Glance and inspector think it is a qcow2 file, but qemu-img does not
337+
# agree. It was forced to interpret as a qcow2, but returned no
338+
# format information as a result.
339+
imginfo.return_value.data_file = None
340+
self.assertRaises(exception.ImageUnacceptable,
341+
images.fetch_to_raw, None, 'href123', '/no.path')
342+
imginfo.assert_called_once_with('/no.path.part', format='qcow2')

nova/virt/images.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def do_image_deep_inspection(img, image_href, path):
160160
# No inspector was found
161161
LOG.warning('Unable to perform deep image inspection on type %r',
162162
img['disk_format'])
163-
if disk_format == 'ami':
163+
if disk_format in ('ami', 'aki', 'ari'):
164164
# A lot of things can be in a UEC, although it is typically a raw
165165
# filesystem. We really have nothing we can do other than treat it
166166
# like a 'raw', which is what qemu-img will detect a filesystem as

0 commit comments

Comments
 (0)