Skip to content

Commit b75dba3

Browse files
{Storage} Validate blob copy source endpoint (#34138)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 46b4c21 commit b75dba3

5 files changed

Lines changed: 295 additions & 1 deletion

File tree

‎src/azure-cli/azure/cli/command_modules/storage/_params.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,6 +1396,7 @@ def load_arguments(self, _): # pylint: disable=too-many-locals, too-many-statem
13961396
c.register_precondition_options()
13971397
c.register_precondition_options(prefix='source_')
13981398
c.register_source_uri_arguments(validator=validate_source_url)
1399+
c.argument('source_is_validated_same_account', ignore_type)
13991400

14001401
c.ignore('incremental_copy')
14011402
c.argument('if_match', options_list=['--destination-if-match'])

‎src/azure-cli/azure/cli/command_modules/storage/_validators.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ def _query_account_key(cli_ctx, account_name):
4343
return scf.storage_accounts.list_keys(rg, account_name, logging_enable=False).keys_property[0].value # pylint: disable=no-member
4444

4545

46+
def _is_valid_storage_account_name(account_name):
47+
import re
48+
return bool(account_name and re.fullmatch(r'[a-z0-9]{3,24}', account_name))
49+
50+
4651
def _query_account_rg(cli_ctx, account_name):
4752
"""Query the storage account's resource group, which the mgmt sdk requires."""
4853
scf = storage_client_factory(cli_ctx)
@@ -422,6 +427,7 @@ def validate_source_url(cmd, namespace): # pylint: disable=too-many-statements,
422427
'\n\tOR --source-share --source-path [--source-account-name & key]'
423428

424429
ns = vars(namespace)
430+
ns['source_is_validated_same_account'] = False
425431

426432
# source as blob
427433
container = ns.pop('source_container', None)
@@ -487,6 +493,9 @@ def validate_source_url(cmd, namespace): # pylint: disable=too-many-statements,
487493
except ValueError:
488494
raise RequiredArgumentMissingError('Source storage account {} not found.'.format(source_account_name))
489495

496+
ns['source_is_validated_same_account'] = bool(
497+
valid_blob_source and same_account and _is_valid_storage_account_name(source_account_name))
498+
490499
# if oauth, use user delegation key to generate sas
491500
source_user_delegation_key = None
492501
if is_oauth:

‎src/azure-cli/azure/cli/command_modules/storage/operations/blob.py‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1103,14 +1103,59 @@ def query_blob(client, query_expression, input_config=None, output_config=None,
11031103
return reader.readall().decode("utf-8")
11041104

11051105

1106+
def _normalize_url_origin(url):
1107+
from ipaddress import ip_address
1108+
from urllib.parse import urlsplit
1109+
import idna
1110+
1111+
try:
1112+
parsed = urlsplit(url)
1113+
scheme = parsed.scheme.lower()
1114+
hostname = parsed.hostname
1115+
if scheme not in ('http', 'https') or not hostname:
1116+
return None
1117+
1118+
hostname = hostname.rstrip('.')
1119+
if not hostname:
1120+
return None
1121+
try:
1122+
hostname = ip_address(hostname).compressed
1123+
except ValueError:
1124+
hostname = idna.encode(hostname, uts46=True).decode('ascii').lower().rstrip('.')
1125+
if not hostname:
1126+
return None
1127+
1128+
port = parsed.port
1129+
if port is None:
1130+
port = 443 if scheme == 'https' else 80
1131+
return scheme, hostname, port
1132+
except (UnicodeError, ValueError):
1133+
return None
1134+
1135+
1136+
def _same_url_origin(first_url, second_url):
1137+
first_origin = _normalize_url_origin(first_url)
1138+
return first_origin is not None and first_origin == _normalize_url_origin(second_url)
1139+
1140+
1141+
def _account_names_match(first_client, second_client):
1142+
first_account = first_client.account_name
1143+
second_account = second_client.account_name
1144+
return bool(first_account and second_account and first_account.lower() == second_account.lower())
1145+
1146+
11061147
def copy_blob(cmd, client, source_url, metadata=None, **kwargs):
11071148
if not kwargs['requires_sync']:
11081149
kwargs.pop('requires_sync')
11091150
blob_type = kwargs.pop('destination_blob_type', None)
1151+
source_is_validated_same_account = kwargs.pop('source_is_validated_same_account', False)
11101152
src_client = kwargs.pop('source_client', None)
11111153
if src_client is None:
11121154
src_client = client.from_blob_url(source_url)
1113-
if src_client.account_name == client.account_name:
1155+
source_matches_destination = _same_url_origin(source_url, client.url) and \
1156+
_account_names_match(src_client, client)
1157+
can_reuse_destination_credential = source_is_validated_same_account or source_matches_destination
1158+
if can_reuse_destination_credential:
11141159
src_client = client.from_blob_url(source_url, credential=client.credential)
11151160
StandardBlobTier = cmd.get_models('_models#StandardBlobTier')
11161161
if blob_type is not None and blob_type != 'Detect':

‎src/azure-cli/azure/cli/command_modules/storage/tests/latest/test_storage_blob_copy_scenarios.py‎

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,192 @@
33
# Licensed under the MIT License. See License.txt in the project root for license information.
44
# --------------------------------------------------------------------------------------------
55

6+
import base64
7+
from unittest import TestCase, mock
8+
9+
from azure.storage.blob import BlobServiceClient
10+
611
from azure.cli.testsdk import LiveScenarioTest, ResourceGroupPreparer, StorageAccountPreparer, JMESPathCheck
12+
from ...operations import blob as blob_operations
713
from ..storage_test_util import StorageScenarioMixin
814

915

16+
class StorageBlobCopySecurityTests(TestCase):
17+
def test_storage_blob_copy_preserves_source_sas_for_custom_endpoint(self):
18+
account_key = base64.b64encode(b'account-key').decode()
19+
service_client = BlobServiceClient(
20+
account_url='https://storage.internal.example',
21+
credential={'account_name': 'account', 'account_key': account_key})
22+
destination_client = service_client.get_blob_client(container='dst', blob='output')
23+
source_url = 'https://storage.internal.example/src/input?sp=r&sig=existing'
24+
25+
self.assertIsNone(destination_client.from_blob_url(source_url).account_name)
26+
27+
with mock.patch.object(destination_client, 'upload_blob_from_url') as upload_blob_from_url:
28+
blob_operations.copy_blob(
29+
mock.MagicMock(), destination_client, source_url,
30+
requires_sync=False, destination_blob_type='BlockBlob')
31+
32+
upload_blob_from_url.assert_called_once_with(
33+
source_url=source_url, overwrite=True, tags=None, destination_lease=None,
34+
standard_blob_tier=None, source_if_modified_since=None, source_if_unmodified_since=None,
35+
if_modified_since=None, if_unmodified_since=None, timeout=None)
36+
37+
def test_storage_blob_copy_source_origin_normalization(self):
38+
equivalent_origins = [
39+
('https://account.blob.core.windows.net/container/source',
40+
'https://ACCOUNT.blob.core.windows.net:443/container/destination'),
41+
('https://account.blob.core.usgovcloudapi.net./container/source',
42+
'https://account.blob.core.usgovcloudapi.net/container/destination'),
43+
('https://account.blob.core.windows.net。/container/source',
44+
'https://account.blob.core.windows.net/container/destination'),
45+
('https://xn--bcher-kva.example/container/source',
46+
'https://bücher.example:443/container/destination'),
47+
('http://127.0.0.1:10000/account/container/source',
48+
'http://127.0.0.1:10000/account/container/destination'),
49+
('http://[::1]:10000/account/container/source',
50+
'http://[0:0:0:0:0:0:0:1]:10000/account/container/destination'),
51+
]
52+
for source_url, destination_url in equivalent_origins:
53+
with self.subTest(source_url=source_url, destination_url=destination_url):
54+
self.assertTrue(blob_operations._same_url_origin(source_url, destination_url))
55+
56+
different_origins = [
57+
('https://account.blob.core.windows.net.attacker.example/container/source',
58+
'https://account.blob.core.windows.net/container/destination'),
59+
('https://account.blob.core.windows.net@attacker.example/container/source',
60+
'https://account.blob.core.windows.net/container/destination'),
61+
('http://account.blob.core.windows.net/container/source',
62+
'https://account.blob.core.windows.net/container/destination'),
63+
('https://account.blob.core.windows.net:444/container/source',
64+
'https://account.blob.core.windows.net/container/destination'),
65+
('https://faß.example/container/source',
66+
'https://fass.example/container/destination'),
67+
('https://account.blob.core.windows.net/container/source',
68+
'https://custom.example/container/destination'),
69+
('not-a-url', 'https://account.blob.core.windows.net/container/destination'),
70+
]
71+
for source_url, destination_url in different_origins:
72+
with self.subTest(source_url=source_url, destination_url=destination_url):
73+
self.assertFalse(blob_operations._same_url_origin(source_url, destination_url))
74+
75+
@mock.patch.object(blob_operations, 'generate_sas_blob_uri')
76+
def test_storage_blob_copy_does_not_reuse_credentials_for_untrusted_source(self, generate_sas):
77+
source_url = 'https://account.blob.core.windows.net.attacker.example/container/source'
78+
79+
for blob_type in ('BlockBlob', 'AppendBlob', 'PageBlob'):
80+
with self.subTest(blob_type=blob_type):
81+
destination_client = mock.MagicMock()
82+
destination_client.account_name = 'account'
83+
destination_client.url = 'https://account.blob.core.windows.net/container/destination'
84+
destination_client.credential = mock.sentinel.destination_credential
85+
86+
source_client = mock.MagicMock()
87+
source_client.account_name = 'account'
88+
source_client.get_blob_properties.return_value.size = 512
89+
source_service_client = source_client._get_container_client.return_value. \
90+
_get_blob_service_client.return_value
91+
source_service_client.credential = None
92+
destination_client.from_blob_url.return_value = source_client
93+
94+
cmd = mock.MagicMock()
95+
blob_operations.copy_blob(cmd, destination_client, source_url,
96+
requires_sync=False, destination_blob_type=blob_type)
97+
98+
self.assertTrue(all('credential' not in call.kwargs
99+
for call in destination_client.from_blob_url.call_args_list))
100+
generate_sas.assert_not_called()
101+
generate_sas.reset_mock()
102+
103+
@mock.patch.object(blob_operations, 'generate_sas_blob_uri')
104+
def test_storage_blob_copy_reuses_credentials_for_same_source_origin(self, generate_sas):
105+
source_url = 'https://ACCOUNT.blob.core.windows.net:443/container/source'
106+
signed_source_url = source_url + '?sig=generated'
107+
generate_sas.return_value = signed_source_url
108+
109+
destination_client = mock.MagicMock()
110+
destination_client.account_name = 'account'
111+
destination_client.url = 'https://account.blob.core.windows.net/container/destination'
112+
destination_client.credential = mock.MagicMock(account_key='key')
113+
114+
anonymous_source_client = mock.MagicMock()
115+
anonymous_source_client.account_name = 'ACCOUNT'
116+
authenticated_source_client = mock.MagicMock()
117+
authenticated_source_service = authenticated_source_client._get_container_client.return_value. \
118+
_get_blob_service_client.return_value
119+
authenticated_source_service.credential = destination_client.credential
120+
destination_client.from_blob_url.side_effect = [anonymous_source_client, authenticated_source_client]
121+
122+
cmd = mock.MagicMock()
123+
blob_operations.copy_blob(cmd, destination_client, source_url,
124+
requires_sync=False, destination_blob_type='BlockBlob')
125+
126+
destination_client.from_blob_url.assert_has_calls([
127+
mock.call(source_url),
128+
mock.call(source_url, credential=destination_client.credential)
129+
])
130+
generate_sas.assert_called_once()
131+
destination_client.upload_blob_from_url.assert_called_once_with(
132+
source_url=signed_source_url, overwrite=True, tags=None, destination_lease=None,
133+
standard_blob_tier=None, source_if_modified_since=None, source_if_unmodified_since=None,
134+
if_modified_since=None, if_unmodified_since=None, timeout=None)
135+
136+
@mock.patch.object(blob_operations, 'generate_sas_blob_uri')
137+
def test_storage_blob_copy_reuses_credentials_for_validated_same_account_source(self, generate_sas):
138+
source_url = 'https://account.blob.core.windows.net/src/input'
139+
signed_source_url = source_url + '?sig=generated'
140+
generate_sas.return_value = signed_source_url
141+
142+
destination_client = mock.MagicMock()
143+
destination_client.url = 'https://storage.internal.example/dst/output'
144+
destination_client.credential = mock.MagicMock(account_key='key')
145+
146+
anonymous_source_client = mock.MagicMock()
147+
authenticated_source_client = mock.MagicMock()
148+
source_service_client = authenticated_source_client._get_container_client.return_value. \
149+
_get_blob_service_client.return_value
150+
source_service_client.credential = destination_client.credential
151+
destination_client.from_blob_url.side_effect = [anonymous_source_client, authenticated_source_client]
152+
153+
cmd = mock.MagicMock()
154+
blob_operations.copy_blob(
155+
cmd, destination_client, source_url, source_is_validated_same_account=True,
156+
requires_sync=False, destination_blob_type='BlockBlob')
157+
158+
destination_client.from_blob_url.assert_has_calls([
159+
mock.call(source_url),
160+
mock.call(source_url, credential=destination_client.credential)
161+
])
162+
generate_sas.assert_called_once()
163+
destination_client.upload_blob_from_url.assert_called_once_with(
164+
source_url=signed_source_url, overwrite=True, tags=None, destination_lease=None,
165+
standard_blob_tier=None, source_if_modified_since=None, source_if_unmodified_since=None,
166+
if_modified_since=None, if_unmodified_since=None, timeout=None)
167+
168+
@mock.patch.object(blob_operations, 'generate_sas_blob_uri')
169+
def test_storage_blob_copy_does_not_reuse_credentials_across_path_style_accounts(self, generate_sas):
170+
source_url = 'http://127.0.0.1:10000/account2/src/input'
171+
172+
destination_client = mock.MagicMock()
173+
destination_client.account_name = 'account1'
174+
destination_client.url = 'http://127.0.0.1:10000/account1/dst/output'
175+
destination_client.credential = mock.sentinel.destination_credential
176+
177+
source_client = mock.MagicMock()
178+
source_client.account_name = 'account2'
179+
source_service_client = source_client._get_container_client.return_value. \
180+
_get_blob_service_client.return_value
181+
source_service_client.credential = None
182+
destination_client.from_blob_url.return_value = source_client
183+
184+
cmd = mock.MagicMock()
185+
blob_operations.copy_blob(
186+
cmd, destination_client, source_url, requires_sync=False, destination_blob_type='BlockBlob')
187+
188+
destination_client.from_blob_url.assert_called_once_with(source_url)
189+
generate_sas.assert_not_called()
190+
191+
10192
class StorageBlobCopyTests(StorageScenarioMixin, LiveScenarioTest):
11193
@ResourceGroupPreparer()
12194
@StorageAccountPreparer(parameter_name='source_account')

‎src/azure-cli/azure/cli/command_modules/storage/tests/latest/test_storage_validators.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
process_blob_source_uri, get_char_options_validator,
2020
get_source_file_or_blob_service_client_track2,
2121
validate_encryption_source, validate_source_uri,
22+
validate_source_url,
2223
validate_encryption_services, as_user_validator,
2324
get_not_none_validator, validate_upload_blob)
2425

@@ -162,6 +163,62 @@ def test_validate_source_uri(self):
162163
validate_source_uri(MockCmd(self.cli), ns)
163164
self.assertEqual(ns.copy_source, 'https://other_name.file.core.windows.net/share2?some_sas_token')
164165

166+
@mock.patch('azure.cli.command_modules.storage._validators.validate_client_parameters')
167+
def test_validate_source_url_marks_structured_same_account_blob(self, _):
168+
ns = Namespace(
169+
source_url=None, source_sas=None, source_container='src', source_blob='input',
170+
source_snapshot=None, source_share=None, source_path=None, file_snapshot=None,
171+
source_account_name='account', source_account_key=None, token_credential=None,
172+
account_name='account', account_key='key', account_url='https://storage.internal.example',
173+
connection_string=None, sas_token=None, location_mode=None, connection_timeout=None,
174+
container_name='dst', share_name=None)
175+
176+
validate_source_url(MockCmd(self.cli), ns)
177+
178+
self.assertTrue(ns.source_is_validated_same_account)
179+
self.assertEqual(ns.source_url, 'https://account.blob.core.windows.net/src/input')
180+
181+
def test_validate_source_url_does_not_mark_arbitrary_uri_as_same_account(self):
182+
source_url = 'https://account.blob.core.windows.net.attacker.example/src/input'
183+
ns = Namespace(
184+
source_url=source_url, source_sas=None, source_container=None, source_blob=None,
185+
source_snapshot=None, source_share=None, source_path=None, file_snapshot=None,
186+
source_account_name=None, source_account_key=None)
187+
validate_source_url(MockCmd(self.cli), ns)
188+
validate_source_url(MockCmd(self.cli), ns)
189+
190+
self.assertFalse(ns.source_is_validated_same_account)
191+
self.assertEqual(ns.source_url, source_url)
192+
193+
@mock.patch('azure.cli.command_modules.storage._validators.validate_client_parameters')
194+
@mock.patch('azure.cli.command_modules.storage.util.create_short_lived_file_sas_v2', return_value='sas')
195+
def test_validate_source_url_does_not_mark_structured_file_as_same_account(self, _, __):
196+
ns = Namespace(
197+
source_url=None, source_sas=None, source_container=None, source_blob=None,
198+
source_snapshot=None, source_share='src', source_path='input', file_snapshot=None,
199+
source_account_name='account', source_account_key=None, token_credential=None,
200+
account_name='account', account_key='key', sas_token=None,
201+
container_name='dst', share_name=None)
202+
203+
validate_source_url(MockCmd(self.cli), ns)
204+
205+
self.assertFalse(ns.source_is_validated_same_account)
206+
207+
@mock.patch('azure.cli.command_modules.storage._validators.validate_client_parameters')
208+
def test_validate_source_url_requires_valid_account_name_for_same_account(self, _):
209+
for account_name in (None, 'same@attacker.example:443/'):
210+
with self.subTest(account_name=account_name):
211+
ns = Namespace(
212+
source_url=None, source_sas=None, source_container='src', source_blob='input',
213+
source_snapshot=None, source_share=None, source_path=None, file_snapshot=None,
214+
source_account_name=account_name, source_account_key=None, token_credential=None,
215+
account_name=account_name, account_key='key', sas_token=None,
216+
container_name='dst', share_name=None)
217+
218+
validate_source_url(MockCmd(self.cli), ns)
219+
220+
self.assertFalse(ns.source_is_validated_same_account)
221+
165222
def test_get_not_none_validator(self):
166223
from azure.cli.core.azclierror import InvalidArgumentValueError
167224
from knack.arguments import CLICommandArgument, CLIArgumentType

0 commit comments

Comments
 (0)