Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ classifiers = []
dependencies = [
"django==5.2.16",
"django-cors-headers==4.9.0",
"djangorestframework==3.16.1",
"djangorestframework==3.17.2",
"djangorestframework-simplejwt==5.5.1",
"psycopg2-binary==2.9.11",
"Faker==40.1.0",
Expand Down
38 changes: 37 additions & 1 deletion backend/src/baserow/api/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.conf import settings
from django.http import JsonResponse
from django.core.exceptions import RequestDataTooBig
from django.http import HttpRequest, HttpResponse, JsonResponse

from rest_framework import status
from rest_framework.exceptions import APIException, Throttled, ValidationError
Expand All @@ -22,6 +23,23 @@ def api_exception_to_json_response(exc: APIException) -> JsonResponse:
return response


def bad_request(request: HttpRequest, exception: Exception) -> HttpResponse:
"""
``handler400`` replacement returning JSON instead of Django's ``400.html``.
"""

if isinstance(exception, RequestDataTooBig):
return api_exception_to_json_response(RequestBodyTooLargeException())

return JsonResponse(
{
"error": "ERROR_BAD_REQUEST",
"detail": "The request could not be processed.",
},
status=status.HTTP_400_BAD_REQUEST,
)


class RequestBodyValidationException(APIException):
def __init__(self, detail=None, code=None):
super().__init__(
Expand All @@ -30,6 +48,24 @@ def __init__(self, detail=None, code=None):
self.status_code = 400


class RequestBodyTooLargeException(APIException):
status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE
default_code = "ERROR_REQUEST_BODY_TOO_LARGE"

def __init__(self, detail=None, code=None):
super().__init__(
{
"error": "ERROR_REQUEST_BODY_TOO_LARGE",
"detail": detail
or (
"The request body is larger than the configured limit and was "
"rejected before it could be parsed."
),
},
code=code,
)


class UnknownFieldProvided(ValidationError):
"""
Raised when an unknown field is provided to an API endpoint.
Expand Down
15 changes: 10 additions & 5 deletions backend/src/baserow/api/user/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@

from rest_framework import serializers

from baserow.api.validators import EMAIL_LIKE_NAME_REGEX, no_url_validation
from baserow.api.validators import (
EMAIL_LIKE_NAME_REGEX,
no_spam_validation,
no_url_validation,
)


def name_validation(value):
"""
Rejects names containing URL-like content or control characters to prevent
abuse of transactional emails for phishing, and email addresses because
they're not a name.
Rejects names containing URL-like content, control characters or spam patterns
to prevent abuse of transactional emails, and email addresses because they're
not a name.
"""

if EMAIL_LIKE_NAME_REGEX.match(value):
Expand All @@ -20,7 +24,8 @@ def name_validation(value):
code="name_is_email",
)

return no_url_validation(value)
no_url_validation(value)
return no_spam_validation(value)


def password_validation(value):
Expand Down
54 changes: 53 additions & 1 deletion backend/src/baserow/api/validators.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import re
import unicodedata

from rest_framework import serializers

Expand All @@ -15,6 +16,37 @@
)
EMAIL_LIKE_NAME_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
CONTROL_CHARS_REGEX = re.compile(r"[\x00-\x1f\x7f]")
# Decorative letters and digits (circled, negative circled, squared, sub/superscript
# and mathematical alphanumerics). Spammers use them to spell out contact details
# while evading filters, and they never occur in real names. Regional indicators
# (flag emoji) are deliberately excluded from the enclosed alphanumeric supplement.
STYLIZED_CHARS_REGEX = re.compile(
"["
"\u2070-\u209f" # superscripts and subscripts
"\u2460-\u24ff" # enclosed alphanumerics
"\u2776-\u2793" # dingbat circled digits
"\u3251-\u32bf" # enclosed CJK numbers 21-50
"\U0001d400-\U0001d7ff" # mathematical alphanumeric symbols
"\U0001f100-\U0001f1e5" # enclosed alphanumeric supplement
"]"
)
# Contact IDs (QQ, WhatsApp, phone numbers) embedded in a name to advertise them via
# transactional emails. Short numbers like `Team 2026` or `2025-2026` stay allowed.
LONG_DIGIT_RUN_REGEX = re.compile(r"\d{6,}")


def _normalize_name(value: str) -> str:
"""
NFKC folds lookalike characters (fullwidth, sub/superscript, mathematical
letters) into their ASCII form, and invisible format characters (zero-width
spaces, joiners, bidi controls) are stripped because they can be inserted
between digits or letters to break up a pattern without changing how the name
renders. They're stripped rather than rejected because zero-width joiners are
legitimate in emoji sequences and some scripts.
"""

normalized = unicodedata.normalize("NFKC", value)
return "".join(c for c in normalized if unicodedata.category(c) != "Cf")


def no_url_validation(value):
Expand All @@ -24,10 +56,30 @@ def no_url_validation(value):
emails sent to others.
"""

if CONTROL_CHARS_REGEX.search(value) or URL_LIKE_NAME_REGEX.search(value):
normalized = _normalize_name(value)

if CONTROL_CHARS_REGEX.search(value) or URL_LIKE_NAME_REGEX.search(normalized):
raise serializers.ValidationError(
"Names can't contain links, domains or web addresses.",
code="invalid_name",
)

return value


def no_spam_validation(value):
"""
Rejects values containing decorative characters or long numbers, because spam
accounts use them to spell out contact details in names that end up in
invitation emails.
"""

normalized = _normalize_name(value)

if STYLIZED_CHARS_REGEX.search(value) or LONG_DIGIT_RUN_REGEX.search(normalized):
raise serializers.ValidationError(
"Names can't contain decorative characters or long numbers.",
code="invalid_name",
)

return value
4 changes: 2 additions & 2 deletions backend/src/baserow/api/workspaces/serializers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from rest_framework import serializers

from baserow.api.mixins import UnknownFieldRaisesExceptionSerializerMixin
from baserow.api.validators import no_url_validation
from baserow.api.validators import no_spam_validation, no_url_validation
from baserow.core.generative_ai.registries import generative_ai_model_type_registry
from baserow.core.models import Workspace

Expand All @@ -28,7 +28,7 @@ class Meta:
extra_kwargs = {
"id": {"read_only": True},
"generative_ai_models_enabled": {"read_only": True},
"name": {"validators": [no_url_validation]},
"name": {"validators": [no_url_validation, no_spam_validation]},
}

def get_generative_ai_models_enabled(self, object):
Expand Down
11 changes: 11 additions & 0 deletions backend/src/baserow/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,17 @@
Decimal(os.getenv("BASEROW_FILE_UPLOAD_SIZE_LIMIT_MB", 1024 * 1024)) * 1024 * 1024
) # ~1TB by default

# The max size of a JSON or form encoded request body.
_body_limit_mb = os.getenv("BASEROW_REQUEST_BODY_SIZE_LIMIT_MB", "").strip()
DATA_UPLOAD_MAX_MEMORY_SIZE = (
int(Decimal(_body_limit_mb) * 1024 * 1024) if _body_limit_mb else None
)
if DATA_UPLOAD_MAX_MEMORY_SIZE is not None and DATA_UPLOAD_MAX_MEMORY_SIZE <= 0:
raise ImproperlyConfigured(
"BASEROW_REQUEST_BODY_SIZE_LIMIT_MB must be greater than zero. Unset it "
"to allow request bodies of any size."
)

FILE_UPLOAD_ACTIVE_CONTENT_POLICY = os.getenv(
"BASEROW_FILE_UPLOAD_ACTIVE_CONTENT_POLICY", "download"
).lower()
Expand Down
2 changes: 2 additions & 0 deletions backend/src/baserow/config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ def old_deprecated_health_check(request):

if settings.DEBUG and "silk" in settings.INSTALLED_APPS:
urlpatterns += [path("silk/", include("silk.urls", namespace="silk"))]

handler400 = "baserow.api.exceptions.bad_request"
19 changes: 17 additions & 2 deletions backend/tests/baserow/api/groups/test_workspace_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,11 @@ def test_workspace_name_validation(api_client, data_fixture):
"www.evil.com",
"https://evil.com",
"bad\nname",
"🅰🅱🅲-❶❷❸❹❺❻◆⓿◆🅐❶❷'s workspace",
"💬🅰🅱🅲-❶❷❸-₁₂₃🅂❹❺.'s workspace",
"群1234567890聯絡加入's workspace",
"優惠活動1234567890加群12's workspace",
"call 0\u200b6\u200b1\u200b2\u200b3\u200b4\u200b5\u200b6",
]
for invalid_name in invalid_names:
response = api_client.post(
Expand Down Expand Up @@ -428,8 +433,18 @@ def test_workspace_name_validation(api_client, data_fixture):
workspace.refresh_from_db()
assert workspace.name == "Old name"

# Dotted names without a high risk TLD or path must still be allowed.
valid_names = ["Dept. Marketing", "rocket.ia", "team.exenra"]
# Dotted names without a high risk TLD or path, emoji, flags and short numbers
# must still be allowed.
valid_names = [
"Dept. Marketing",
"rocket.ia",
"team.exenra",
"🚀 Marketing",
"🇳🇱 Sales",
"2025-2026 Budget",
"12345 Main",
"👨\u200d👩\u200d👧 Family",
]
for valid_name in valid_names:
url = reverse("api:workspaces:item", kwargs={"workspace_id": workspace.id})
response = api_client.patch(
Expand Down
79 changes: 79 additions & 0 deletions backend/tests/baserow/api/test_request_body_size_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import json

from django.shortcuts import reverse
from django.test import override_settings

import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_400_BAD_REQUEST,
HTTP_413_REQUEST_ENTITY_TOO_LARGE,
)


@pytest.mark.django_db
@override_settings(DATA_UPLOAD_MAX_MEMORY_SIZE=1024)
def test_oversized_request_body_returns_json_error(api_client, data_fixture):
"""
Django raises RequestDataTooBig while the body is being read, which is outside
the reach of DRF's exception handler. Without RequestDataTooBigMiddleware the
client gets Django's HTML handler400 page instead of an API error.
"""

user, jwt_token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
field = data_fixture.create_text_field(table=table, name="Notes")

url = reverse("api:database:rows:list", kwargs={"table_id": table.id})
response = api_client.post(
url,
{f"field_{field.id}": "a" * 2048},
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)

assert response.status_code == HTTP_413_REQUEST_ENTITY_TOO_LARGE
assert response["Content-Type"].startswith("application/json")
assert json.loads(response.content)["error"] == "ERROR_REQUEST_BODY_TOO_LARGE"


@pytest.mark.django_db
@override_settings(DATA_UPLOAD_MAX_MEMORY_SIZE=None)
def test_large_request_body_is_accepted_when_no_limit_is_configured(
api_client, data_fixture
):
"""
The limit is unset by default because the table import and create endpoints
inline the whole dataset as JSON.
"""

user, jwt_token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
field = data_fixture.create_text_field(table=table, name="Notes")

url = reverse("api:database:rows:list", kwargs={"table_id": table.id})
response = api_client.post(
url,
{f"field_{field.id}": "a" * 100_000},
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)

assert response.status_code == HTTP_200_OK


@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver"])
def test_other_bad_requests_also_return_json(api_client, data_fixture):
"""
Every other SuspiciousOperation routed to handler400 stays generic, because
Django withholds their detail to avoid echoing a rejected host or path back
to whoever is probing for it.
"""

url = reverse("api:settings:get")
response = api_client.get(url, HTTP_HOST="not-an-allowed-host")

assert response.status_code == HTTP_400_BAD_REQUEST
assert response["Content-Type"].startswith("application/json")
assert json.loads(response.content)["error"] == "ERROR_BAD_REQUEST"
14 changes: 14 additions & 0 deletions backend/tests/baserow/api/users/test_user_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,15 @@ def test_create_user_with_url_in_name_is_rejected(client, data_fixture):
"evil.click",
"unknown-tld.weirdtld/path",
"bad\nname",
"evil.com",
"🅰🅱🅲-❶❷❸❹❺❻◆⓿◆🅐❶❷",
"💬🅰🅱🅲-❶❷❸-₁₂₃🅂❹❺.",
"𝐉𝐨𝐢𝐧 𝐦𝐞 𝐧𝐨𝐰",
"群1234567890聯絡加入",
"優惠活動1234567890加群12",
"call 0612345678",
"call 0\u200b6\u200b1\u200b2\u200b3\u200b4\u200b5\u200b6",
"evil\u200b.com",
]
for invalid_name in invalid_names:
response = client.post(
Expand Down Expand Up @@ -256,6 +265,11 @@ def test_create_user_with_url_in_name_is_rejected(client, data_fixture):
"something.AI",
"b.something",
"startup.ai",
"Zoë Müller",
"山田太郎",
"John 2026",
"John",
"John 👨\u200d👩\u200d👧",
]
for index, valid_name in enumerate(valid_names):
response = client.post(
Expand Down
8 changes: 4 additions & 4 deletions backend/uv.lock

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

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "bug",
"message": "Reject decorative characters and long numbers in user and workspace names",
"issue_origin": "github",
"issue_number": null,
"domain": "core",
"bullet_points": [],
"created_at": "2026-09-11"
}
Loading
Loading