Skip to content

Commit

Permalink
Merge pull request #33 from esnet-security/topic/soehlert/configurabl…
Browse files Browse the repository at this point in the history
…e_payloads

Topic/soehlert/configurable payloads
  • Loading branch information
grigorescu authored May 4, 2024
2 parents 933754a + 821e426 commit 1b1f8c2
Show file tree
Hide file tree
Showing 8 changed files with 105 additions and 60 deletions.
5 changes: 4 additions & 1 deletion .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,19 @@ Any API clients will connect to the SCRAM REST API, which is over HTTPS. We can
information being sent to the API from the client. We wanted to centralize the administration of the clients, so it
seemed natural to use the built-in django admin site. The client is allowed to start up and hit a POST only endpoint on
first connection where it registers itself with SCRAM. We only allow the client to tell us its fqdn and a unique
identifier. The SCRAM admin is expected to go to the admin site to toggle the boolean for setting the client as
identifier. The SCRAM administrator is expected to go to the admin site to toggle the boolean for setting the client as
authorized, as well as choose from any of the list of available actiontypes. During an entry's creation, we verify this
client is allowed to create an entry using the actiontype it is giving.
client is allowed to create an entry using the actiontype it is providing.

This model does allow for anyone to "register" a client, but until someone with admin level permissions goes to the admin
site and accepts the registration and sets authorized actiontypes, this client cannot create any entries and therefore,
not affect any sort of change. This endpoint is POST only, so nobody should be allowed to see what clients exist unless
they can access the admin site. Our main security concern after all this is a DoS by constantly POSTing to this endpoint,
which can be handled the same way any other DoS would be dealth with (ie likely blocked via SCRAM).

#### Configurable Payloads
Configurable payloads allows you to override certain data sent to the translator. Currently, this is ASN and BGP community.
In order to do so, you update the JSON dictionary inside the Web Socket Message entry in the admin page. One thing to note
is that you must include a "route" key in that dictionary with a string value of any kind. If you decide to change the
key in the JSON payload whose value will contain the route being acted on field, you must change the route key in the
dictionary to match that field.
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Feature: entries auto-expire
And we add the entry 192.0.2.1/32 with expiration 1970-01-01T00:00:00Z
And we add the entry 192.0.2.1/32 with expiration 2030-01-01T00:00:00Z
Then 192.0.2.1/32 is announced by block translators
And we remove expired entries
Then we remove expired entries
And 192.0.2.1/32 is announced by block translators

Scenario: Adding an IP twice gets expired after the last entry expires in 2 seconds
Expand All @@ -55,7 +55,7 @@ Feature: entries auto-expire
And we add the entry 192.0.2.1/32 with expiration 1970-01-01T00:00:00Z
And we add the entry 192.0.2.1/32 with expiration in 12 seconds
Then 192.0.2.1/32 is announced by block translators
And we remove expired entries
Then we remove expired entries
And we wait 1 seconds
And 192.0.2.1/32 is announced by block translators
And we wait 13 seconds
Expand Down
1 change: 1 addition & 0 deletions scram/route_manager/tests/test_websockets.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ def api_create_entry(self, route):
"route": route,
"comment": "test",
"uuid": self.uuid,
"who": "Test User",
},
format="json",
)
Expand Down
90 changes: 59 additions & 31 deletions translator/gobgp.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
from google.protobuf.any_pb2 import Any

_TIMEOUT_SECONDS = 1000
DEFAULT_ASN = 65400
DEFAULT_COMMUNITY = 666
DEFAULT_V4_NEXTHOP = "192.0.2.199"
DEFAULT_V6_NEXTHOP = "100::1"

logging.basicConfig(level=logging.DEBUG)

Expand All @@ -16,20 +20,30 @@ def __init__(self, url):
channel = grpc.insecure_channel(url)
self.stub = gobgp_pb2_grpc.GobgpApiStub(channel)

def _get_family(self, ip_version):
def _get_family_AFI(self, ip_version):
if ip_version == 6:
return gobgp_pb2.Family.AFI_IP6
else:
return gobgp_pb2.Family.AFI_IP

