-
Notifications
You must be signed in to change notification settings - Fork 5
feat(client): A few improvements to clients #188
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
00d5ae0
77ce3fe
6182b89
16b0d23
17d78d9
24c9d7f
464b8e0
3334e72
b81827c
02fb391
b42ae97
ccd0dec
bd29206
63b6cf9
a7289fc
12240f9
c3269d9
92ce954
601fc23
8fd2686
b203021
502de71
8f797e0
cba39df
3cbe78d
e6d053a
43053d1
d704386
920485f
873d977
e2ee4ac
d3d4160
0724e29
1e253db
c36eae7
7d0386b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,13 +8,15 @@ | |
| from django.conf import settings | ||
| from django.core.exceptions import PermissionDenied | ||
| from django.db.models import Q | ||
| from drf_spectacular.utils import extend_schema | ||
| from drf_spectacular.utils import OpenApiResponse, extend_schema | ||
| from rest_framework import status, viewsets | ||
| from rest_framework.exceptions import ValidationError | ||
| from rest_framework.permissions import AllowAny, IsAuthenticated | ||
| from rest_framework.response import Response | ||
| from simple_history.utils import update_change_reason | ||
|
|
||
| from scram.shared.shared_code import get_client_ip | ||
|
|
||
| from ..models import ActionType, Client, Entry, IgnoreEntry, Route, WebSocketSequenceElement | ||
| from .exceptions import ActiontypeNotAllowed, IgnoredRoute, NoActiveEntryFound, PrefixTooLarge | ||
| from .serializers import ( | ||
|
|
@@ -30,11 +32,15 @@ | |
|
|
||
|
|
||
| @extend_schema( | ||
| description="API endpoint for actiontypes", | ||
| responses={200: ActionTypeSerializer}, | ||
| description="API endpoint for actiontypes.", | ||
| responses={ | ||
| 200: OpenApiResponse(response=ActionTypeSerializer, description="Successful retrieval of actiontype(s)."), | ||
| 403: OpenApiResponse(description="Authentication credentials were not provided."), | ||
| 404: OpenApiResponse(description="The requested actiontype does not exist."), | ||
| }, | ||
| ) | ||
| class ActionTypeViewSet(viewsets.ReadOnlyModelViewSet): | ||
| """Lookup ActionTypes by name when authenticated, and bind to the serializer.""" | ||
| """Lookup ActionTypes by name, and bind to the serializer.""" | ||
|
|
||
| queryset = ActionType.objects.all() | ||
| permission_classes = (IsAuthenticated,) | ||
|
|
@@ -43,8 +49,17 @@ class ActionTypeViewSet(viewsets.ReadOnlyModelViewSet): | |
|
|
||
|
|
||
| @extend_schema( | ||
| description="API endpoint for ignore entries", | ||
| responses={200: IgnoreEntrySerializer}, | ||
| description="API endpoint for ignore entries.", | ||
| responses={ | ||
| 200: OpenApiResponse( | ||
| response=IgnoreEntrySerializer, description="Successful retrieval or update of an ignore entry." | ||
| ), | ||
| 201: OpenApiResponse(response=IgnoreEntrySerializer, description="Ignore entry successfully created."), | ||
| 204: OpenApiResponse(description="Ignore entry successfully deleted."), | ||
| 400: OpenApiResponse(description="Invalid data provided."), | ||
| 403: OpenApiResponse(description="Authentication credentials were not provided."), | ||
| 404: OpenApiResponse(description="The requested ignore entry does not exist."), | ||
| }, | ||
| ) | ||
| class IgnoreEntryViewSet(viewsets.ModelViewSet): | ||
| """Lookup IgnoreEntries by route when authenticated, and bind to the serializer.""" | ||
|
|
@@ -56,20 +71,80 @@ class IgnoreEntryViewSet(viewsets.ModelViewSet): | |
|
|
||
|
|
||
| @extend_schema( | ||
| description="API endpoint for clients", | ||
| responses={200: ClientSerializer}, | ||
| description="API endpoint for clients.", | ||
| responses={ | ||
| 200: OpenApiResponse( | ||
| response=ClientSerializer, | ||
| description="Client already existed and was retrieved successfully.", | ||
| ), | ||
| 201: OpenApiResponse(response=ClientSerializer, description="Client successfully created."), | ||
| 400: OpenApiResponse( | ||
| description="Client with this name already exists with a different UUID, or client_name was not provided." | ||
| ), | ||
| }, | ||
| ) | ||
| class ClientViewSet(viewsets.ModelViewSet): | ||
| """Lookup Client by hostname on POSTs regardless of authentication, and bind to the serializer.""" | ||
| """Lookup Client by client_name on POSTs regardless of authentication, and bind to the serializer.""" | ||
|
|
||
| queryset = Client.objects.all() | ||
| # We want to allow a client to be registered from anywhere | ||
| permission_classes = (AllowAny,) | ||
| serializer_class = ClientSerializer | ||
| lookup_field = "hostname" | ||
| lookup_field = "client_name" | ||
samoehlert marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| http_method_names = ["post"] | ||
|
|
||
| def perform_create(self, serializer): | ||
| """Create a new Client, capturing the IP address it was created from.""" | ||
| ip = get_client_ip(self.request) | ||
| serializer.save(registered_from_ip=ip) | ||
|
|
||
| def create(self, request, *args, **kwargs): | ||
| """Create a new Client while avoiding information leaks hopefully.""" | ||
| client_name = request.data.get("client_name") | ||
| request_uuid = request.data.get("uuid") | ||
|
|
||
| if not client_name: | ||
| return Response({"detail": "client_name is required."}, status=status.HTTP_400_BAD_REQUEST) | ||
|
|
||
| existing_client = self.queryset.filter(client_name=client_name).first() | ||
|
|
||
| if existing_client: | ||
| if request_uuid and str(existing_client.uuid) == request_uuid: | ||
| # Idempotent success | ||
| serializer = self.get_serializer(existing_client) | ||
| return Response(serializer.data, status=status.HTTP_200_OK) | ||
|
|
||
| # Log the conflict without leaking client_names to the user | ||
| logger.warning( | ||
| "Client named %s already exists with a different UUID", | ||
| client_name, | ||
| ) | ||
| return Response( | ||
| {"detail": "Invalid client registration request."}, | ||
| status=status.HTTP_400_BAD_REQUEST, | ||
| ) | ||
|
|
||
| return super().create(request, *args, **kwargs) | ||
|
|
||
|
|
||
| @extend_schema( | ||
| description="API endpoint to check if a route is active.", | ||
| parameters=[ | ||
| { | ||
| "name": "cidr", | ||
| "required": True, | ||
| "type": "string", | ||
| "description": "The CIDR network to check (e.g., 192.0.2.0/24).", | ||
| "in": "query", | ||
| } | ||
| ], | ||
| responses={ | ||
| 200: OpenApiResponse( | ||
| response=IsActiveSerializer, description="The 'is_active' field indicates the status of the route." | ||
| ), | ||
| 400: OpenApiResponse(description="The 'cidr' parameter is missing or invalid."), | ||
| }, | ||
| ) | ||
| class IsActiveViewSet(viewsets.ReadOnlyModelViewSet): | ||
| """Look up a route to see if SCRAM considers it active or deactivated.""" | ||
|
|
||
|
|
@@ -116,8 +191,15 @@ def list(self, request): | |
|
|
||
|
|
||
| @extend_schema( | ||
| description="API endpoint for entries", | ||
| responses={200: EntrySerializer}, | ||
| description="API endpoint for entries.", | ||
| responses={ | ||
| 200: OpenApiResponse(response=EntrySerializer, description="Successful retrieval of an entry/entries."), | ||
| 201: OpenApiResponse(response=EntrySerializer, description="Entry successfully created."), | ||
| 204: OpenApiResponse(description="Entry successfully deleted."), | ||
| 400: OpenApiResponse(description="The route is likely on the ignore list or the prefix is too large."), | ||
| 403: OpenApiResponse(description="The client is not authorized for this action."), | ||
| 404: OpenApiResponse(description="The requested entry does not exist."), | ||
| }, | ||
| ) | ||
| class EntryViewSet(viewsets.ModelViewSet): | ||
| """Lookup Entry when authenticated, and bind to the serializer.""" | ||
|
|
@@ -139,18 +221,40 @@ def get_permissions(self): | |
| return super().get_permissions() | ||
|
|
||
| def check_client_authorization(self, actiontype): | ||
| """Ensure that a given client is authorized to use a given actiontype.""" | ||
| """Ensure that a given client is authorized to use a given actiontype and IP address.""" | ||
| uuid = self.request.data.get("uuid") | ||
| if uuid: | ||
| authorized_actiontypes = Client.objects.filter(uuid=uuid).values_list( | ||
| "authorized_actiontypes__name", | ||
| flat=True, | ||
| ) | ||
| authorized_client = Client.objects.filter(uuid=uuid).values("is_authorized") | ||
| if not authorized_client or actiontype not in authorized_actiontypes: | ||
| logger.debug("Client: %s, actiontypes: %s", uuid, authorized_actiontypes) | ||
| try: | ||
| client = Client.objects.get(uuid=uuid) | ||
| except Client.DoesNotExist as client_dne: | ||
| msg = "Client does not exist" | ||
| raise PermissionDenied(msg) from client_dne | ||
|
|
||
| # Check if client is authorized for the action type | ||
| if not client.is_authorized or actiontype not in client.authorized_actiontypes.values_list( | ||
| "name", flat=True | ||
| ): | ||
| logger.debug( | ||
| "Client: %s, actiontypes: %s", | ||
| uuid, | ||
| list(client.authorized_actiontypes.values_list("name", flat=True)), | ||
| ) | ||
| logger.info("%s is not allowed to add an entry to the %s list.", uuid, actiontype) | ||
| raise ActiontypeNotAllowed | ||
|
|
||
| # Check if the client's IP address is allow listed | ||
| if client.registered_from_ip: | ||
| request_ip = self.get_client_ip() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure how tests are passing with this, but when I try running a POST to the entries API, I get a 500: I think this needs to be request_ip = get_client_ip(self.request)? |
||
| if client.registered_from_ip != request_ip: | ||
| logger.warning( | ||
| "Client %s attempted to authorize from unauthorized IP %s (expected %s)", | ||
| uuid, | ||
| request_ip, | ||
| client.registered_from_ip, | ||
| ) | ||
| msg = "Request from unauthorized IP address %s" | ||
| raise PermissionDenied(msg) | ||
|
Comment on lines
+245
to
+256
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's our plan for existing clients that have already been registered? Right now, our existing clients will get denied with a null IP in the database so I think we should consider:
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another thought... Say I'm a bad person and I'm trying to enumerate what's allowed from an IP standpoint or maybe trying to get information about what's behind a load balancer. Could I then maybe use this to grab the load-balancer's IP address and use that for something nefarious? |
||
|
|
||
| elif not self.request.user.has_perm("route_manager.can_add_entry"): | ||
| raise PermissionDenied | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| # Generated by Django 4.2.26 on 2025-11-21 05:52 | ||
|
|
||
| from django.db import migrations, models | ||
| import uuid | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ("route_manager", "0034_alter_entry_originating_scram_instance_and_more"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AlterField( | ||
| model_name="client", | ||
| name="uuid", | ||
| field=models.UUIDField(default=uuid.uuid4, editable=False), | ||
| ), | ||
| ] |
crankynetman marked this conversation as resolved.
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| # Generated by Django 4.2.26 on 2025-11-21 06:03 | ||
|
|
||
| from django.db import migrations | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ("route_manager", "0035_alter_client_uuid"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.RenameField( | ||
| model_name="client", | ||
| old_name="hostname", | ||
| new_name="client_name", | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| # Generated by Django 4.2.26 on 2025-11-21 06:08 | ||
|
|
||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ("route_manager", "0036_rename_hostname_client_client_name"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AddField( | ||
| model_name="client", | ||
| name="registered_from_ip", | ||
| field=models.GenericIPAddressField(blank=True, null=True), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| # Generated by Django 4.2.26 on 2025-11-21 06:17 | ||
|
|
||
| from django.db import migrations, models | ||
| import uuid | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ("route_manager", "0037_client_registered_from_ip"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AlterField( | ||
| model_name="client", | ||
| name="uuid", | ||
| field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -169,15 +169,16 @@ def __str__(self): | |
| class Client(models.Model): | ||
| """Any client that would like to hit the API to add entries (e.g. Zeek).""" | ||
|
|
||
| hostname = models.CharField(max_length=50, unique=True) | ||
| uuid = models.UUIDField() | ||
| client_name = models.CharField(max_length=50, unique=True) | ||
| uuid = models.UUIDField(default=uuid_lib.uuid4, editable=False, unique=True) | ||
| registered_from_ip = models.GenericIPAddressField(null=True, blank=True) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How does this work for dual-stacked hosts? Sometimes they might connect from IPv4, sometimes from IPv6. Additionally, do we need to come up with a method for a client to change its IP automagically? If we don't, how will we handle hosts getting renumbered (or even how will we handle multiple v6 addresses on a host (privacy, ula, etc). |
||
|
|
||
| is_authorized = models.BooleanField(null=True, blank=True, default=False) | ||
| authorized_actiontypes = models.ManyToManyField(ActionType) | ||
|
|
||
| def __str__(self): | ||
| """Only display the hostname.""" | ||
| return str(self.hostname) | ||
| """Only display the client_name.""" | ||
| return str(self.client_name) | ||
|
|
||
|
|
||
| channel_layer = get_channel_layer() | ||
Uh oh!
There was an error while loading. Please reload this page.