]> review.fuel-infra Code Review - openstack-build/neutron-build.git/commitdiff
Add ARP spoofing protection for LinuxBridge agent
authorKevin Benton <blak111@gmail.com>
Tue, 30 Jun 2015 04:05:08 +0000 (21:05 -0700)
committerKevin Benton <blak111@gmail.com>
Tue, 7 Jul 2015 06:04:06 +0000 (23:04 -0700)
This patch adds ARP spoofing protection for the Linux Bridge
agent based on ebtables. This code was written to be minimally
invasive with the intent of back-porting to Kilo.

The protection is enabled and disabled with the same
'prevent_arp_spoofing' agent config flag added for the OVS agent
in I7c079b779245a0af6bc793564fa8a560e4226afe.

The protection works by setting up an ebtables chain for each port
and jumping all ARP traffic to that chain. The port-specific chains
have a default DROP policy and then have allow rules installed that
only allow ARP traffic with a source CIDR that matches one of the
port's fixed IPs or an allowed address pair.

Closes-Bug: #1274034
Change-Id: I0b0e3b1272472385dff060897ecbd25e93fd78e7

neutron/plugins/ml2/drivers/linuxbridge/agent/arp_protect.py [new file with mode: 0644]
neutron/plugins/ml2/drivers/linuxbridge/agent/common/config.py
neutron/plugins/ml2/drivers/linuxbridge/agent/linuxbridge_neutron_agent.py
neutron/plugins/ml2/drivers/openvswitch/agent/common/config.py
neutron/tests/common/machine_fixtures.py
neutron/tests/functional/agent/linux/test_linuxbridge_arp_protect.py [new file with mode: 0644]
neutron/tests/unit/plugins/ml2/drivers/linuxbridge/agent/test_linuxbridge_neutron_agent.py

