Skip to content
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

Backend Security Audit fixes #352

Merged
merged 9 commits into from
Sep 12, 2024
Merged

Backend Security Audit fixes #352

merged 9 commits into from
Sep 12, 2024

Conversation

oudeismetis
Copy link
Member

@oudeismetis oudeismetis commented Sep 9, 2024

What this does

Had to do a security audit recently for one of our projects that uses this bootstrapper.
This PR includes fixes from that effort.

How to test

Add user steps to achieve desired functionality for this feature.

@oudeismetis oudeismetis temporarily deployed to tn-spa-bootstrapper-pr-352 September 9, 2024 18:16 Inactive
@@ -16,6 +16,7 @@ class Meta:
"last_name",
"full_name",
)
read_only_fields = ["email"]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default behavior shouldn't allow changing of a users email just by having a valid token.
We should consider implement a proper endpoint for this, but it should require a user to re-enter their password and probably should trigger an email to the old email address when the change is made and possibly require them to verify they control the new email address. (currently this last one doesn't happen for registration either, but it probably should)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@paribaker I know you had thoughts about this one

mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
):
class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We implement some of these with overrides below.
Oddly, by implementing update() and having the UpdateModelMixin above, we ended up with PATCH and POST endpoints that both handled updating, but had different rules and validation.
Most projects shouldn't need all of these (ex: listing all users). If they do, it's easy enough to add them, along with the proper security controls.

user = self.get_object()
user.is_active = False
user.save()
return Response(status=status.HTTP_204_NO_CONTENT)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment above should be clear.
By default apps shouldn't just delete important data from the DB like this.

@@ -306,7 +309,6 @@
if not IN_DEV:
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
MIDDLEWARE += ["django.middleware.security.SecurityMiddleware"]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duplicate from another spot in this file.

{% if cookiecutter.client_app.lower() != 'none' -%}
CORS_ALLOWED_ORIGINS.append("http://localhost:8080")
{% endif -%}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See further up.
This is now controlled by ALLOWED_HOSTS. Hardcoding values like localhost into this file is a security risk.

@@ -1,6 +1,9 @@
from django.contrib import admin
from django.urls import include, path

admin.site.site_header = "{{ cookiecutter.project_name }} Admin"
admin.site.site_title = "{{ cookiecutter.project_name }}"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a nice little addition so that managing multiple projects makes it more obvious which admin you are in.

Also, non-dev project stakeholders don't know (or need to care) what the "Django Admin" is. It's just the admin for the app.

@@ -16,4 +16,4 @@ class Meta:
abstract = True

def __str__(self):
return "ah yes"
return "__str__ not defined for this model"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gentle nudge for devs to set this for their new models

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you

@oudeismetis oudeismetis temporarily deployed to tn-spa-bootstrapper-pr-352 September 9, 2024 18:37 Inactive
queryset = User.objects.all()
serializer_class = UserSerializer

# No auth required to create user
# Auth required for all other actions
permission_classes = (permissions.IsAuthenticated | CreateOnlyPermissions,)
permission_classes = (HasUserPermissions,)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IsAuthenticated was causing a problem here. It meant that a logged in user could do a GET on any user.
Changing this to a permission class that only lets them take action on themselves by default (unless they are a staff user)

Create still works fine.

There are automated tests now for all of this so should this break on a project, a dev would have to also remove the test to get around it.

@oudeismetis oudeismetis temporarily deployed to tn-spa-bootstrapper-pr-352 September 11, 2024 21:55 Inactive
"""
user = self.request.user
return User.objects.filter(pk=user.pk)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@whusterj I removed the querset and replaced it with this call. It might not matter since we don't have list as an option here anymore. But probably a good default to have for projects that try adding a quick mixin here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aaaand it's broken the tests. Will investigate in the morning.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to add the queryset back. Left a comment as to why

@oudeismetis oudeismetis temporarily deployed to tn-spa-bootstrapper-pr-352 September 12, 2024 13:47 Inactive
@oudeismetis oudeismetis temporarily deployed to tn-spa-bootstrapper-pr-352 September 12, 2024 14:42 Inactive
other_user = user_factory()
other_user.save()
res = api_client.get(f"/api/users/{other_user.pk}/")
assert res.status_code == status.HTTP_404_NOT_FOUND
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this is a 404. The other user does exist, but we don't want to make bad actors aware of that fact. So it's a 404.

mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
):
class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin):
queryset = User.objects.all()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to remove this but it raises an error:
AssertionError: basenameargument not specified, and could not automatically determine the name from the viewset, as it does not have a.queryset attribute.

I couldn't find any deep discussion on how to better use the queryset here. You can't auto-filter here by the logged in user or anything like that. All examples just have it as .all(). So this seems to be field you just HAVE to have.
But it doesn't degrade our security at all as additional filters are tacked on to this

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, in that case I think you could replace it with User.objects.none()

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it, but that doesn't work. A couple of tests are failing now because the chain of filters starts by finding nothing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setting it to just User.objects works though. And that would imply to devs that filters get layered on elsewhere

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think that's a good place to land

"""
return super().get_queryset().for_user(self.request.user)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This get_queryset doesn't do anything right now. But if you were to add the list mixin or add a def list() below then it'll do the job of only returning that logged in user for that list (or all users in a list for staff users)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure? I think get_queryset is also called for update methods

@oudeismetis oudeismetis temporarily deployed to tn-spa-bootstrapper-pr-352 September 12, 2024 15:19 Inactive
Copy link
Member

@whusterj whusterj left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just one question on final review. If the answer is "yes," then this is good to go.

@@ -57,7 +65,7 @@ class CustomUserAdmin(UserAdmin):
ordering = []

def permissions(self, obj):
return ", ".join([g.name for g in obj.groups.all()])
return [g.name for g in obj.groups.all()]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@oudeismetis - I trust that you checked that this works? This is changing the return type from str to list[str].

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct. I pulled this from a project where I made the change.
The join isn't needed and happens for you

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just re-tested on a locally generated my_project.
image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great! This is good to go then.

return self.none()
elif user.is_staff:
return self.all()
return self.filter(pk=user.pk)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻

@oudeismetis oudeismetis merged commit 8ac3f59 into main Sep 12, 2024
17 of 18 checks passed
@oudeismetis oudeismetis deleted the security-audit-fixes branch September 12, 2024 18:35
if view.action == "create":
return True
return False
class HasUserPermissions(permissions.BasePermission):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q, is there a situation where request.user might be None? if so wont that cause a 500 error crash

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should not happen ever

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notice that the viewset where this is used also handles user creation requests. There are automated tests that cover that case.

I do think I've had to handle some sort of none case in the past, but I haven't seen that in a long while. But should that bug arise it would be a small/quick/obvious fix

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the Authentication Middleware always adds user to the request. If the client is unauthenticated, then it sets request.user to an instance of AnonymousUser. But as Ed said, it should never be None or falsey. Could only happen if that middleware is removed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That tracks. Otherwise is_anonymous wouldn't be very useful. That might have been a bug in older versions that people reported

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah nvm this is used on the object level!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants