fix(api): require at least one alphanumeric char in workspace name (#9278)

* 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 <veeraghanta.sriram@gmail.com>

* 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 <veeraghanta.sriram@gmail.com>

---------

Signed-off-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
sriram veeraghanta
2026-06-20 17:36:37 +05:30
committed by GitHub
parent ad32dc7f70
commit 7b0704d9cc
4 changed files with 117 additions and 0 deletions

View File

@@ -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):

View File

@@ -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:

View File

@@ -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)

View File

@@ -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 ""))