1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-15 09:34:13 +00:00

Add password validation on public view update

This commit is contained in:
Nigel Gott 2023-07-27 08:06:48 +00:00
parent d820d7a5f1
commit 533602da13
12 changed files with 165 additions and 46 deletions
backend
src/baserow/contrib/database
tests/baserow/contrib/database
premium/backend
src/baserow_premium/views
tests/baserow_premium_tests

View file

@ -341,26 +341,27 @@ class CreateViewSerializer(serializers.ModelSerializer):
class UpdateViewSerializer(serializers.ModelSerializer):
def _make_password(self, plain_password: str):
"""
An empty string disables password protection.
A non-empty string will be encrypted and used to check user authorization.
MAX_PUBLIC_VIEW_PASSWORD_LENGTH = 256
MIN_PUBLIC_VIEW_PASSWORD_LENGTH = 8
:param plain_password: The password to encrypt.
:return: The encrypted password or "" if disabled.
"""
public_view_password = serializers.CharField(
required=False,
allow_blank=True,
min_length=MIN_PUBLIC_VIEW_PASSWORD_LENGTH,
max_length=MAX_PUBLIC_VIEW_PASSWORD_LENGTH,
help_text="The new password or an empty string to remove any previous "
"password from the view and make it publicly accessible again.",
)
return View.make_password(plain_password) if plain_password else ""
def to_internal_value(self, data):
plain_password = data.get("public_view_password")
if plain_password is not None:
data = {
**data,
"public_view_password": self._make_password(plain_password),
}
return super().to_internal_value(data)
def to_representation(self, data):
representation = super().to_representation(data)
public_view_password = representation.pop("public_view_password", None)
if public_view_password is not None:
# Pass a differently named attribute down to the handler, so it knows
# the difference between the user setting a new raw password and/or
# someone directly changing the literal hashed and salted password value.
representation["raw_public_view_password"] = public_view_password
return representation
class Meta:
ref_name = "view_update"

View file

