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
8 changes: 8 additions & 0 deletions apps/api/plane/app/serializers/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from plane.utils.content_validator import (
validate_html_content,
validate_binary_data,
has_alphanumeric,
)

# Django imports
Expand All @@ -48,6 +49,13 @@ def validate_name(self, value):
# 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):
Expand Down
16 changes: 16 additions & 0 deletions apps/api/plane/license/api/serializers/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
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):
Expand All @@ -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

Comment thread
coderabbitai[bot] marked this conversation as resolved.
def validate_slug(self, value):
# Check if the slug is restricted
if value in RESTRICTED_WORKSPACE_SLUGS:
Expand Down
75 changes: 75 additions & 0 deletions apps/api/plane/tests/unit/serializers/test_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -52,3 +77,53 @@ def test_workspace_lite_serializer_read_only(self, db):
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)
18 changes: 18 additions & 0 deletions apps/api/plane/utils/content_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""))
Loading