From 7b0704d9cc0fa7d5f16fc269705beffeb2496eb5 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Sat, 20 Jun 2026 17:36:37 +0530 Subject: [PATCH] fix(api): require at least one alphanumeric char in workspace name (#9278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(api): require at least one alphanumeric char in workspace name Workspace name validation was enforced only on the frontend (validateWorkspaceName), which gates the UI submit but is bypassable via a direct API call. The backend WorkSpaceSerializer.validate_name only rejected URLs, so a symbol-only name like "-_________-" could still be saved via create or the rename (partial_update) path. Add a Unicode-aware has_alphanumeric() helper and enforce it in both the app and instance/license workspace serializers, mirroring the frontend HAS_ALPHANUMERIC_REGEX (/[\p{L}\p{N}]/u) added in #9263. International names (日本語, José, محمد) still pass since str.isalnum() covers all scripts. Adds unit tests covering symbol-only rejection and international acceptance on both serializers. Refs #9255 Signed-off-by: sriramveeraghanta * fix(api): reject URLs in instance workspace name for parity Address CodeRabbit review on #9278: the instance/license WorkspaceSerializer.validate_name rejected symbol-only names but, unlike the app-level WorkSpaceSerializer, still accepted names containing URLs. Add the same contains_url() guard (imported from plane.utils.url, not content_validator) so both workspace-create paths validate identically. Add unit tests asserting URL-containing names are rejected on both serializers. Signed-off-by: sriramveeraghanta --------- Signed-off-by: sriramveeraghanta --- apps/api/plane/app/serializers/workspace.py | 8 ++ .../license/api/serializers/workspace.py | 16 ++++ .../tests/unit/serializers/test_workspace.py | 75 +++++++++++++++++++ apps/api/plane/utils/content_validator.py | 18 +++++ 4 files changed, 117 insertions(+) diff --git a/apps/api/plane/app/serializers/workspace.py b/apps/api/plane/app/serializers/workspace.py index 608cdad851..940b2c58d5 100644 --- a/apps/api/plane/app/serializers/workspace.py +++ b/apps/api/plane/app/serializers/workspace.py @@ -31,6 +31,7 @@ from plane.utils.url import contains_url from plane.utils.content_validator import ( validate_html_content, validate_binary_data, + has_alphanumeric, ) # Django imports @@ -48,6 +49,13 @@ class WorkSpaceSerializer(DynamicBaseSerializer): # Check if the name contains a URL if contains_url(value): raise serializers.ValidationError("Name must not contain URLs") + # Reject symbol-only names like "-_________-" that have no letter or + # digit. Mirrors the frontend HAS_ALPHANUMERIC_REGEX check so the rule + # cannot be bypassed via a direct API call. + if not has_alphanumeric(value): + raise serializers.ValidationError( + "Name must contain at least one letter or number" + ) return value def validate_slug(self, value): diff --git a/apps/api/plane/license/api/serializers/workspace.py b/apps/api/plane/license/api/serializers/workspace.py index d12473e204..dd10766fdb 100644 --- a/apps/api/plane/license/api/serializers/workspace.py +++ b/apps/api/plane/license/api/serializers/workspace.py @@ -10,6 +10,8 @@ from .base import BaseSerializer from .user import UserLiteSerializer from plane.db.models import Workspace from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS +from plane.utils.content_validator import has_alphanumeric +from plane.utils.url import contains_url class WorkspaceSerializer(BaseSerializer): @@ -18,6 +20,20 @@ class WorkspaceSerializer(BaseSerializer): total_projects = serializers.IntegerField(read_only=True) total_members = serializers.IntegerField(read_only=True) + def validate_name(self, value): + # Check if the name contains a URL (kept consistent with the app-level + # WorkSpaceSerializer so both workspace-create paths validate alike). + if contains_url(value): + raise serializers.ValidationError("Name must not contain URLs") + # Reject symbol-only names like "-_________-" that have no letter or + # digit. Mirrors the frontend HAS_ALPHANUMERIC_REGEX check so the rule + # cannot be bypassed via a direct API call. + if not has_alphanumeric(value): + raise serializers.ValidationError( + "Name must contain at least one letter or number" + ) + return value + def validate_slug(self, value): # Check if the slug is restricted if value in RESTRICTED_WORKSPACE_SLUGS: diff --git a/apps/api/plane/tests/unit/serializers/test_workspace.py b/apps/api/plane/tests/unit/serializers/test_workspace.py index f59667f701..554deba624 100644 --- a/apps/api/plane/tests/unit/serializers/test_workspace.py +++ b/apps/api/plane/tests/unit/serializers/test_workspace.py @@ -5,9 +5,34 @@ import pytest from uuid import uuid4 +from rest_framework import serializers + from plane.api.serializers import WorkspaceLiteSerializer +from plane.app.serializers import WorkSpaceSerializer +from plane.license.api.serializers import WorkspaceSerializer as InstanceWorkspaceSerializer from plane.db.models import Workspace, User +# Names with no letter or digit — must be rejected (issue #9255) +SYMBOL_ONLY_NAMES = ["-_________-", "---", "___", "- - -", " ", " -_ "] + +# Names containing at least one letter or digit — must be accepted, including +# international scripts, so the rule does not regress non-Latin workspace names +VALID_NAMES = [ + "Acme Corp", + "Acme_Corp-123", + "123", + "a", + "R&D", + "José", + "Müller GmbH", + "日本語", + "株式会社", + "محمد", +] + +# Names embedding a URL — must be rejected on both create paths +URL_NAMES = ["https://evil.com", "www.example.com", "example.com"] + @pytest.mark.unit class TestWorkspaceLiteSerializer: @@ -52,3 +77,53 @@ class TestWorkspaceLiteSerializer: updated_workspace = serializer.save() assert updated_workspace.name == "Test Workspace" assert updated_workspace.slug == "test-workspace" + + +@pytest.mark.unit +class TestWorkSpaceSerializerNameValidation: + """validate_name must reject symbol-only workspace names (issue #9255). + + Frontend validation is bypassable via a direct API call, so the rule is + enforced server-side on both the create and rename (partial_update) paths, + which share this serializer's field-level validation. + """ + + @pytest.mark.parametrize("name", SYMBOL_ONLY_NAMES) + def test_rejects_symbol_only_names(self, name): + serializer = WorkSpaceSerializer() + with pytest.raises(serializers.ValidationError): + serializer.validate_name(name) + + @pytest.mark.parametrize("name", VALID_NAMES) + def test_accepts_names_with_alphanumeric(self, name): + serializer = WorkSpaceSerializer() + assert serializer.validate_name(name) == name + + @pytest.mark.parametrize("name", URL_NAMES) + def test_rejects_names_containing_urls(self, name): + serializer = WorkSpaceSerializer() + with pytest.raises(serializers.ValidationError): + serializer.validate_name(name) + + +@pytest.mark.unit +class TestInstanceWorkspaceSerializerNameValidation: + """The instance/license workspace create path must enforce the same rules + as the app serializer (symbol-only rejection AND URL rejection).""" + + @pytest.mark.parametrize("name", SYMBOL_ONLY_NAMES) + def test_rejects_symbol_only_names(self, name): + serializer = InstanceWorkspaceSerializer() + with pytest.raises(serializers.ValidationError): + serializer.validate_name(name) + + @pytest.mark.parametrize("name", VALID_NAMES) + def test_accepts_names_with_alphanumeric(self, name): + serializer = InstanceWorkspaceSerializer() + assert serializer.validate_name(name) == name + + @pytest.mark.parametrize("name", URL_NAMES) + def test_rejects_names_containing_urls(self, name): + serializer = InstanceWorkspaceSerializer() + with pytest.raises(serializers.ValidationError): + serializer.validate_name(name) diff --git a/apps/api/plane/utils/content_validator.py b/apps/api/plane/utils/content_validator.py index 1b4ede2626..711d560fac 100644 --- a/apps/api/plane/utils/content_validator.py +++ b/apps/api/plane/utils/content_validator.py @@ -241,3 +241,21 @@ def validate_html_content(html_content: str): except Exception as e: log_exception(e) return False, "Failed to sanitize HTML", None + + +def has_alphanumeric(value): + """ + Check whether a string contains at least one alphanumeric character. + + `str.isalnum()` is Unicode-aware, so letters and digits from any script + (Latin, CJK, Arabic, Cyrillic, etc.) all count. This mirrors the frontend + HAS_ALPHANUMERIC_REGEX (/[\\p{L}\\p{N}]/u) check and is used to reject + symbol-only names such as "-_________-". + + Args: + value (str): The string to check. + + Returns: + bool: True if the value contains at least one letter or digit. + """ + return any(char.isalnum() for char in (value or ""))