@ -24,7 +24,6 @@ from baserow.contrib.database.views.models import (
ViewFilter,
ViewSort,
)
from baserow.contrib.database.views.registries import view_type_registry
from baserow.core.action.models import Action
from baserow.core.action.registries import (
ActionScopeStr,
@ -870,19 +869,8 @@ class UpdateViewActionType(UndoableActionType):
:params data: The data to update.
"""
def get_prepared_values_for_data(view):
return {
key: value
for key, value in view_type.export_prepared_values(view).items()
if key in data
}
view_type = view_type_registry.get_by_model(view)
original_data = get_prepared_values_for_data(view)
view = ViewHandler().update_view(user, view, **data)
new_data = get_prepared_values_for_data(view)
updated_view_with_changes = ViewHandler().update_view(user, view, **data)
view = updated_view_with_changes.updated_view_instance
cls.register_action(
user=user,
@ -893,8 +881,8 @@ class UpdateViewActionType(UndoableActionType):
view.table.name,
view.table.database.id,
view.table.database.name,
new_data,
original_data,
updated_view_with_changes.new_view_attributes,
updated_view_with_changes.original_view_attributes,
),
scope=cls.scope(view.id),
workspace=view.table.database.workspace,

View file

@ -1,3 +1,4 @@
import dataclasses
import re
import traceback
from collections import defaultdict, namedtuple
@ -63,7 +64,10 @@ from baserow.contrib.database.views.operations import (
UpdateViewSlugOperationType,
UpdateViewSortOperationType,
)
from baserow.contrib.database.views.registries import view_ownership_type_registry
from baserow.contrib.database.views.registries import (
ViewType,
view_ownership_type_registry,
)
from baserow.core.db import specific_iterator
from baserow.core.handler import CoreHandler
from baserow.core.telemetry.utils import baserow_trace_methods
@ -141,6 +145,13 @@ PerViewTableIndexUpdate = namedtuple(
)
@dataclasses.dataclass
class UpdatedViewWithChangedAttributes:
updated_view_instance: View
original_view_attributes: Dict[str, Any]
new_view_attributes: Dict[str, Any]
class ViewIndexingHandler(metaclass=baserow_trace_methods(tracer)):
@classmethod
def does_index_exist(cls, index_name: str) -> bool:
@ -746,7 +757,7 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
def update_view(
self, user: AbstractUser, view: View, **data: Dict[str, Any]
) -> View:
) -> UpdatedViewWithChangedAttributes:
"""
Updates an existing view instance.
@ -777,6 +788,11 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
"show_logo",
] + view_type.allowed_fields
changed_allowed_keys = extract_allowed(view_values, allowed_fields).keys()
original_view_values = self._get_prepared_values_for_data(
view_type, view, changed_allowed_keys
)
previous_public_value = view.public
view = set_allowed_attrs(view_values, allowed_fields, view)
if previous_public_value != view.public:
@ -788,12 +804,20 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
)
view.save()
new_view_values = self._get_prepared_values_for_data(
view_type, view, changed_allowed_keys
)
if "filters_disabled" in view_values:
view_type.after_filter_update(view)
view_updated.send(self, view=view, user=user)
return view
return UpdatedViewWithChangedAttributes(
updated_view_instance=view,
original_view_attributes=original_view_values,
new_view_attributes=new_view_values,
)
def order_views(self, user: AbstractUser, table: Table, order: List[int]):
"""
@ -2684,6 +2708,15 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
return queryset, field_ids, publicly_visible_field_options
def _get_prepared_values_for_data(
self, view_type: ViewType, view: View, changed_allowed_keys: Iterable[str]
) -> Dict[str, Any]:
return {
key: value
for key, value in view_type.export_prepared_values(view).items()
if key in changed_allowed_keys
}
@dataclass
class PublicViewRows:

View file

@ -91,6 +91,10 @@ class View(
db_index=True,
)
public_view_password = models.CharField(
# PLEASE NOTE: This max_length is not for the password that the user inputs,
# but instead to fit the hashed and salted password generated by Django!
# See the UpdateViewSerializer for the validations on how long a user
# password can be!
max_length=128,
blank=True,
help_text="The password required to access the public view URL.",

View file

@ -528,6 +528,17 @@ class ViewType(
:return: The updates values.
"""
from baserow.contrib.database.views.models import View
raw_public_view_password = values.get("raw_public_view_password", None)
if raw_public_view_password is not None:
if raw_public_view_password:
values["public_view_password"] = View.make_password(
raw_public_view_password
)
else:
values["public_view_password"] = "" # nosec b105
return values
def view_created(self, view: "View"):

View file

