From d01e2bea977de2f5a9777ad69c5bc50b654cc893 Mon Sep 17 00:00:00 2001 From: Kushagra Date: Thu, 12 Nov 2026 01:52:31 +0530 Subject: [PATCH] Handle UTF-8 BOM in config and credentials files --- .changes/next-release/config-utf8-bom.json | 7 ++ awscli/botocore/configloader.py | 2 +- tests/unit/botocore/test_configloader.py | 76 ++++++++++++++++++++++ tests/unit/botocore/test_credentials.py | 18 +++++ 4 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 .changes/next-release/config-utf8-bom.json diff --git a/.changes/next-release/config-utf8-bom.json b/.changes/next-release/config-utf8-bom.json new file mode 100644 index 000000000000..0c6f107f3eb8 --- /dev/null +++ b/.changes/next-release/config-utf8-bom.json @@ -0,0 +1,7 @@ +[ + { + "type": "bugfix", + "category": "config", + "description": "Fix config file parsing failure when file contains a UTF-8 BOM (Byte Order Mark)" + } +] diff --git a/awscli/botocore/configloader.py b/awscli/botocore/configloader.py index e7bbf68d78d4..a61517d8ff00 100644 --- a/awscli/botocore/configloader.py +++ b/awscli/botocore/configloader.py @@ -145,7 +145,7 @@ def raw_config_parse(config_filename, parse_subsections=True): raise botocore.exceptions.ConfigNotFound(path=_unicode_path(path)) cp = configparser.RawConfigParser() try: - cp.read([path]) + cp.read([path], encoding='utf-8-sig') except (configparser.Error, UnicodeDecodeError): raise botocore.exceptions.ConfigParseError( path=_unicode_path(path) diff --git a/tests/unit/botocore/test_configloader.py b/tests/unit/botocore/test_configloader.py index 0238592da7fb..74b681c982be 100644 --- a/tests/unit/botocore/test_configloader.py +++ b/tests/unit/botocore/test_configloader.py @@ -60,6 +60,26 @@ def create_config_file(self, filename): f.write(contents) return full_path + def create_config_file_with_bom(self, filename, contents=None): + if contents is None: + contents = ( + '[default]\n' + 'aws_access_key_id = foo\n' + 'aws_secret_access_key = bar\n\n' + '[profile "personal"]\n' + 'aws_access_key_id = fie\n' + 'aws_secret_access_key = baz\n' + 'aws_security_token = fiebaz\n' + ) + directory = self.tempdir + if isinstance(filename, bytes): + directory = directory.encode('latin-1') + full_path = os.path.join(directory, filename) + + with open(full_path, 'wb') as f: + f.write(b'\xef\xbb\xbf' + contents.encode('utf-8')) + return full_path + def test_config_not_found(self): with self.assertRaises(botocore.exceptions.ConfigNotFound): raw_config_parse(path('aws_config_notfound')) @@ -207,6 +227,62 @@ def test_services_config(self): 'https://localhost:8888/', ) + def test_raw_config_parse_with_utf8_bom(self): + filename = self.create_config_file_with_bom('aws_config_bom') + loaded_config = raw_config_parse(filename) + self.assertIn('default', loaded_config) + self.assertIn('profile "personal"', loaded_config) + self.assertEqual( + loaded_config['default']['aws_access_key_id'], 'foo' + ) + self.assertEqual( + loaded_config['default']['aws_secret_access_key'], 'bar' + ) + + def test_load_config_with_utf8_bom(self): + filename = self.create_config_file_with_bom('aws_config_bom') + loaded_config = load_config(filename) + self.assertIn('profiles', loaded_config) + self.assertEqual( + sorted(loaded_config['profiles'].keys()), ['default', 'personal'] + ) + self.assertEqual( + loaded_config['profiles']['default']['aws_access_key_id'], 'foo' + ) + self.assertEqual( + loaded_config['profiles']['personal']['aws_security_token'], + 'fiebaz', + ) + + def test_nested_hierarchy_parsing_with_utf8_bom(self): + contents = ( + '[default]\n' + 'aws_access_key_id = foo\n' + 'region = us-west-2\n' + 's3 =\n' + ' signature_version = s3v4\n' + ) + filename = self.create_config_file_with_bom( + 'aws_config_nested_bom', contents + ) + loaded_config = load_config(filename) + config = loaded_config['profiles']['default'] + self.assertEqual(config['aws_access_key_id'], 'foo') + self.assertEqual(config['region'], 'us-west-2') + self.assertEqual(config['s3']['signature_version'], 's3v4') + + def test_multi_file_load_with_utf8_bom(self): + bom_file = self.create_config_file_with_bom('aws_config_bom') + non_bom_file = path('aws_third_config') + loaded_config = multi_file_load_config(bom_file, non_bom_file) + self.assertEqual( + loaded_config['profiles']['default']['aws_access_key_id'], 'foo' + ) + self.assertEqual( + loaded_config['profiles']['third']['aws_access_key_id'], + 'third_fie', + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/botocore/test_credentials.py b/tests/unit/botocore/test_credentials.py index ef0735a66cb3..46492520c68c 100644 --- a/tests/unit/botocore/test_credentials.py +++ b/tests/unit/botocore/test_credentials.py @@ -1654,6 +1654,24 @@ def test_credentials_file_does_not_exist_returns_none(self): creds = provider.load() self.assertIsNone(creds) + def test_credentials_file_with_utf8_bom(self): + with tempfile.NamedTemporaryFile('wb', delete=False) as f: + f.write( + b'\xef\xbb\xbf[default]\n' + b'aws_access_key_id = bom_key\n' + b'aws_secret_access_key = bom_secret\n' + ) + creds_file = f.name + self.addCleanup(os.unlink, creds_file) + provider = credentials.SharedCredentialProvider( + creds_filename=creds_file, + profile_name='default', + ) + creds = provider.load() + self.assertIsNotNone(creds) + self.assertEqual(creds.access_key, 'bom_key') + self.assertEqual(creds.secret_key, 'bom_secret') + def test_credentials_file_exists_with_account_id(self): self.ini_parser.return_value = { 'default': {