def _build_path(self, ip):
def _build_path(self, ip, event_data={}):
# Grab ASN and Community from our event_data, or use the defaults
asn = event_data.get("asn", DEFAULT_ASN)
community = event_data.get("community", DEFAULT_COMMUNITY)
ip_version = ip.ip.version

# Set the origin to incomplete (options are IGP, EGP, incomplete)
# Incomplete means that BGP is unsure of exactly how the prefix was injected into the topology.
# The most common scenario here is that the prefix was redistributed into Border Gateway Protocol
# from some other protocol, typically an IGP. - https://www.kwtrain.com/blog/bgp-pt2
origin = Any()
origin.Pack(
attribute_pb2.OriginAttribute(
origin=2, # INCOMPLETE
origin=2,
)
)

# IP prefix and its associated length
nlri = Any()
nlri.Pack(
attribute_pb2.IPAddressPrefix(
Expand All @@ -38,70 +52,84 @@ def _build_path(self, ip):
)
)

# Set the next hop to the correct value depending on IP family
next_hop = Any()
family = self._get_family(ip.ip.version)

if ip.ip.version == 6:
family_afi = self._get_family_AFI(ip_version)
if ip_version == 6:
next_hops = event_data.get("next_hop", DEFAULT_V6_NEXTHOP)
next_hop.Pack(
attribute_pb2.MpReachNLRIAttribute(
family=gobgp_pb2.Family(afi=family, safi=gobgp_pb2.Family.SAFI_UNICAST),
next_hops=["100::1"],
family=gobgp_pb2.Family(afi=family_afi, safi=gobgp_pb2.Family.SAFI_UNICAST),
next_hops=[next_hops],
nlris=[nlri],
)
)

else:
next_hops = event_data.get("next_hop", DEFAULT_V4_NEXTHOP)
next_hop.Pack(
attribute_pb2.NextHopAttribute(
next_hop="192.0.2.199",
next_hop=next_hops,
)
)

# Set our AS Path
as_path = Any()
as_segment = None

# Make sure our asn is an acceptable number. This is the max as stated in rfc6996
assert 0 < asn < 4294967295
as_segment = [attribute_pb2.AsSegment(numbers=[asn])]
as_segments = attribute_pb2.AsPathAttribute(segments=as_segment)
as_path.Pack(as_segments)

# Set our community number
communities = Any()
comm_id = (293 << 16) + 666
communities.Pack(attribute_pb2.CommunitiesAttribute(communities=[comm_id]))
communities.Pack(attribute_pb2.CommunitiesAttribute(communities=[community]))

attributes = [origin, next_hop, communities]
attributes = [origin, next_hop, as_path, communities]

return gobgp_pb2.Path(
nlri=nlri,
pattrs=attributes,
family=gobgp_pb2.Family(afi=family, safi=gobgp_pb2.Family.SAFI_UNICAST),
family=gobgp_pb2.Family(afi=family_afi, safi=gobgp_pb2.Family.SAFI_UNICAST),
)

def add_path(self, ip):
path = self._build_path(ip)

def add_path(self, ip, event_data):
logging.info(f"Blocking {ip}")
try:
path = self._build_path(ip, event_data)

self.stub.AddPath(
gobgp_pb2.AddPathRequest(table_type=gobgp_pb2.GLOBAL, path=path),
_TIMEOUT_SECONDS,
)
self.stub.AddPath(
gobgp_pb2.AddPathRequest(table_type=gobgp_pb2.GLOBAL, path=path),
_TIMEOUT_SECONDS,
)
except AssertionError:
logging.warning("ASN assertion failed")

def del_all_paths(self):
logging.warning("Withdrawing ALL routes")

self.stub.DeletePath(gobgp_pb2.DeletePathRequest(table_type=gobgp_pb2.GLOBAL), _TIMEOUT_SECONDS)

def del_path(self, ip):
path = self._build_path(ip)

def del_path(self, ip, event_data):
logging.info(f"Unblocking {ip}")

self.stub.DeletePath(
gobgp_pb2.DeletePathRequest(table_type=gobgp_pb2.GLOBAL, path=path),
_TIMEOUT_SECONDS,
)
try:
path = self._build_path(ip, event_data)
self.stub.DeletePath(
gobgp_pb2.DeletePathRequest(table_type=gobgp_pb2.GLOBAL, path=path),
_TIMEOUT_SECONDS,
)
except AssertionError:
logging.warning("ASN assertion failed")

