From d600b3d433a06446abeacfb4c5de6a88774e75de Mon Sep 17 00:00:00 2001 From: yangjianfeng Date: Mon, 5 Sep 2022 11:41:55 +0800 Subject: [PATCH] Forbid enable ndp proxy when external netwrok has no IPv6 address scope In neutron, user can create multiple ports with same IPv6 address if the network has no IPv6 address scope. This maybe result in some security issues. This can be exploited by a malicious tenant via creating a subnet with a prefix that covers an address that is already in use and take over (part of) the traffic flowing towards that address. The success of the attack depends on winning the race of who answers the NDP query first, but still a 50% chance of capturing traffic seems dangerous. The attack works not only against other addresses served by NDP proxy, but also against other hosts that may exist, potentially even the gateway for the external network. So, we should use `IPv6 address scope` to ensure the IPv6 address is unique when we want to use `ndp proxy` feature. Depends-on: https://review.opendev.org/#/c/855997 Closes-Bug: #1987410 Change-Id: I0fa431a91a7679e409386a357a01c31ec5ad0cfd --- doc/source/admin/config-ndp-proxy.rst | 154 ++++++++++++++++-- neutron/services/ndp_proxy/plugin.py | 31 ++-- neutron/tests/unit/extensions/test_l3.py | 3 + .../unit/extensions/test_l3_ndp_proxy.py | 56 ++++++- 4 files changed, 208 insertions(+), 36 deletions(-) diff --git a/doc/source/admin/config-ndp-proxy.rst b/doc/source/admin/config-ndp-proxy.rst index cc7e9b60e1c..d62cb7354e3 100644 --- a/doc/source/admin/config-ndp-proxy.rst +++ b/doc/source/admin/config-ndp-proxy.rst @@ -96,12 +96,130 @@ To configure NDP proxy, take the following steps: User workflow ~~~~~~~~~~~~~ -Assume the admin operator already prepared an IPv6 subnetpool: -``test-subnetpool``, its CIDR is 2001:db8::/96. - The basic steps to publish an IPv6 address to an external network (such as: public network) are the following: +.. note:: + + In order to prevent potential + `security risk `_, + the `ndp proxy` feature require that use `address scope` to ensure the + uniqueness of the IPv6 address which published to external + +#. Create an IPv6 address scope + + .. code-block:: console + + $ openstack address scope create test-ipv6-as --ip-version 6 + +------------+--------------------------------------+ + | Field | Value | + +------------+--------------------------------------+ + | id | 24761ec5-b659-4358-b9ab-495ead15fa7a | + | ip_version | 6 | + | name | test-ipv6-as | + | project_id | bcb0c7a5338b4a46959e47971c58f0f1 | + | shared | False | + +------------+--------------------------------------+ + +#. Create an IPv6 subnet pool + + .. code-block:: console + + $ openstack subnet pool create test-subnetpool --address-scope test-ipv6-as \ + --pool-prefix 2001:db8::/96 --default-prefix-length 112 + +-------------------+--------------------------------------+ + | Field | Value | + +-------------------+--------------------------------------+ + | address_scope_id | 24761ec5-b659-4358-b9ab-495ead15fa7a | + | created_at | 2022-09-05T06:16:31Z | + | default_prefixlen | 112 | + | default_quota | None | + | description | | + | id | 4af07f59-45b8-424d-98c5-35d20ba61526 | + | ip_version | 6 | + | is_default | False | + | max_prefixlen | 128 | + | min_prefixlen | 64 | + | name | test-subnetpool | + | prefixes | 2001:db8::/96 | + | project_id | bcb0c7a5338b4a46959e47971c58f0f1 | + | revision_number | 0 | + | shared | False | + | tags | | + | updated_at | 2022-01-01T06:42:08Z | + +-------------------+--------------------------------------+ + +#. Create an external network + + .. code-block:: console + + $ openstack network create --external --provider-network-type flat \ + --provider-physical-network public public + +---------------------------+--------------------------------------+ + | Field | Value | + +---------------------------+--------------------------------------+ + | admin_state_up | UP | + | availability_zone_hints | | + | availability_zones | | + | created_at | 2022-09-05T06:18:31Z | + | description | | + | dns_domain | None | + | id | 98b0f468-7be0-4530-919d-c4d9417c3abf | + | ipv4_address_scope | None | + | ipv6_address_scope | None | + | is_default | False | + | is_vlan_transparent | None | + | mtu | 1500 | + | name | public | + | port_security_enabled | True | + | project_id | bcb0c7a5338b4a46959e47971c58f0f1 | + | provider:network_type | flat | + | provider:physical_network | public | + | provider:segmentation_id | None | + | qos_policy_id | None | + | revision_number | 1 | + | router:external | External | + | segments | None | + | shared | False | + | status | ACTIVE | + | subnets | | + | tags | | + | updated_at | 2022-01-01T06:45:08Z | + +---------------------------+--------------------------------------+ + +#. Create an external subnet + + .. code-block:: console + + $ openstack subnet create --network public --subnet-pool test-subnetpool \ + --prefix-length 112 --ip-version 6 --no-dhcp ext-sub + +----------------------+--------------------------------------+ + | Field | Value | + +----------------------+--------------------------------------+ + | allocation_pools | 2001:db8::2-2001:db8::ffff | + | cidr | 2001:db8::/112 | + | created_at | 2022-09-05T06:21:37Z | + | description | | + | dns_nameservers | | + | dns_publish_fixed_ip | None | + | enable_dhcp | False | + | gateway_ip | 2001:db8::1 | + | host_routes | | + | id | ec11de28-9b84-4cee-b6a1-0ed56135bcd8 | + | ip_version | 6 | + | ipv6_address_mode | None | + | ipv6_ra_mode | None | + | name | ext-sub | + | network_id | 98b0f468-7be0-4530-919d-c4d9417c3abf | + | project_id | bcb0c7a5338b4a46959e47971c58f0f1 | + | revision_number | 0 | + | segment_id | None | + | service_types | | + | subnetpool_id | 4af07f59-45b8-424d-98c5-35d20ba61526 | + | tags | | + | updated_at | 2022-01-01T06:47:08Z | + +----------------------+--------------------------------------+ + #. Create a router: .. code-block:: console @@ -200,14 +318,14 @@ network (such as: public network) are the following: +----------------------+--------------------------------------+ | Field | Value | +----------------------+--------------------------------------+ - | allocation_pools | 2001:db8::2-2001:db8::ffff | - | cidr | 2001:db8::/112 | - | created_at | 2022-01-02T08:20:26Z | + | allocation_pools | 2001:db8::1:2-2001:db8::1:ffff | + | cidr | 2001:db8::1:0/112 | + | created_at | 2022-09-05T06:24:13Z | | description | | | dns_nameservers | | | dns_publish_fixed_ip | None | | enable_dhcp | True | - | gateway_ip | 2001:db8::1 | + | gateway_ip | 2001:db8::1:1 | | host_routes | | | id | 9bcf194c-d44f-4e6f-90da-98510ddef283 | | ip_version | 6 | @@ -219,7 +337,7 @@ network (such as: public network) are the following: | revision_number | 0 | | segment_id | None | | service_types | | - | subnetpool_id | 73c5311c-6750-43f5-9a69-b50c1c5694fd | + | subnetpool_id | 4af07f59-45b8-424d-98c5-35d20ba61526 | | tags | | | updated_at | 2022-01-02T08:20:26Z | +----------------------+--------------------------------------+ @@ -272,11 +390,11 @@ network (such as: public network) are the following: .. code-block:: console $ openstack port list --server test-server - +--------------------------------------+------+-------------------+------------------------------------------------------------------------------+--------+ - | ID | Name | MAC Address | Fixed IP Addresses | Status | - +--------------------------------------+------+-------------------+------------------------------------------------------------------------------+--------+ - | bdd64aa0-437a-4db6-bbca-99869426c908 | | fa:16:3e:ac:15:b8 | ip_address='2001:db8::284', subnet_id='9bcf194c-d44f-4e6f-90da-98510ddef283' | ACTIVE | - +--------------------------------------+------+-------------------+------------------------------------------------------------------------------+--------+ + +--------------------------------------+------+-------------------+--------------------------------------------------------------------------------+--------+ + | ID | Name | MAC Address | Fixed IP Addresses | Status | + +--------------------------------------+------+-------------------+--------------------------------------------------------------------------------+--------+ + | bdd64aa0-437a-4db6-bbca-99869426c908 | | fa:16:3e:ac:15:b8 | ip_address='2001:db8::1:284', subnet_id='9bcf194c-d44f-4e6f-90da-98510ddef283' | ACTIVE | + +--------------------------------------+------+-------------------+--------------------------------------------------------------------------------+--------+ Create NDP proxy for the port @@ -289,7 +407,7 @@ network (such as: public network) are the following: | created_at | 2022-01-02T08:25:31Z | | description | | | id | 73889fee-e322-443f-941e-142e4fc5f898 | - | ip_address | 2001:db8::284 | + | ip_address | 2001:db8::1:284 | | name | test-np | | port_id | bdd64aa0-437a-4db6-bbca-99869426c908 | | project_id | bcb0c7a5338b4a46959e47971c58f0f1 | @@ -302,10 +420,10 @@ network (such as: public network) are the following: .. code-block:: console - $ ping 2001:db8::284 - PING 2001:db8::284(2001:db8::284) 56 data bytes - 64 bytes from 2001:db8::284: icmp_seq=1 ttl=64 time=0.365 ms - 64 bytes from 2001:db8::284: icmp_seq=2 ttl=64 time=0.385 ms + $ ping 2001:db8::1:284 + PING 2001:db8::1:284(2001:db8::1:284) 56 data bytes + 64 bytes from 2001:db8::1:284: icmp_seq=1 ttl=64 time=0.365 ms + 64 bytes from 2001:db8::1:284: icmp_seq=2 ttl=64 time=0.385 ms .. note:: diff --git a/neutron/services/ndp_proxy/plugin.py b/neutron/services/ndp_proxy/plugin.py index b73935cde83..1fbb8edbbfa 100644 --- a/neutron/services/ndp_proxy/plugin.py +++ b/neutron/services/ndp_proxy/plugin.py @@ -75,11 +75,7 @@ class NDPProxyPlugin(l3_ndp_proxy.NDPProxyBase): # parameter is always False. enable_ndp_proxy = False if result_dict.get(l3_apidef.EXTERNAL_GW_INFO, None): - # For already existed routers (created before this plugin - # enabled), they have no ndp_proxy_state object. - if not router_db.ndp_proxy_state: - enable_ndp_proxy = cfg.CONF.enable_ndp_proxy_by_default - else: + if router_db.ndp_proxy_state: enable_ndp_proxy = router_db.ndp_proxy_state.enable_ndp_proxy result_dict[l3_ext_ndp_proxy.ENABLE_NDP_PROXY] = enable_ndp_proxy @@ -148,6 +144,8 @@ class NDPProxyPlugin(l3_ndp_proxy.NDPProxyBase): if not gw_port_id: return False port_dict = self.core_plugin.get_port(context.elevated(), gw_port_id) + if not self._check_ext_gw_network(context, port_dict['network_id']): + return False v6_fixed_ips = [ fixed_ip for fixed_ip in port_dict['fixed_ips'] if (netaddr.IPNetwork(fixed_ip['ip_address']).version == V6)] @@ -158,6 +156,9 @@ class NDPProxyPlugin(l3_ndp_proxy.NDPProxyBase): return False def _check_ext_gw_network(self, context, network_id): + network = self.core_plugin.get_network(context, network_id) + if not network.get('ipv6_address_scope'): + return False ext_subnets = self.core_plugin.get_subnets( context.elevated(), filters={'network_id': network_id}) has_ipv6_subnet = False @@ -197,7 +198,7 @@ class NDPProxyPlugin(l3_ndp_proxy.NDPProxyBase): if not ext_gw_support_ndp and ndp_proxy_state is True: reason = _("The external network %s don't support " "IPv6 ndp proxy, the network has no IPv6 " - "subnets.") % network_id + "subnets or has no IPv6 address scope") % network_id raise exc.RouterGatewayNotValid( router_id=router_db.id, reason=reason) if ndp_proxy_state == lib_consts.ATTR_NOT_SPECIFIED: @@ -217,16 +218,20 @@ class NDPProxyPlugin(l3_ndp_proxy.NDPProxyBase): ndp_proxy_state = request_body.get( l3_ext_ndp_proxy.ENABLE_NDP_PROXY, lib_consts.ATTR_NOT_SPECIFIED) - if ndp_proxy_state == lib_consts.ATTR_NOT_SPECIFIED: - return - if self._gateway_is_valid(context, router_db['gw_port_id']): - self._ensure_router_ndp_proxy_state_model( - context, router_db, ndp_proxy_state) - elif ndp_proxy_state: + gw_support_ndp = self._gateway_is_valid( + context, router_db['gw_port_id']) + if ndp_proxy_state is True and not gw_support_ndp: reason = _("The router has no external gateway or the external " - "gateway port has no IPv6 address") + "gateway port has no IPv6 address or IPv6 address " + "scope") raise exc.RouterGatewayNotValid( router_id=router_db.id, reason=reason) + if ndp_proxy_state == lib_consts.ATTR_NOT_SPECIFIED: + self._ensure_router_ndp_proxy_state_model( + context, router_db, gw_support_ndp) + else: + self._ensure_router_ndp_proxy_state_model( + context, router_db, ndp_proxy_state) @registry.receives(resources.ROUTER_INTERFACE, [events.BEFORE_DELETE]) def _check_router_remove_subnet_request(self, resource, event, diff --git a/neutron/tests/unit/extensions/test_l3.py b/neutron/tests/unit/extensions/test_l3.py index c5c2758e08c..14039edf56a 100644 --- a/neutron/tests/unit/extensions/test_l3.py +++ b/neutron/tests/unit/extensions/test_l3.py @@ -388,6 +388,9 @@ class L3NatTestCaseMixin(object): data['router'][arg] = kwargs[arg] if 'distributed' in kwargs: data['router']['distributed'] = bool(kwargs['distributed']) + if 'enable_ndp_proxy' in kwargs: + data['router']['enable_ndp_proxy'] = \ + bool(kwargs['enable_ndp_proxy']) router_req = self.new_create_request('routers', data, fmt) if set_context and tenant_id: # create a specific auth context for this request diff --git a/neutron/tests/unit/extensions/test_l3_ndp_proxy.py b/neutron/tests/unit/extensions/test_l3_ndp_proxy.py index 7f8dd960c95..2814c6fee9f 100644 --- a/neutron/tests/unit/extensions/test_l3_ndp_proxy.py +++ b/neutron/tests/unit/extensions/test_l3_ndp_proxy.py @@ -78,7 +78,16 @@ class L3NDPProxyTestCase(test_address_scope.AddressScopeTestCase, ext_mgr=ext_mgr, service_plugins=svc_plugins, plugin=plugin) self.ext_api = test_extensions.setup_extensions_middleware(ext_mgr) - self.ext_net = self._make_network(self.fmt, 'ext-net', True) + self.address_scope_id = self._make_address_scope( + self.fmt, constants.IP_VERSION_6, + **{'tenant_id': self.tenant_id})['address_scope']['id'] + self.subnetpool_id = self._make_subnetpool( + self.fmt, ['2001::0/96'], + **{'address_scope_id': self.address_scope_id, + 'default_prefixlen': 112, 'tenant_id': self.tenant_id, + 'name': "test-ipv6-pool"})['subnetpool']['id'] + self.ext_net = self._make_network( + self.fmt, 'ext-net', True) self.ext_net_id = self.ext_net['network']['id'] self._set_net_external(self.ext_net_id) self._ext_subnet_v4 = self._make_subnet( @@ -87,6 +96,7 @@ class L3NDPProxyTestCase(test_address_scope.AddressScopeTestCase, self._ext_subnet_v4_id = self._ext_subnet_v4['subnet']['id'] self._ext_subnet_v6 = self._make_subnet( self.fmt, self.ext_net, gateway="2001::1:1", + subnetpool_id=self.subnetpool_id, cidr="2001::1:0/112", ip_version=constants.IP_VERSION_6, ipv6_ra_mode=constants.DHCPV6_STATEFUL, @@ -97,6 +107,7 @@ class L3NDPProxyTestCase(test_address_scope.AddressScopeTestCase, self.private_net = self._make_network(self.fmt, 'private-net', True) self.private_subnet = self._make_subnet( self.fmt, self.private_net, gateway="2001::2:1", + subnetpool_id=self.subnetpool_id, cidr="2001::2:0/112", ip_version=constants.IP_VERSION_6, ipv6_ra_mode=constants.DHCPV6_STATEFUL, @@ -249,11 +260,40 @@ class L3NDPProxyTestCase(test_address_scope.AddressScopeTestCase, router_id = router['router']['id'] err_msg = ("Can not enable ndp proxy on router %s, The router has " "no external gateway or the external gateway port has " - "no IPv6 address.") % router_id + "no IPv6 address or IPv6 address scope.") % router_id self._update_router(router_id, {'enable_ndp_proxy': True}, expected_code=exc.HTTPConflict.code, expected_message=err_msg) + def test_enable_ndp_proxy_without_address_scope(self): + with self.network() as ext_net, \ + self.subnet( + cidr='2001::12:0/112', + ip_version=constants.IP_VERSION_6, + ipv6_ra_mode=constants.DHCPV6_STATEFUL, + ipv6_address_mode=constants.DHCPV6_STATEFUL): + self._set_net_external(ext_net['network']['id']) + res = self._make_router( + self.fmt, self.tenant_id, + external_gateway_info={'network_id': ext_net['network']['id']}, + **{'enable_ndp_proxy': True}) + expected_msg = ( + "The external network %s don't support IPv6 ndp proxy, the " + "network has no IPv6 subnets or has no IPv6 address " + "scope.") % ext_net['network']['id'] + self.assertTrue(expected_msg in res['NeutronError']['message']) + router = self._make_router( + self.fmt, self.tenant_id, + external_gateway_info={'network_id': ext_net['network']['id']}) + expected_msg = ( + "Can not enable ndp proxy on router %s, The router has no " + "external gateway or the external gateway port has no IPv6 " + "address or IPv6 address scope.") % router['router']['id'] + self._update_router( + router['router']['id'], {'enable_ndp_proxy': True}, + expected_code=exc.HTTPConflict.code, + expected_message=expected_msg) + def test_delete_router_gateway_with_enable_ndp_proxy(self): with self.router() as router: router_id = router['router']['id'] @@ -281,6 +321,7 @@ class L3NDPProxyTestCase(test_address_scope.AddressScopeTestCase, def test_create_ndp_proxy_with_invalid_port(self): with self.subnet( + subnetpool_id=self.subnetpool_id, cidr='2001::8:0/112', ip_version=constants.IP_VERSION_6, ipv6_ra_mode=constants.DHCPV6_STATEFUL, @@ -290,6 +331,7 @@ class L3NDPProxyTestCase(test_address_scope.AddressScopeTestCase, ip_version=constants.IP_VERSION_6, ipv6_ra_mode=constants.DHCPV6_STATEFUL, ipv6_address_mode=constants.DHCPV6_STATEFUL, + subnetpool_id=self.subnetpool_id, cidr='2001::9:0/112') as sub2, \ self.subnet(self.private_net) as sub3, \ self.port(sub1) as port1, \ @@ -349,6 +391,7 @@ class L3NDPProxyTestCase(test_address_scope.AddressScopeTestCase, def test_create_ndp_proxy_with_invalid_router(self): with self.subnet( + subnetpool_id=self.subnetpool_id, cidr='2001::8:0/112', ipv6_ra_mode=constants.DHCPV6_STATEFUL, ipv6_address_mode=constants.DHCPV6_STATEFUL, @@ -405,7 +448,8 @@ class L3NDPProxyTestCase(test_address_scope.AddressScopeTestCase, with self.subnet(ip_version=constants.IP_VERSION_6, ipv6_ra_mode=constants.DHCPV6_STATEFUL, ipv6_address_mode=constants.DHCPV6_STATEFUL, - cidr='2001::50:1:0/112') as subnet, \ + subnetpool_id=self.subnetpool_id, + cidr='2001::50:0/112') as subnet, \ self.port(subnet) as port: subnet_id = subnet['subnet']['id'] port_id = port['port']['id'] @@ -445,9 +489,10 @@ class L3NDPProxyTestCase(test_address_scope.AddressScopeTestCase, port_id = port['port']['id'] self._router_interface_action( 'add', self.router1_id, subnet_id, None) - err_msg = ("The IPv6 address scope None of external network " + err_msg = ("The IPv6 address scope %s of external network " "conflict with internal network's IPv6 address " - "scope %s.") % addr_scope['address_scope']['id'] + "scope %s.") % (self.address_scope_id, + addr_scope['address_scope']['id']) self._create_ndp_proxy( self.router1_id, port_id, expected_code=exc.HTTPConflict.code, @@ -496,6 +541,7 @@ class L3NDPProxyTestCase(test_address_scope.AddressScopeTestCase, def test_enable_ndp_proxy_by_default_conf_option(self): cfg.CONF.set_override("enable_ndp_proxy_by_default", True) with self.subnet( + subnetpool_id=self.subnetpool_id, cidr='2001::8:0/112', ipv6_ra_mode=constants.DHCPV6_STATEFUL, ipv6_address_mode=constants.DHCPV6_STATEFUL,