diff --git a/backend/pyproject.toml b/backend/pyproject.toml index fe4f8c8bbf..ad7fa083b5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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", diff --git a/backend/src/baserow/api/exceptions.py b/backend/src/baserow/api/exceptions.py index c70cc8dcd8..26b0881987 100644 --- a/backend/src/baserow/api/exceptions.py +++ b/backend/src/baserow/api/exceptions.py @@ -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 @@ -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__( @@ -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. diff --git a/backend/src/baserow/api/user/validators.py b/backend/src/baserow/api/user/validators.py index d6394e76d9..69d9c9024c 100644 --- a/backend/src/baserow/api/user/validators.py +++ b/backend/src/baserow/api/user/validators.py @@ -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): @@ -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): diff --git a/backend/src/baserow/api/validators.py b/backend/src/baserow/api/validators.py index d610833bab..158b359a20 100644 --- a/backend/src/baserow/api/validators.py +++ b/backend/src/baserow/api/validators.py @@ -1,4 +1,5 @@ import re +import unicodedata from rest_framework import serializers @@ -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): @@ -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 diff --git a/backend/src/baserow/api/workspaces/serializers.py b/backend/src/baserow/api/workspaces/serializers.py index 470ee140a2..7ad137d118 100755 --- a/backend/src/baserow/api/workspaces/serializers.py +++ b/backend/src/baserow/api/workspaces/serializers.py @@ -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 @@ -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): diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py index 9303e99593..36d00c43e6 100644 --- a/backend/src/baserow/config/settings/base.py +++ b/backend/src/baserow/config/settings/base.py @@ -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() diff --git a/backend/src/baserow/config/urls.py b/backend/src/baserow/config/urls.py index d8f29837f4..91c70f255d 100644 --- a/backend/src/baserow/config/urls.py +++ b/backend/src/baserow/config/urls.py @@ -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" diff --git a/backend/tests/baserow/api/groups/test_workspace_views.py b/backend/tests/baserow/api/groups/test_workspace_views.py index 1afca55228..0d73137d0c 100755 --- a/backend/tests/baserow/api/groups/test_workspace_views.py +++ b/backend/tests/baserow/api/groups/test_workspace_views.py @@ -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( @@ -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( diff --git a/backend/tests/baserow/api/test_request_body_size_limit.py b/backend/tests/baserow/api/test_request_body_size_limit.py new file mode 100644 index 0000000000..8ee0e3d5f8 --- /dev/null +++ b/backend/tests/baserow/api/test_request_body_size_limit.py @@ -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" diff --git a/backend/tests/baserow/api/users/test_user_views.py b/backend/tests/baserow/api/users/test_user_views.py index 294651c748..fc1bdeff24 100755 --- a/backend/tests/baserow/api/users/test_user_views.py +++ b/backend/tests/baserow/api/users/test_user_views.py @@ -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", + "ο½…ο½–ο½‰ο½ŒοΌŽο½ƒο½ο½", + "πŸ…°πŸ…±πŸ…²-βΆβ·βΈβΉβΊβ»β—†β“Ώβ—†πŸ…βΆβ·", + "πŸ’¬πŸ…°πŸ…±πŸ…²-❢❷❸-β‚β‚‚β‚ƒπŸ…‚βΉβΊ.", + "𝐉𝐨𝐒𝐧 𝐦𝐞 𝐧𝐨𝐰", + "ηΎ€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( @@ -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", + "οΌͺohn", + "John πŸ‘¨\u200dπŸ‘©\u200dπŸ‘§", ] for index, valid_name in enumerate(valid_names): response = client.post( diff --git a/backend/uv.lock b/backend/uv.lock index 27ae5650ba..1ae8ee79fa 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -393,7 +393,7 @@ requires-dist = [ { name = "django-storages", specifier = "==1.14.6" }, { name = "django-storages", extras = ["azure"], specifier = "==1.14.6" }, { name = "django-storages", extras = ["google"], specifier = "==1.14.6" }, - { name = "djangorestframework", specifier = "==3.16.1" }, + { name = "djangorestframework", specifier = "==3.17.2" }, { name = "djangorestframework-simplejwt", specifier = "==5.5.1" }, { name = "drf-spectacular", specifier = "==0.29.0" }, { name = "faker", specifier = "==40.1.0" }, @@ -1212,14 +1212,14 @@ wheels = [ [[package]] name = "djangorestframework" -version = "3.16.1" +version = "3.17.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/35/c96055e700fdff25da3a7b7756cfd1d4dc54f38b9bc6d6c5e19e3a0fdc20/djangorestframework-3.17.2.tar.gz", hash = "sha256:89ed713b6dc83e1539f214b7d10808ae19bb8511004beba886225da6d5c9dafa", size = 906683, upload-time = "2026-08-05T07:47:22.5Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, + { url = "https://files.pythonhosted.org/packages/a2/46/c14108e400b208c394325eb63fbae06c81341b6447fa1a6f9da718b17fe7/djangorestframework-3.17.2-py3-none-any.whl", hash = "sha256:cb0546a7415d5b46c04e0f4fe0a54b2109f4fdd5e83ca773c8c6183a6493d042", size = 899109, upload-time = "2026-08-05T07:47:20.853Z" }, ] [[package]] diff --git a/changelog/entries/unreleased/bug/reject_decorative_characters_and_long_numbers_in_names.json b/changelog/entries/unreleased/bug/reject_decorative_characters_and_long_numbers_in_names.json new file mode 100644 index 0000000000..d43396892d --- /dev/null +++ b/changelog/entries/unreleased/bug/reject_decorative_characters_and_long_numbers_in_names.json @@ -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" +} diff --git a/changelog/entries/unreleased/feature/add_configurable_request_body_size_limit_via_baserow_request_b.json b/changelog/entries/unreleased/feature/add_configurable_request_body_size_limit_via_baserow_request_b.json new file mode 100644 index 0000000000..39f59cb4cd --- /dev/null +++ b/changelog/entries/unreleased/feature/add_configurable_request_body_size_limit_via_baserow_request_b.json @@ -0,0 +1,9 @@ +{ + "type": "feature", + "message": "Add optional request body size limit via BASEROW_REQUEST_BODY_SIZE_LIMIT_MB", + "issue_origin": "github", + "issue_number": null, + "domain": "core", + "bullet_points": [], + "created_at": "2026-09-11" +} diff --git a/docker-compose.no-caddy.yml b/docker-compose.no-caddy.yml index 673a9a67d6..7289a0b12c 100644 --- a/docker-compose.no-caddy.yml +++ b/docker-compose.no-caddy.yml @@ -86,6 +86,7 @@ x-backend-variables: BATCH_ROWS_SIZE_LIMIT: INITIAL_TABLE_DATA_LIMIT: BASEROW_FILE_UPLOAD_SIZE_LIMIT_MB: + BASEROW_REQUEST_BODY_SIZE_LIMIT_MB: BASEROW_FILE_UPLOAD_ACTIVE_CONTENT_POLICY: BASEROW_OPENAI_UPLOADED_FILE_SIZE_LIMIT_MB: BASEROW_UNIQUE_ROW_VALUES_SIZE_LIMIT: diff --git a/docker-compose.yml b/docker-compose.yml index 86f2b059c1..6c6e357840 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -99,6 +99,7 @@ x-backend-variables: BATCH_ROWS_SIZE_LIMIT: INITIAL_TABLE_DATA_LIMIT: BASEROW_FILE_UPLOAD_SIZE_LIMIT_MB: + BASEROW_REQUEST_BODY_SIZE_LIMIT_MB: BASEROW_FILE_UPLOAD_ACTIVE_CONTENT_POLICY: BASEROW_OPENAI_UPLOADED_FILE_SIZE_LIMIT_MB: BASEROW_UNIQUE_ROW_VALUES_SIZE_LIMIT: diff --git a/docs/installation/configuration.md b/docs/installation/configuration.md index 97dd36652b..9bfdaa3be8 100644 --- a/docs/installation/configuration.md +++ b/docs/installation/configuration.md @@ -57,6 +57,7 @@ The installation methods referred to in the variable descriptions are: | BASEROW\_MAX\_FIELD\_TEXT\_LENGTH | The maximum number of characters accepted when writing a text based field value (single line text, long text, URL, and AI fields with a text output) via the API or web frontend. Existing longer values remain readable. | 1000000 | | BASEROW\_FORMULA\_RANGE\_MAX\_ITEMS | The maximum number of items the `range` runtime formula function can generate. Prevents a small formula from producing an unbounded array. | 10000 | | BASEROW\_INTEGRATION\_LOCAL\_BASEROW\_BATCH\_OPERATION\_SIZE\_LIMIT | Controls how many rows can be created, updated, or deleted at once using Local Baserow batch workflow actions. | 1000 | +| BASEROW\_REQUEST\_BODY\_SIZE\_LIMIT\_MB | The max size in MB of a JSON or form encoded request body. Bodies over the limit are rejected before they are parsed, with an `ERROR_REQUEST_BODY_TOO_LARGE` response. Unset by default, meaning no limit, because the table import and create endpoints send the whole dataset as one JSON body and the largest legitimate size depends on your data. Note that file uploads to a File Field are covered by `BASEROW_FILE_UPLOAD_SIZE_LIMIT_MB` instead. | unset (no limit) | | BASEROW\_FILE_UPLOAD\_SIZE\_LIMIT\_MB | The max file size in MB allowed to be uploaded by users into a Baserow File Field. | 1048576 (1 TB or 1024*1024) | | BASEROW\_FILE\_UPLOAD\_ACTIVE\_CONTENT\_POLICY | Controls how uploads with active-content extensions (`.html`, `.htm`, `.xhtml`, `.xml`, `.svg`, `.svgz`) or MIME types (`text/html`, `text/xml`, `application/xml`, `application/xhtml+xml`, `image/svg+xml`) are handled. Set to `download` to allow them but store them as `application/octet-stream` without image previews. Set to `block` to reject them. | download | | BASEROW\_OPENAI\_UPLOADED\_FILE\_SIZE\_LIMIT\_MB | The max file size in MB allowed to be loaded in RAM and uploaded to OpenAI servers. See also [OpenAI docs](https://platform.openai.com/docs/api-reference/files/create). | 512 | diff --git a/e2e-tests/tests/database/ai_field_provider_realtime.spec.ts b/e2e-tests/tests/database/ai_field_provider_realtime.spec.ts index 2e2c61bda7..554a0fdbaa 100644 --- a/e2e-tests/tests/database/ai_field_provider_realtime.spec.ts +++ b/e2e-tests/tests/database/ai_field_provider_realtime.spec.ts @@ -83,7 +83,7 @@ test("AI field availability stays synchronized while visiting admin settings", a ); const workspace = await createWorkspace( staffUser, - `AI realtime ${Date.now()}`, + `AI realtime ${Date.now().toString(36)}`, ); workspaceId = workspace.id; const database = await createDatabase( diff --git a/web-frontend/locales/en.json b/web-frontend/locales/en.json index 1f7a5d8fdf..e9f37d98f4 100644 --- a/web-frontend/locales/en.json +++ b/web-frontend/locales/en.json @@ -116,6 +116,7 @@ "maxLength": "A maximum of {max} characters is allowed here.", "minMaxLength": "A minimum of {min} and a maximum of {max} characters is allowed here.", "nameContainsUrl": "Names can't contain links, domains or web addresses.", + "nameContainsSpam": "Names can't contain decorative characters or long numbers.", "nameCantBeEmail": "Please enter your name, not your email address.", "invalidCharacters": "This field contains invalid characters.", "requiredField": "This field is required.", diff --git a/web-frontend/modules/core/components/admin/users/forms/UserForm.vue b/web-frontend/modules/core/components/admin/users/forms/UserForm.vue index 95f68d2e4e..531ac271ff 100644 --- a/web-frontend/modules/core/components/admin/users/forms/UserForm.vue +++ b/web-frontend/modules/core/components/admin/users/forms/UserForm.vue @@ -32,6 +32,9 @@ {{ $t('error.nameContainsUrl') }} + + {{ $t('error.nameContainsSpam') }} + @@ -151,6 +154,7 @@ import { email, maxLength, minLength, required } from '@vuelidate/validators' import form from '@baserow/modules/core/mixins/form' import { + nameContainsNoSpam, nameContainsNoUrl, nameIsNotEmail, } from '@baserow/modules/core/validators' @@ -187,6 +191,7 @@ export default { maxLength: maxLength(60), nameIsNotEmail, nameContainsNoUrl, + nameContainsNoSpam, }, username: { required, diff --git a/web-frontend/modules/core/components/auth/PasswordRegister.vue b/web-frontend/modules/core/components/auth/PasswordRegister.vue index 23966e0e35..5f1d72d93d 100644 --- a/web-frontend/modules/core/components/auth/PasswordRegister.vue +++ b/web-frontend/modules/core/components/auth/PasswordRegister.vue @@ -83,6 +83,14 @@ > {{ $t('error.nameContainsUrl') }} + + {{ $t('error.nameContainsSpam') }} + {{ $t('error.minMaxLength', { min: 2, max: 60 }) }} @@ -143,6 +151,7 @@ import error from '@baserow/modules/core/mixins/error' import PasswordInput from '@baserow/modules/core/components/helpers/PasswordInput' import CaptchaWidget from '@baserow/modules/core/components/auth/CaptchaWidget' import { + nameContainsNoSpam, nameContainsNoUrl, nameIsNotEmail, passwordValidation, @@ -183,6 +192,7 @@ export default { maxLength: maxLength(60), nameIsNotEmail, nameContainsNoUrl, + nameContainsNoSpam, }, password: passwordValidation, }, diff --git a/web-frontend/modules/core/components/settings/AccountForm.vue b/web-frontend/modules/core/components/settings/AccountForm.vue index 0aa4c37b5c..e5a84397fc 100644 --- a/web-frontend/modules/core/components/settings/AccountForm.vue +++ b/web-frontend/modules/core/components/settings/AccountForm.vue @@ -46,6 +46,7 @@ import { useI18n } from 'vue-i18n' import form from '@baserow/modules/core/mixins/form' import { + nameContainsNoSpam, nameContainsNoUrl, nameIsNotEmail, } from '@baserow/modules/core/validators' @@ -101,6 +102,10 @@ export default { this.$t('error.nameContainsUrl'), nameContainsNoUrl ), + nameContainsNoSpam: helpers.withMessage( + this.$t('error.nameContainsSpam'), + nameContainsNoSpam + ), }, }, } diff --git a/web-frontend/modules/core/components/workspace/WorkspaceForm.vue b/web-frontend/modules/core/components/workspace/WorkspaceForm.vue index 5e654839f6..f5a35d1826 100644 --- a/web-frontend/modules/core/components/workspace/WorkspaceForm.vue +++ b/web-frontend/modules/core/components/workspace/WorkspaceForm.vue @@ -26,7 +26,10 @@ import { useVuelidate } from '@vuelidate/core' import { required, helpers } from '@vuelidate/validators' import form from '@baserow/modules/core/mixins/form' -import { nameContainsNoUrl } from '@baserow/modules/core/validators' +import { + nameContainsNoSpam, + nameContainsNoUrl, +} from '@baserow/modules/core/validators' export default { name: 'WorkspaceForm', @@ -60,6 +63,10 @@ export default { this.$t('error.nameContainsUrl'), nameContainsNoUrl ), + nameContainsNoSpam: helpers.withMessage( + this.$t('error.nameContainsSpam'), + nameContainsNoSpam + ), }, }, } diff --git a/web-frontend/modules/core/mixins/editWorkspace.js b/web-frontend/modules/core/mixins/editWorkspace.js index 0db0b0e8e7..5015e776ab 100644 --- a/web-frontend/modules/core/mixins/editWorkspace.js +++ b/web-frontend/modules/core/mixins/editWorkspace.js @@ -1,5 +1,8 @@ import { notifyIf } from '@baserow/modules/core/utils/error' -import { nameContainsNoUrl } from '@baserow/modules/core/validators' +import { + nameContainsNoSpam, + nameContainsNoUrl, +} from '@baserow/modules/core/validators' /** * Some helper methods to modify workspaces used by the dashboard. @@ -17,11 +20,16 @@ export default { // The name is edited inline without a form, so there is no place to show // a validation error. Show a toast notification instead and revert the // name. - if (!nameContainsNoUrl(event.value)) { + const invalidNameMessage = !nameContainsNoUrl(event.value) + ? this.$t('error.nameContainsUrl') + : !nameContainsNoSpam(event.value) + ? this.$t('error.nameContainsSpam') + : null + if (invalidNameMessage) { this.$refs.rename.set(event.oldValue) this.$store.dispatch('toast/error', { title: this.$t('editWorkspace.invalidNameTitle'), - message: this.$t('error.nameContainsUrl'), + message: invalidNameMessage, }) return } diff --git a/web-frontend/modules/core/validators.js b/web-frontend/modules/core/validators.js index a494570d35..6d39f2718d 100644 --- a/web-frontend/modules/core/validators.js +++ b/web-frontend/modules/core/validators.js @@ -30,8 +30,41 @@ const EMAIL_LIKE_NAME_REGEX = /^[^@\s]+@[^@\s]+\.[^@\s]+$/ // eslint-disable-next-line no-control-regex const CONTROL_CHARS_REGEX = /[\u0000-\u001f\u007f]/ +// 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. +// Must be kept in sync with `STYLIZED_CHARS_REGEX` in the backend. +const STYLIZED_CHARS_REGEX = new RegExp( + '[' + + '\\u2070-\\u209f' + // superscripts and subscripts + '\\u2460-\\u24ff' + // enclosed alphanumerics + '\\u2776-\\u2793' + // dingbat circled digits + '\\u3251-\\u32bf' + // enclosed CJK numbers 21-50 + '\\u{1d400}-\\u{1d7ff}' + // mathematical alphanumeric symbols + '\\u{1f100}-\\u{1f1e5}' + // enclosed alphanumeric supplement + ']', + 'u' +) +// 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. +const LONG_DIGIT_RUN_REGEX = /\d{6,}/ + +// 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. +const normalizeName = (value) => value.normalize('NFKC').replace(/\p{Cf}/gu, '') + export const nameContainsNoUrl = (value) => - !URL_LIKE_NAME_REGEX.test(value) && !CONTROL_CHARS_REGEX.test(value) + !URL_LIKE_NAME_REGEX.test(normalizeName(value)) && + !CONTROL_CHARS_REGEX.test(value) + +export const nameContainsNoSpam = (value) => + !STYLIZED_CHARS_REGEX.test(value) && + !LONG_DIGIT_RUN_REGEX.test(normalizeName(value)) export const nameIsNotEmail = (value) => !EMAIL_LIKE_NAME_REGEX.test(value.trim()) diff --git a/web-frontend/test/unit/core/validators.spec.js b/web-frontend/test/unit/core/validators.spec.js index 62a566079f..546f966e30 100644 --- a/web-frontend/test/unit/core/validators.spec.js +++ b/web-frontend/test/unit/core/validators.spec.js @@ -1,4 +1,5 @@ import { + nameContainsNoSpam, nameContainsNoUrl, nameIsNotEmail, } from '@baserow/modules/core/validators' @@ -33,6 +34,8 @@ describe('nameContainsNoUrl', () => { 'unknown-tld.weirdtld/path', 'bad\nname', 'bad\tname', + 'ο½…ο½–ο½‰ο½ŒοΌŽο½ƒο½ο½', + 'evil\u200b.com', ] test.each(validNames)('accepts %j', (name) => { @@ -44,6 +47,42 @@ describe('nameContainsNoUrl', () => { }) }) +describe('nameContainsNoSpam', () => { + const validNames = [ + 'Dr. Smith', + "Mary-Jane O'Neil", + 'ZoΓ« MΓΌller', + 'ε±±η”°ε€ͺιƒŽ', + 'Team 2026', + '2025-2026 Budget', + '12345 Main', + 'πŸš€ Marketing', + 'πŸ‡³πŸ‡± Sales', + 'Area mΒ²', + 'οΌͺohn', + 'πŸ‘¨\u200dπŸ‘©\u200dπŸ‘§ Family', + ] + + const invalidNames = [ + 'πŸ…°πŸ…±πŸ…²-βΆβ·βΈβΉβΊβ»β—†β“Ώβ—†πŸ…βΆβ·', + 'πŸ’¬πŸ…°πŸ…±πŸ…²-❢❷❸-β‚β‚‚β‚ƒπŸ…‚βΉβΊ.', + '𝐉𝐨𝐒𝐧 𝐦𝐞 𝐧𝐨𝐰', + 'β‘ β‘‘β‘’β‘£β‘€β‘₯', + 'ηΎ€1234567890聯硑加ε…₯', + 'ε„ͺζƒ ζ΄»ε‹•1234567890加羀12', + 'call 0612345678', + 'call 0\u200b6\u200b1\u200b2\u200b3\u200b4\u200b5\u200b6', + ] + + test.each(validNames)('accepts %j', (name) => { + expect(nameContainsNoSpam(name)).toBe(true) + }) + + test.each(invalidNames)('rejects %j', (name) => { + expect(nameContainsNoSpam(name)).toBe(false) + }) +}) + describe('nameIsNotEmail', () => { test.each(['J.Smith', 'Dr. Smith', 'Bram', 'name with @ in it'])( 'accepts %j',