diff --git a/neutron/plugins/ml2/drivers/linuxbridge/agent/arp_protect.py b/neutron/plugins/ml2/drivers/linuxbridge/agent/arp_protect.py
new file mode 100644 (file)
index 0000000..10fcae5
--- /dev/null
@@ -0,0 +1,128 @@
+# Copyright (c) 2015 Mirantis, Inc.
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+import netaddr
+from oslo_concurrency import lockutils
+from oslo_log import log as logging
+
+from neutron.agent.linux import ip_lib
+from neutron.i18n import _LI
+
+LOG = logging.getLogger(__name__)
+SPOOF_CHAIN_PREFIX = 'neutronARP-'
+
+
+def setup_arp_spoofing_protection(vif, port_details):
+    current_rules = ebtables(['-L']).splitlines()
+    if not port_details.get('port_security_enabled', True):
+        # clear any previous entries related to this port
+        delete_arp_spoofing_protection([vif], current_rules)
+        LOG.info(_LI("Skipping ARP spoofing rules for port '%s' because "
+                     "it has port security disabled"), vif)
+        return
+    # collect all of the addresses and cidrs that belong to the port
+    addresses = {f['ip_address'] for f in port_details['fixed_ips']}
+    if port_details.get('allowed_address_pairs'):
+        addresses |= {p['ip_address']
+                      for p in port_details['allowed_address_pairs']}
+
+    addresses = {ip for ip in addresses
+                 if netaddr.IPNetwork(ip).version == 4}
+    if any(netaddr.IPNetwork(ip).prefixlen == 0 for ip in addresses):
+        # don't try to install protection because a /0 prefix allows any
+        # address anyway and the ARP_SPA can only match on /1 or more.
+        return
+
+    install_arp_spoofing_protection(vif, addresses, current_rules)
+
+
+def chain_name(vif):
+    # start each chain with a common identifer for cleanup to find
+    return '%s%s' % (SPOOF_CHAIN_PREFIX, vif)
+
+
+@lockutils.synchronized('ebtables')
+def delete_arp_spoofing_protection(vifs, current_rules=None):
+    if not current_rules:
+        current_rules = ebtables(['-L']).splitlines()
+    # delete the jump rule and then delete the whole chain
+    jumps = [vif for vif in vifs if vif_jump_present(vif, current_rules)]
+    for vif in jumps:
+        ebtables(['-D', 'FORWARD', '-i', vif, '-j',
+                  chain_name(vif), '-p', 'ARP'])
+    for vif in vifs:
+        if chain_exists(chain_name(vif), current_rules):
+            ebtables(['-X', chain_name(vif)])
+
+
+def delete_unreferenced_arp_protection(current_vifs):
+    # deletes all jump rules and chains that aren't in current_vifs but match
+    # the spoof prefix
+    output = ebtables(['-L']).splitlines()
+    to_delete = []
+    for line in output:
+        # we're looking to find and turn the following:
+        # Bridge chain: SPOOF_CHAIN_PREFIXtap199, entries: 0, policy: DROP
+        # into 'tap199'
+        if line.startswith('Bridge chain: %s' % SPOOF_CHAIN_PREFIX):
+            devname = line.split(SPOOF_CHAIN_PREFIX, 1)[1].split(',')[0]
+            if devname not in current_vifs:
+                to_delete.append(devname)
+    LOG.info(_LI("Clearing orphaned ARP spoofing entries for devices %s"),
+             to_delete)
+    delete_arp_spoofing_protection(to_delete, output)
+
+
+@lockutils.synchronized('ebtables')
+def install_arp_spoofing_protection(vif, addresses, current_rules):
+    # make a VIF-specific ARP chain so we don't conflict with other rules
+    vif_chain = chain_name(vif)
+    if not chain_exists(vif_chain, current_rules):
+        ebtables(['-N', vif_chain, '-P', 'DROP'])
+    # flush the chain to clear previous accepts. this will cause dropped ARP
+    # packets until the allows are installed, but that's better than leaked
+    # spoofed packets and ARP can handle losses.
+    ebtables(['-F', vif_chain])
+    for addr in addresses:
+        ebtables(['-A', vif_chain, '-p', 'ARP', '--arp-ip-src', addr,
+                  '-j', 'ACCEPT'])
+    # check if jump rule already exists, if not, install it
+    if not vif_jump_present(vif, current_rules):
+        ebtables(['-A', 'FORWARD', '-i', vif, '-j',
+                  vif_chain, '-p', 'ARP'])
+
+
+def chain_exists(chain, current_rules):
+    for rule in current_rules:
+        if rule.startswith('Bridge chain: %s' % chain):
+            return True
+    return False
+
+
+def vif_jump_present(vif, current_rules):
+    searches = (('-i %s' % vif), ('-j %s' % chain_name(vif)), ('-p ARP'))
+    for line in current_rules:
+        if all(s in line for s in searches):
+            return True
+    return False
+
+
+# Used to scope ebtables commands in testing
+NAMESPACE = None
+
+
+def ebtables(comm):
+    execute = ip_lib.IPWrapper(NAMESPACE).netns.execute
+    return execute(['ebtables'] + comm, run_as_root=True)
index fa1487c6b49f089507fc88fc19b8b3dd028175e0..c31e51736daddb93f81d21cffe12161041f13a59 100644 (file)
@@ -66,6 +66,22 @@ agent_opts = [
                help=_("Set new timeout in seconds for new rpc calls after "
                       "agent receives SIGTERM. If value is set to 0, rpc "
                       "timeout won't be changed")),
+    # TODO(kevinbenton): The following opt is duplicated between the OVS agent
+    # and the Linuxbridge agent to make it easy to back-port. These shared opts
+    # should be moved into a common agent config options location as part of
+    # the deduplication work.
+    cfg.BoolOpt('prevent_arp_spoofing', default=True,
+                help=_("Enable suppression of ARP responses that don't match "
+                       "an IP address that belongs to the port from which "
+                       "they originate. Note: This prevents the VMs attached "
+                       "to this agent from spoofing, it doesn't protect them "
+                       "from other devices which have the capability to spoof "
+                       "(e.g. bare metal or VMs attached to agents without "
+                       "this flag set to True). Spoofing rules will not be "
+                       "added to any ports that have port security disabled. "
+                       "For LinuxBridge, this requires ebtables. For OVS, it "
+                       "requires a version that supports matching ARP "
+                       "headers."))
 ]
 
 
index b359dac62ec7854dcaacb2e018703ed66543d3b5..d97e21bc659ef4a771b5d7d944d45745396ac64c 100644 (file)
@@ -47,6 +47,7 @@ from neutron.i18n import _LE, _LI, _LW
 from neutron.plugins.common import constants as p_const
 from neutron.plugins.ml2.drivers.l2pop.rpc_manager \
     import l2population_rpc as l2pop_rpc
+from neutron.plugins.ml2.drivers.linuxbridge.agent import arp_protect
 from neutron.plugins.ml2.drivers.linuxbridge.agent.common import config  # noqa
 from neutron.plugins.ml2.drivers.linuxbridge.agent.common \
     import constants as lconst
@@ -768,6 +769,7 @@ class LinuxBridgeNeutronAgentRPC(service.Service):
         self.quitting_rpc_timeout = quitting_rpc_timeout
 
     def start(self):
