-
Notifications
You must be signed in to change notification settings - Fork 9
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
Conversation
@@ -16,6 +16,7 @@ class Meta: | |||
"last_name", | |||
"full_name", | |||
) | |||
read_only_fields = ["email"] |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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"] |
There was a problem hiding this comment.
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 -%} | ||
|
There was a problem hiding this comment.
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 }}" | |||
|
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you
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,) |
There was a problem hiding this comment.
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.
""" | ||
user = self.request.user | ||
return User.objects.filter(pk=user.pk) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
There was a problem hiding this comment.
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
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 |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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()
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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
There was a problem hiding this 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()] |
There was a problem hiding this comment.
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]
.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍🏻
if view.action == "create": | ||
return True | ||
return False | ||
class HasUserPermissions(permissions.BasePermission): |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should not happen ever
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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!
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.