@ -360,7 +360,7 @@ class GalleryViewType(ViewType):
"view's table."
)
return values
return super().prepare_values(values, table, user)
def export_serialized(
self,

View file

@ -776,6 +776,78 @@ def test_anon_user_cant_get_info_about_a_public_password_protected_view(
assert public_view_token is None
@pytest.mark.django_db
def test_public_view_password_validation(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
grid_view = data_fixture.create_grid_view(user=user, public=True)
# set password for the current view with 8 characters
response = api_client.patch(
reverse("api:database:views:item", kwargs={"view_id": grid_view.id}),
{"public_view_password": "12345678"},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
# set password for the current view with 256 characters
response = api_client.patch(
reverse("api:database:views:item", kwargs={"view_id": grid_view.id}),
{"public_view_password": "1" * 256},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
# remove password for the current view
response = api_client.patch(
reverse("api:database:views:item", kwargs={"view_id": grid_view.id}),
{"public_view_password": ""},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
# attempt setting password with less than 8 characters
response = api_client.patch(
reverse("api:database:views:item", kwargs={"view_id": grid_view.id}),
{"public_view_password": "1234567"},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
response_json = response.json()
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
assert response_json["detail"]["public_view_password"] == [
{"code": "min_length", "error": "Ensure this field has at least 8 characters."}
]
# attempt setting password more than 256 characters
response = api_client.patch(
reverse("api:database:views:item", kwargs={"view_id": grid_view.id}),
{"public_view_password": "1" * 256},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
# attempt setting password with more than 256 characters
response = api_client.patch(
reverse("api:database:views:item", kwargs={"view_id": grid_view.id}),
{"public_view_password": "1" * 257},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
response_json = response.json()
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
assert response_json["detail"]["public_view_password"] == [
{
"code": "max_length",
"error": "Ensure this field has no more than 256 characters.",
}
]
@pytest.mark.django_db
def test_user_with_invalid_token_cant_get_info_about_a_public_password_protected_view(
api_client, data_fixture

View file

@ -658,7 +658,10 @@ def test_can_undo_redo_update_form_view(data_fixture):
"submit_action_redirect_url": "https://localhost/original",
}
form_view = ViewHandler().update_view(user, form_view, **original_form_data)
form_view_with_changes = ViewHandler().update_view(
user, form_view, **original_form_data
)
form_view = form_view_with_changes.updated_view_instance
assert form_view.name == original_form_data["name"]
assert form_view.public == original_form_data["public"]
@ -760,9 +763,10 @@ def test_can_undo_redo_update_gallery_view(data_fixture):
"card_cover_image_field": file_field_1,
}
gallery_view = ViewHandler().update_view(
gallery_view_with_changes = ViewHandler().update_view(
user, gallery_view, **original_gallery_data
)
gallery_view = gallery_view_with_changes.updated_view_instance
assert gallery_view.name == original_gallery_data["name"]
assert gallery_view.filter_type == original_gallery_data["filter_type"]

View file

@ -203,7 +203,8 @@ def test_update_grid_view(send_mock, data_fixture):
with pytest.raises(ValueError):
handler.update_view(user=user, view=object(), name="Test 1")
view = handler.update_view(user=user, view=grid, name="Test 1")
view_with_changes = handler.update_view(user=user, view=grid, name="Test 1")
view = view_with_changes.updated_view_instance
send_mock.assert_called_once()
assert send_mock.call_args[1]["view"].id == view.id
@ -351,7 +352,7 @@ def test_update_form_view(send_mock, data_fixture):
user_file_2 = data_fixture.create_user_file()
handler = ViewHandler()
view = handler.update_view(
view_with_changes = handler.update_view(
user=user,
view=form,
slug="Test slug",
@ -364,6 +365,7 @@ def test_update_form_view(send_mock, data_fixture):
submit_action="REDIRECT",
submit_action_redirect_url="https://localhost",
)
view = view_with_changes.updated_view_instance
send_mock.assert_called_once()
assert send_mock.call_args[1]["view"].id == view.id

View file

@ -114,7 +114,7 @@ class KanbanViewType(ViewType):
"view's table."
)
return values
return super().prepare_values(values, table, user)
def export_serialized(
self,
@ -325,7 +325,7 @@ class CalendarViewType(ViewType):
"view's table."
)
return values
return super().prepare_values(values, table, user)
def export_serialized(
self,

View file

@ -936,7 +936,10 @@ def test_can_undo_redo_update_kanban_view(data_fixture, premium_data_fixture):
"card_cover_image_field": cover_image_file_field_1,
}
kanban_view = ViewHandler().update_view(user, kanban_view, **original_kanban_data)
kanban_view_with_changes = ViewHandler().update_view(
user, kanban_view, **original_kanban_data
)
kanban_view = kanban_view_with_changes.updated_view_instance
assert kanban_view.name == original_kanban_data["name"]
assert kanban_view.filter_type == original_kanban_data["filter_type"]

View file

@ -873,9 +873,10 @@ def test_can_undo_redo_update_calendar_view(data_fixture, premium_data_fixture):
"date_field": date_field_1,
}
calendar_view = ViewHandler().update_view(
calendar_view_with_changes = ViewHandler().update_view(
user, calendar_view, **original_calendar_data
)
calendar_view = calendar_view_with_changes.updated_view_instance
assert calendar_view.name == original_calendar_data["name"]
assert calendar_view.date_field_id == original_calendar_data["date_field"].id