+        self.prevent_arp_spoofing = cfg.CONF.AGENT.prevent_arp_spoofing
         self.setup_linux_bridge(self.interface_mappings)
         configurations = {'interface_mappings': self.interface_mappings}
         if self.br_mgr.vxlan_mode != lconst.VXLAN_NONE:
@@ -895,6 +897,11 @@ class LinuxBridgeNeutronAgentRPC(service.Service):
             if 'port_id' in device_details:
                 LOG.info(_LI("Port %(device)s updated. Details: %(details)s"),
                          {'device': device, 'details': device_details})
+                if self.prevent_arp_spoofing:
+                    port = self.br_mgr.get_tap_device_name(
+                        device_details['port_id'])
+                    arp_protect.setup_arp_spoofing_protection(port,
+                                                              device_details)
                 if device_details['admin_state_up']:
                     # create the networking for the port
                     network_type = device_details.get('network_type')
@@ -948,6 +955,8 @@ class LinuxBridgeNeutronAgentRPC(service.Service):
                 LOG.info(_LI("Port %s updated."), device)
             else:
                 LOG.debug("Device %s not defined on plugin", device)
+        if self.prevent_arp_spoofing:
+            arp_protect.delete_arp_spoofing_protection(devices)
         return resync
 
     def scan_devices(self, previous, sync):
@@ -968,6 +977,10 @@ class LinuxBridgeNeutronAgentRPC(service.Service):
                         'current': set(),
                         'updated': set(),
                         'removed': set()}
+            # clear any orphaned ARP spoofing rules (e.g. interface was
+            # manually deleted)
+            if self.prevent_arp_spoofing:
+                arp_protect.delete_unreferenced_arp_protection(current_devices)
 
         if sync:
             # This is the first iteration, or the previous one had a problem.
index e7f512a00af3a747c61d0f68161de6f823eede62..98b6210f937d68fad6510aa6d136e04c0e56052c 100644 (file)
@@ -86,8 +86,9 @@ agent_opts = [
                        "(e.g. bare metal or VMs attached to agents without "
                        "this flag set to True). Spoofing rules will not be "
                        "added to any ports that have port security disabled. "
-                       "This requires a version of OVS that supports matching "
-                       "ARP headers.")),
+                       "For LinuxBridge, this requires ebtables. For OVS, it "
+                       "requires a version that supports matching ARP "
+                       "headers.")),
     cfg.BoolOpt('dont_fragment', default=True,
                 help=_("Set or un-set the don't fragment (DF) bit on "
                        "outgoing IP packet carrying GRE/VXLAN tunnel.")),
