Skip to content

Commit 68811b5

Browse files
Tighten file permissions for virtual MFA bootstrap output (#10194)
Restrict MFA seed files to 0600 (owner-only) to prevent other local users from reading the bootstrap data.
1 parent e0799fd commit 68811b5

3 files changed

Lines changed: 80 additions & 27 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"type": "bugfix",
3+
"category": "``iam``",
4+
"description": "Tighten file permissions for virtual MFA bootstrap output"
5+
}

awscli/customizations/iamvirtmfa.py

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,43 +22,55 @@
2222
to the specified file. It will also remove the two bootstrap data
2323
fields from the response.
2424
"""
25-
import base64
2625

27-
from awscli.customizations.arguments import StatefulArgument
28-
from awscli.customizations.arguments import resolve_given_outfile_path
29-
from awscli.customizations.arguments import is_parsed_result_successful
26+
import base64
27+
import os
3028

29+
from awscli.compat import compat_open
30+
from awscli.customizations.arguments import (
31+
StatefulArgument,
32+
is_parsed_result_successful,
33+
resolve_given_outfile_path,
34+
)
3135

3236
CHOICES = ('QRCodePNG', 'Base32StringSeed')
33-
OUTPUT_HELP = ('The output path and file name where the bootstrap '
34-
'information will be stored.')
35-
BOOTSTRAP_HELP = ('Method to use to seed the virtual MFA. '
36-
'Valid values are: %s | %s' % CHOICES)
37+
OUTPUT_HELP = (
38+
'The output path and file name where the bootstrap '
39+
'information will be stored.'
40+
)
41+
BOOTSTRAP_HELP = (
42+
'Method to use to seed the virtual MFA. '
43+
'Valid values are: %s | %s' % CHOICES
44+
)
3745

3846

3947
class FileArgument(StatefulArgument):
40-
4148
def add_to_params(self, parameters, value):
4249
# Validate the file here so we can raise an error prior
4350
# calling the service.
4451
value = resolve_given_outfile_path(value)
4552
super(FileArgument, self).add_to_params(parameters, value)
4653

4754

48-
class IAMVMFAWrapper(object):
49-
55+
class IAMVMFAWrapper:
5056
def __init__(self, event_handler):
5157
self._event_handler = event_handler
5258
self._outfile = FileArgument(
53-
'outfile', help_text=OUTPUT_HELP, required=True)
59+
'outfile', help_text=OUTPUT_HELP, required=True
60+
)
5461
self._method = StatefulArgument(
55-
'bootstrap-method', help_text=BOOTSTRAP_HELP,
56-
choices=CHOICES, required=True)
62+
'bootstrap-method',
63+
help_text=BOOTSTRAP_HELP,
64+
choices=CHOICES,
65+
required=True,
66+
)
5767
self._event_handler.register(
5868
'building-argument-table.iam.create-virtual-mfa-device',
59-
self._add_options)
69+
self._add_options,
70+
)
6071
self._event_handler.register(
61-
'after-call.iam.CreateVirtualMFADevice', self._save_file)
72+
'after-call.iam.CreateVirtualMFADevice', self._save_file
73+
)
6274

6375
def _add_options(self, argument_table, **kwargs):
6476
argument_table['outfile'] = self._outfile
@@ -71,7 +83,9 @@ def _save_file(self, parsed, **kwargs):
7183
outfile = self._outfile.value
7284
if method in parsed['VirtualMFADevice']:
7385
body = parsed['VirtualMFADevice'][method]
74-
with open(outfile, 'wb') as fp:
86+
with compat_open(outfile, 'wb', access_permissions=0o600) as fp:
87+
if hasattr(os, 'fchmod'):
88+
os.fchmod(fp.fileno(), 0o600)
7589
fp.write(base64.b64decode(body))
7690
for choice in CHOICES:
7791
if choice in parsed['VirtualMFADevice']:

tests/functional/iam/test_create_virtual_mfa_device.py

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,26 @@
1111
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
1212
# ANY KIND, either express or implied. See the License for the specific
1313
# language governing permissions and limitations under the License.
14-
from awscli.testutils import BaseAWSCommandParamsTest
1514
import os
1615

16+
from awscli.testutils import BaseAWSCommandParamsTest, skip_if_windows
1717

18-
class TestCreateVirtualMFADevice(BaseAWSCommandParamsTest):
1918

19+
class TestCreateVirtualMFADevice(BaseAWSCommandParamsTest):
2020
prefix = 'iam create-virtual-mfa-device'
2121

2222
def setUp(self):
2323
super(TestCreateVirtualMFADevice, self).setUp()
2424
self.parsed_response = {
2525
'ResponseMetadata': {
2626
'HTTPStatusCode': 200,
27-
'RequestId': 'requset-id'
27+
'RequestId': 'requset-id',
2828
},
2929
"VirtualMFADevice": {
3030
"Base32StringSeed": (
3131
"VFpYTVc2V1lIUFlFRFczSVhLUlpRUTJRVFdUSFRNRDNTQ0c3"
32-
"TkZDUVdQWDVETlNWM0IyUENaQVpWTEpQTlBOTA=="),
32+
"TkZDUVdQWDVETlNWM0IyUENaQVpWTEpQTlBOTA=="
33+
),
3334
"SerialNumber": "arn:aws:iam::419278470775:mfa/fiebaz",
3435
"QRCodePNG": (
3536
"iVBORw0KGgoAAAANSUhEUgAAAPoAAAD6CAIAAAAHjs1qAAAFi"
@@ -74,12 +75,13 @@ def setUp(self):
7475
"R3EVwF8FdBHcR3EVwF8Fd5F/+AgASajf850wfAAAAAElFTkSu"
7576
"QmCC"
7677
),
77-
}
78+
},
7879
}
7980

8081
def getpath(self, filename):
81-
return os.path.join(os.path.abspath(os.path.dirname(__file__)),
82-
filename)
82+
return os.path.join(
83+
os.path.abspath(os.path.dirname(__file__)), filename
84+
)
8385

8486
def remove_file_if_exists(self, filename):
8587
if os.path.isfile(filename):
@@ -91,7 +93,8 @@ def test_base32(self):
9193
cmdline = self.prefix
9294
cmdline += ' --virtual-mfa-device-name fiebaz'
9395
cmdline += (
94-
' --outfile %s --bootstrap-method Base32StringSeed' % outfile)
96+
' --outfile %s --bootstrap-method Base32StringSeed' % outfile
97+
)
9598
result = {"VirtualMFADeviceName": 'fiebaz'}
9699
self.assert_params_for_cmd(cmdline, result)
97100
self.assertTrue(os.path.exists(outfile))
@@ -145,7 +148,8 @@ def test_bad_response(self):
145148
},
146149
'ResponseMetadata': {
147150
'HTTPStatusCode': 409,
148-
'RequestId': 'requset-id'}
151+
'RequestId': 'requset-id',
152+
},
149153
}
150154
self.http_response.status_code = 409
151155
cmdline = self.prefix
@@ -155,4 +159,34 @@ def test_bad_response(self):
155159
self.assert_params_for_cmd(
156160
cmdline,
157161
stderr_contains=self.parsed_response['Error']['Message'],
158-
expected_rc=255)
162+
expected_rc=255,
163+
)
164+
165+
@skip_if_windows("Permissions test not valid on Windows.")
166+
def test_output_file_permissions(self):
167+
outfile = self.getpath('fiebaz_perms.b32')
168+
self.addCleanup(self.remove_file_if_exists, outfile)
169+
cmdline = self.prefix
170+
cmdline += ' --virtual-mfa-device-name fiebaz'
171+
cmdline += (
172+
' --outfile %s --bootstrap-method Base32StringSeed' % outfile
173+
)
174+
result = {"VirtualMFADeviceName": 'fiebaz'}
175+
self.assert_params_for_cmd(cmdline, result)
176+
self.assertEqual(os.stat(outfile).st_mode & 0xFFF, 0o600)
177+
178+
@skip_if_windows("Permissions test not valid on Windows.")
179+
def test_output_file_permissions_existing_file(self):
180+
outfile = self.getpath('fiebaz_perms_existing.b32')
181+
self.addCleanup(self.remove_file_if_exists, outfile)
182+
with open(outfile, 'wb') as f:
183+
f.write(b'existing')
184+
os.chmod(outfile, 0o644)
185+
cmdline = self.prefix
186+
cmdline += ' --virtual-mfa-device-name fiebaz'
187+
cmdline += (
188+
' --outfile %s --bootstrap-method Base32StringSeed' % outfile
189+
)
190+
result = {"VirtualMFADeviceName": 'fiebaz'}
191+
self.assert_params_for_cmd(cmdline, result)
192+
self.assertEqual(os.stat(outfile).st_mode & 0xFFF, 0o600)

0 commit comments

Comments
 (0)