def get_prefixes(self, ip):
prefixes = [gobgp_pb2.TableLookupPrefix(prefix=str(ip.ip))]
family = self._get_family(ip.ip.version)
family_afi = self._get_family_AFI(ip.ip.version)
result = self.stub.ListPath(
gobgp_pb2.ListPathRequest(
table_type=gobgp_pb2.GLOBAL,
prefixes=prefixes,
family=gobgp_pb2.Family(afi=family, safi=gobgp_pb2.Family.SAFI_UNICAST),
family=gobgp_pb2.Family(afi=family_afi, safi=gobgp_pb2.Family.SAFI_UNICAST),
),
_TIMEOUT_SECONDS,
)
Expand Down
36 changes: 20 additions & 16 deletions translator/tests/acceptance/features/bgp.feature
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
Feature: block with BGP
Users can block routes via BGP

Scenario: We can block a v4 IP
When we add 192.0.2.4/32 to the block list
Then 192.0.2.4 is blocked
And 192.0.2.5 is unblocked
Scenario Outline: We can block an IP
When we add <route> with <asn> and <community> to the block list
Then <route> is blocked
And <unblock_ip> is unblocked

Scenario: We can block a v6 IP
When we add 2001:DB8:A::/64 to the block list
Then 2001:DB8:A:: is blocked
And baba:: is unblocked
Examples: data
| route | asn | community | unblock_ip |
| 192.0.2.4/32 | 54321 | 444 | 192.0.2.5 |
| 192.0.2.10/32 | 4200000000 | 321 | 192.0.2.11 |
| 2001:DB8:A::/64 | 54321 | 444 | baba:: |
| 2001:DB8:B::/64 | 4200000000 | 321 | 2001:DB8::4 |

Scenario: We can block, then unblock a v4 IP
When we add 192.0.2.4/32 to the block list
And we delete 192.0.2.4/32 from the block list
Then 192.0.2.4 is unblocked
Scenario Outline: We can block an IP
When we add <route> with <asn> and <community> to the block list
And we delete <route> with <asn> and <community> from the block list
Then <unblock_ip> is unblocked

Scenario: We can block, then unblock a v6 IP
When we add 2001:DB8:B::/64 to the block list
And we delete 2001:DB8:B::/64 from the block list
Then 2001:DB8:B:: is unblocked
Examples: data
| route | asn | community | unblock_ip |
| 192.0.2.4/32 | 54321 | 444 | 192.0.2.4 |
| 192.0.2.10/32 | 4200000000 | 321 | 192.0.2.11 |
| 2001:DB8:A::/64 | 54321 | 444 | 2001:DB8::1 |
| 2001:DB8:B::/64 | 4200000000 | 321 | 2001:DB8::4 |
14 changes: 8 additions & 6 deletions translator/tests/acceptance/steps/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@
from behave import then, when


@when("we add {route} to the block list")
def add_block(context, route):
@when("we add {route} with {asn} and {community} to the block list")
def add_block(context, route, asn, community):
ip = ipaddress.ip_interface(route)
context.gobgp.add_path(ip)
event_data = {"asn": int(asn), "community": int(community)}
context.gobgp.add_path(ip, event_data)


@when("we delete {route} from the block list")
def del_block(context, route):
@when("we delete {route} with {asn} and {community} from the block list")
def del_block(context, route, asn, community):
ip = ipaddress.ip_interface(route)
context.gobgp.del_path(ip)
event_data = {"asn": int(asn), "community": int(community)}
context.gobgp.del_path(ip, event_data)


def get_block_status(context, ip):
Expand Down
4 changes: 2 additions & 2 deletions translator/translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ async def main():
continue

if event_type == "translator_add":
g.add_path(ip)
g.add_path(ip, event_data)
elif event_type == "translator_remove":
g.del_path(ip)
g.del_path(ip, event_data)
elif event_type == "translator_check":
json_message["type"] = "translator_check_resp"
json_message["message"]["is_blocked"] = g.is_blocked(ip)
Expand Down

0 comments on commit 1b1f8c2

Please sign in to comment.