index 6e46c879b7940630df25c8fae6c874fd82e204de..c6ff0f78f8a85e62e8dd63c9464845f2e9fa195b 100644 (file)
@@ -76,19 +76,19 @@ class PeerMachines(fixtures.Fixture):
     :type machines: FakeMachine list
     """
 
-    AMOUNT = 2
     CIDR = '192.168.0.1/24'
 
-    def __init__(self, bridge, ip_cidr=None, gateway_ip=None):
+    def __init__(self, bridge, ip_cidr=None, gateway_ip=None, amount=2):
         super(PeerMachines, self).__init__()
         self.bridge = bridge
         self.ip_cidr = ip_cidr or self.CIDR
         self.gateway_ip = gateway_ip
+        self.amount = amount
 
     def _setUp(self):
         self.machines = []
 
-        for index in range(self.AMOUNT):
+        for index in range(self.amount):
             ip_cidr = net_helpers.increment_ip_cidr(self.ip_cidr, index)
             self.machines.append(
                 self.useFixture(
diff --git a/neutron/tests/functional/agent/linux/test_linuxbridge_arp_protect.py b/neutron/tests/functional/agent/linux/test_linuxbridge_arp_protect.py
new file mode 100644 (file)
index 0000000..8ccd715
--- /dev/null
@@ -0,0 +1,100 @@
+# Copyright (c) 2015 Mirantis, Inc.
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+from neutron.plugins.ml2.drivers.linuxbridge.agent import arp_protect
+
+from neutron.tests.common import machine_fixtures
+from neutron.tests.common import net_helpers
+from neutron.tests.functional import base as functional_base
+
+no_arping = net_helpers.assert_no_arping
+arping = net_helpers.assert_arping
+
+
+class LinuxBridgeARPSpoofTestCase(functional_base.BaseSudoTestCase):
+
+    def setUp(self):
+        super(LinuxBridgeARPSpoofTestCase, self).setUp()
+
+        lbfixture = self.useFixture(net_helpers.LinuxBridgeFixture())
+        self.addCleanup(setattr, arp_protect, 'NAMESPACE', None)
+        arp_protect.NAMESPACE = lbfixture.namespace
+        bridge = lbfixture.bridge
+        self.source, self.destination, self.observer = self.useFixture(
+            machine_fixtures.PeerMachines(bridge, amount=3)).machines
+
+    def _add_arp_protection(self, machine, addresses, extra_port_dict=None):
+        port_dict = {'fixed_ips': [{'ip_address': a} for a in addresses]}
+        if extra_port_dict:
+            port_dict.update(extra_port_dict)
+        name = net_helpers.VethFixture.get_peer_name(machine.port.name)
+        arp_protect.setup_arp_spoofing_protection(name, port_dict)
+        self.addCleanup(arp_protect.delete_arp_spoofing_protection,
+                        [name])
+
+    def test_arp_no_protection(self):
+        arping(self.source.namespace, self.destination.ip)
+        arping(self.destination.namespace, self.source.ip)
+
+    def test_arp_correct_protection(self):
+        self._add_arp_protection(self.source, [self.source.ip])
+        self._add_arp_protection(self.destination, [self.destination.ip])
+        arping(self.source.namespace, self.destination.ip)
+        arping(self.destination.namespace, self.source.ip)
+
+    def test_arp_fails_incorrect_protection(self):
+        self._add_arp_protection(self.source, ['1.1.1.1'])
+        self._add_arp_protection(self.destination, ['2.2.2.2'])
+        no_arping(self.source.namespace, self.destination.ip)
+        no_arping(self.destination.namespace, self.source.ip)
+
+    def test_arp_protection_removal(self):
+        self._add_arp_protection(self.source, ['1.1.1.1'])
+        self._add_arp_protection(self.destination, ['2.2.2.2'])
+        no_arping(self.observer.namespace, self.destination.ip)
+        no_arping(self.observer.namespace, self.source.ip)
+        name = net_helpers.VethFixture.get_peer_name(self.source.port.name)
+        arp_protect.delete_arp_spoofing_protection([name])
+        # spoofing should have been removed from source, but not dest
+        arping(self.observer.namespace, self.source.ip)
+        no_arping(self.observer.namespace, self.destination.ip)
+
+    def test_arp_protection_update(self):
+        self._add_arp_protection(self.source, ['1.1.1.1'])
+        self._add_arp_protection(self.destination, ['2.2.2.2'])
+        no_arping(self.observer.namespace, self.destination.ip)
+        no_arping(self.observer.namespace, self.source.ip)
+        self._add_arp_protection(self.source, ['192.0.0.0/1'])
+        # spoofing should have been updated on source, but not dest
+        arping(self.observer.namespace, self.source.ip)
+        no_arping(self.observer.namespace, self.destination.ip)
+
+    def test_arp_protection_port_security_disabled(self):
+        self._add_arp_protection(self.source, ['1.1.1.1'])
+        no_arping(self.observer.namespace, self.source.ip)
+        self._add_arp_protection(self.source, ['1.1.1.1'],
+                                 {'port_security_enabled': False})
+        arping(self.observer.namespace, self.source.ip)
+
+    def test_arp_protection_dead_reference_removal(self):
+        self._add_arp_protection(self.source, ['1.1.1.1'])
+        self._add_arp_protection(self.destination, ['2.2.2.2'])
+        no_arping(self.observer.namespace, self.destination.ip)
+        no_arping(self.observer.namespace, self.source.ip)
+        name = net_helpers.VethFixture.get_peer_name(self.source.port.name)
+        # This should remove all arp protect rules that aren't source port
+        arp_protect.delete_unreferenced_arp_protection([name])
+        no_arping(self.observer.namespace, self.source.ip)
+        arping(self.observer.namespace, self.destination.ip)
index 8651a14d8ffb207e0f05033e74c784bf2e75f6bd..c0324f4e8d468db36f09dc5cdab6d057208b0e13 100644 (file)
@@ -89,6 +89,7 @@ class TestLinuxBridgeAgent(base.BaseTestCase):
         super(TestLinuxBridgeAgent, self).setUp()
         # disable setting up periodic state reporting
         cfg.CONF.set_override('report_interval', 0, 'AGENT')
+        cfg.CONF.set_override('prevent_arp_spoofing', False, 'AGENT')
         cfg.CONF.set_default('firewall_driver',
                              'neutron.agent.firewall.NoopFirewallDriver',
                              group='SECURITYGROUP')