[VPAT-55] chore(security): implement input validation across authentication and workspace forms (#8528)

* chore(security): implement input validation across authentication and workspace forms

  - Add OWASP-compliant autocomplete attributes to all auth input fields
  - Create centralized validation utilities blocking injection-risk characters
  - Apply validation to names, display names, workspace names, and slugs
  - Block special characters: < > ' " % # { } [ ] * ^ !
  - Secure sensitive input fields across admin, web, and space apps

* chore: add missing workspace name validation to settings and admin forms

* feat: enhance validation regex for international names and usernames

- Updated regex patterns to support Unicode characters for person names, display names, company names, and slugs.
- Improved validation functions to block injection-risk characters in names and slugs.
This commit is contained in:
Prateek Shourya
2026-02-17 00:18:46 +05:30
committed by GitHub
parent 55e89cb8fc
commit 49fc6aa0a0
11 changed files with 281 additions and 54 deletions

View File

@@ -14,6 +14,7 @@ import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { InstanceWorkspaceService } from "@plane/services";
import type { IWorkspace } from "@plane/types";
import { validateSlug, validateWorkspaceName } from "@plane/utils";
// components
import { CustomSelect, Input } from "@plane/ui";
// hooks
@@ -96,14 +97,7 @@ export function WorkspaceCreateForm() {
control={control}
name="name"
rules={{
required: "This is a required field.",
validate: (value) =>
/^[\w\s-]*$/.test(value) ||
`Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`,
maxLength: {
value: 80,
message: "Limit your name to 80 characters.",
},
validate: (value) => validateWorkspaceName(value, true),
}}
render={({ field: { value, ref, onChange } }) => (
<Input
@@ -135,11 +129,7 @@ export function WorkspaceCreateForm() {
control={control}
name="slug"
rules={{
required: "The URL is a required field.",
maxLength: {
value: 48,
message: "Limit your URL to 48 characters.",
},
validate: (value) => validateSlug(value),
}}
render={({ field: { onChange, value, ref } }) => (
<Input

View File

@@ -13,7 +13,7 @@ import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { AuthService } from "@plane/services";
import { Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
import { getPasswordStrength, validatePersonName, validateCompanyName } from "@plane/utils";
// components
import { AuthHeader } from "@/app/(all)/(home)/auth-header";
import { Banner } from "../common/banner";
@@ -173,9 +173,15 @@ export function InstanceSetupForm() {
inputSize="md"
placeholder="Wilber"
value={formData.first_name}
onChange={(e) => handleFormChange("first_name", e.target.value)}
autoComplete="on"
onChange={(e) => {
const validation = validatePersonName(e.target.value);
if (validation === true || e.target.value === "") {
handleFormChange("first_name", e.target.value);
}
}}
autoComplete="off"
autoFocus
maxLength={50}
/>
</div>
<div className="w-full space-y-1">
@@ -190,8 +196,14 @@ export function InstanceSetupForm() {
inputSize="md"
placeholder="Wright"
value={formData.last_name}
onChange={(e) => handleFormChange("last_name", e.target.value)}
autoComplete="on"
onChange={(e) => {
const validation = validatePersonName(e.target.value);
if (validation === true || e.target.value === "") {
handleFormChange("last_name", e.target.value);
}
}}
autoComplete="off"
maxLength={50}
/>
</div>
</div>
@@ -229,7 +241,13 @@ export function InstanceSetupForm() {
inputSize="md"
placeholder="Company name"
value={formData.company_name}
onChange={(e) => handleFormChange("company_name", e.target.value)}
onChange={(e) => {
const validation = validateCompanyName(e.target.value, false);
if (validation === true || e.target.value === "") {
handleFormChange("company_name", e.target.value);
}
}}
maxLength={80}
/>
</div>

View File

@@ -16,6 +16,7 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IUser, IWorkspace, TOnboardingSteps } from "@plane/types";
// ui
import { CustomSelect, Input, Spinner } from "@plane/ui";
import { validateWorkspaceName, validateSlug } from "@plane/utils";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserProfile, useUserSettings } from "@/hooks/store/user";
@@ -138,8 +139,7 @@ export const CreateWorkspace = observer(function CreateWorkspace(props: Props) {
name="name"
rules={{
required: t("common.errors.required"),
validate: (value) =>
/^[\w\s-]*$/.test(value) || t("workspace_creation.errors.validation.name_alphanumeric"),
validate: (value) => validateWorkspaceName(value, true),
maxLength: {
value: 80,
message: t("workspace_creation.errors.validation.name_length"),
@@ -200,7 +200,8 @@ export const CreateWorkspace = observer(function CreateWorkspace(props: Props) {
type="text"
value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
onChange={(e) => {
if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false);
const validation = validateSlug(e.target.value);
if (validation === true) setInvalidSlug(false);
else setInvalidSlug(true);
onChange(e.target.value.toLowerCase());
}}

View File

@@ -17,7 +17,7 @@ import type { IUser, TUserProfile, TOnboardingSteps } from "@plane/types";
// ui
import { Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
// components
import { cn, getFileURL, getPasswordStrength } from "@plane/utils";
import { cn, getFileURL, getPasswordStrength, validatePersonName } from "@plane/utils";
import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal";
// hooks
import { useUser, useUserProfile } from "@/hooks/store/user";
@@ -303,9 +303,10 @@ export const ProfileSetup = observer(function ProfileSetup(props: Props) {
name="first_name"
rules={{
required: "First name is required",
validate: validatePersonName,
maxLength: {
value: 24,
message: "First name must be within 24 characters.",
value: 50,
message: "First name must be within 50 characters.",
},
}}
render={({ field: { value, onChange, ref } }) => (
@@ -340,9 +341,10 @@ export const ProfileSetup = observer(function ProfileSetup(props: Props) {
name="last_name"
rules={{
required: "Last name is required",
validate: validatePersonName,
maxLength: {
value: 24,
message: "Last name must be within 24 characters.",
value: 50,
message: "Last name must be within 50 characters.",
},
}}
render={({ field: { value, onChange, ref } }) => (

View File

@@ -14,7 +14,7 @@ import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IUser } from "@plane/types";
import { EOnboardingSteps } from "@plane/types";
import { cn, getFileURL, getPasswordStrength } from "@plane/utils";
import { cn, getFileURL, getPasswordStrength, validatePersonName } from "@plane/utils";
// components
import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal";
// hooks
@@ -208,9 +208,10 @@ export const ProfileSetupStep = observer(function ProfileSetupStep({ handleStepC
name="first_name"
rules={{
required: "Name is required",
validate: validatePersonName,
maxLength: {
value: 24,
message: "Name must be within 24 characters.",
value: 50,
message: "Name must be within 50 characters.",
},
}}
render={({ field: { value, onChange, ref } }) => (

View File

@@ -15,7 +15,7 @@ import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IUser, IWorkspace } from "@plane/types";
import { Spinner } from "@plane/ui";
import { cn } from "@plane/utils";
import { cn, validateWorkspaceName, validateSlug } from "@plane/utils";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useWorkspace } from "@/hooks/store/use-workspace";
@@ -146,8 +146,7 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({
name="name"
rules={{
required: t("common.errors.required"),
validate: (value) =>
/^[\w\s-]*$/.test(value) || t("workspace_creation.errors.validation.name_alphanumeric"),
validate: (value) => validateWorkspaceName(value, true),
maxLength: {
value: 80,
message: t("workspace_creation.errors.validation.name_length"),
@@ -220,7 +219,8 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({
type="text"
value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
onChange={(e) => {
if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false);
const validation = validateSlug(e.target.value);
if (validation === true) setInvalidSlug(false);
else setInvalidSlug(true);
onChange(e.target.value.toLowerCase());
}}

View File

@@ -28,6 +28,8 @@ import { handleCoverImageChange } from "@/helpers/cover-image.helper";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useUser, useUserProfile } from "@/hooks/store/user";
// utils
import { validatePersonName, validateDisplayName } from "@plane/utils";
type TUserProfileForm = {
avatar_url: string;
@@ -260,6 +262,7 @@ export const GeneralProfileSettingsForm = observer(function GeneralProfileSettin
name="first_name"
rules={{
required: "Please enter first name",
validate: validatePersonName,
}}
render={({ field: { value, onChange, ref } }) => (
<Input
@@ -272,7 +275,7 @@ export const GeneralProfileSettingsForm = observer(function GeneralProfileSettin
hasError={Boolean(errors.first_name)}
placeholder="Enter your first name"
className={`w-full rounded-md ${errors.first_name ? "border-danger-strong" : ""}`}
maxLength={24}
maxLength={50}
autoComplete="on"
/>
)}
@@ -284,6 +287,9 @@ export const GeneralProfileSettingsForm = observer(function GeneralProfileSettin
<Controller
control={control}
name="last_name"
rules={{
validate: validatePersonName,
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="last_name"
@@ -295,11 +301,12 @@ export const GeneralProfileSettingsForm = observer(function GeneralProfileSettin
hasError={Boolean(errors.last_name)}
placeholder="Enter your last name"
className="w-full rounded-md"
maxLength={24}
maxLength={50}
autoComplete="on"
/>
)}
/>
{errors.last_name && <span className="text-11 text-danger-primary">{errors.last_name.message}</span>}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-13 font-medium text-secondary">
@@ -311,14 +318,7 @@ export const GeneralProfileSettingsForm = observer(function GeneralProfileSettin
name="display_name"
rules={{
required: "Display name is required.",
validate: (value) => {
if (value.trim().length < 1) return "Display name can't be empty.";
if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces.";
if (value.replace(/\s/g, "").length < 1) return "Display name must be at least 1 character long.";
if (value.replace(/\s/g, "").length > 20)
return "Display name must be less than 20 characters long.";
return true;
},
validate: validateDisplayName,
}}
render={({ field: { value, onChange, ref } }) => (
<Input
@@ -331,7 +331,7 @@ export const GeneralProfileSettingsForm = observer(function GeneralProfileSettin
hasError={Boolean(errors?.display_name)}
placeholder="Enter your display name"
className={`w-full ${errors?.display_name ? "border-danger-strong" : ""}`}
maxLength={24}
maxLength={50}
/>
)}
/>

View File

@@ -15,6 +15,7 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspace } from "@plane/types";
// ui
import { CustomSelect, Input } from "@plane/ui";
import { validateWorkspaceName, validateSlug } from "@plane/utils";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useAppRouter } from "@/hooks/use-app-router";
@@ -126,8 +127,7 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
name="name"
rules={{
required: t("common.errors.required"),
validate: (value) =>
/^[\w\s-]*$/.test(value) || t("workspace_creation.errors.validation.name_alphanumeric"),
validate: (value) => validateWorkspaceName(value, true),
maxLength: {
value: 80,
message: t("workspace_creation.errors.validation.name_length"),
@@ -178,7 +178,8 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
type="text"
value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
onChange={(e) => {
if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false);
const validation = validateSlug(e.target.value);
if (validation === true) setInvalidSlug(false);
else setInvalidSlug(true);
onChange(e.target.value.toLowerCase());
}}

View File

@@ -15,7 +15,7 @@ import { EditIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspace } from "@plane/types";
import { CustomSelect, Input } from "@plane/ui";
import { cn, copyUrlToClipboard, getFileURL } from "@plane/utils";
import { cn, copyUrlToClipboard, getFileURL, validateWorkspaceName } from "@plane/utils";
// components
import { WorkspaceImageUploadModal } from "@/components/core/modals/workspace-image-upload-modal";
import { TimezoneSelect } from "@/components/global/timezone-select";
@@ -195,11 +195,7 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
control={control}
name="name"
rules={{
required: t("workspace_settings.settings.general.errors.name.required"),
maxLength: {
value: 80,
message: t("workspace_settings.settings.general.errors.name.max_length"),
},
validate: (value) => validateWorkspaceName(value, true),
}}
render={({ field: { value, onChange, ref } }) => (
<Input
@@ -216,6 +212,7 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
/>
)}
/>
{errors.name && <p className="text-caption-sm-regular text-danger-primary">{errors.name.message}</p>}
</div>
<div className="flex flex-col gap-2">
<h4 className="text-body-sm-medium text-tertiary">

View File

@@ -36,6 +36,7 @@ export * from "./tab-indices";
export * from "./theme";
export { resolveGeneralTheme } from "./theme-legacy";
export * from "./url";
export * from "./validation";
export * from "./work-item-filters";
export * from "./work-item";
export * from "./workspace";

View File

@@ -0,0 +1,216 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
/**
* Input Validation Utilities
* Following OWASP Input Validation best practices using allowlist approach
*
* Security: Blocks injection-risk characters: < > ' " % # { } [ ] * ^ !
* These patterns are designed to prevent XSS, SQL injection, template injection,
* and other security vulnerabilities while maintaining good UX
*/
// =============================================================================
// VALIDATION REGEX PATTERNS
// =============================================================================
/**
* Person Name Pattern (for first_name, last_name)
* Allows: Unicode letters (\p{L}), spaces, hyphens, apostrophes
* Use case: Accommodates international names like "José", "李明", "محمد", "Müller"
* Blocks: Injection-risk characters and special symbols
*/
export const PERSON_NAME_REGEX = /^[\p{L}\s'-]+$/u;
/**
* Display Name Pattern (for display_name, usernames)
* Allows: Unicode letters (\p{L}), numbers (\p{N}), underscore, period, hyphen
* Use case: International usernames like "josé_123", "李明.dev", "müller-2024"
* Blocks: Spaces and injection-risk characters
*/
export const DISPLAY_NAME_REGEX = /^[\p{L}\p{N}_.-]+$/u;
/**
* Company/Organization Name Pattern (for company_name, workspace names)
* Allows: Unicode letters (\p{L}), numbers (\p{N}), spaces, underscores, hyphens
* Use case: International business names like "Société Générale", "株式会社", "Müller GmbH"
* Blocks: Special punctuation and injection-risk chars
*/
export const COMPANY_NAME_REGEX = /^[\p{L}\p{N}\s_-]+$/u;
/**
* URL Slug Pattern (for workspace slugs, URL-safe identifiers)
* Allows: Unicode letters (\p{L}), numbers (\p{N}), underscores, hyphens
* Use case: International URL-safe identifiers like "josé-workspace", "李明-project"
* Blocks: Spaces and special characters (URL encoding will handle Unicode in actual URLs)
*/
export const SLUG_REGEX = /^[\p{L}\p{N}_-]+$/u;
// =============================================================================
// VALIDATION FUNCTIONS
// =============================================================================
/**
* @description Validates person names (first name, last name)
* @param {string} name - Name to validate
* @returns {boolean | string} true if valid, error message if invalid
* @example
* validatePersonName("John") // returns true
* validatePersonName("O'Brien") // returns true
* validatePersonName("Jean-Paul") // returns true
* validatePersonName("John<script>") // returns error message
*/
export const validatePersonName = (name: string): boolean | string => {
if (!name || name.trim() === "") {
return "Name is required";
}
if (name.length > 50) {
return "Name must be 50 characters or less";
}
if (hasInjectionRiskChars(name)) {
return "Names cannot contain special characters like < > ' \" { } [ ] * ^ ! # %";
}
if (!PERSON_NAME_REGEX.test(name)) {
return "Names can only contain letters, spaces, hyphens, and apostrophes";
}
return true;
};
/**
* @description Validates display names and usernames
* @param {string} displayName - Display name to validate
* @returns {boolean | string} true if valid, error message if invalid
* @example
* validateDisplayName("john_doe") // returns true
* validateDisplayName("john.doe-123") // returns true
* validateDisplayName("john doe") // returns error message (spaces not allowed)
* validateDisplayName("john<>doe") // returns error message
*/
export const validateDisplayName = (displayName: string): boolean | string => {
if (!displayName || displayName.trim() === "") {
return true; // Display name is optional in most cases
}
if (displayName.length > 50) {
return "Display name must be 50 characters or less";
}
if (hasInjectionRiskChars(displayName)) {
return "Display name cannot contain special characters like < > ' \" { } [ ] * ^ ! # %";
}
if (!DISPLAY_NAME_REGEX.test(displayName)) {
return "Display name can only contain letters, numbers, periods, hyphens, and underscores";
}
return true;
};
/**
* @description Validates company and organization names
* @param {string} companyName - Company name to validate
* @param {boolean} required - Whether the field is required
* @returns {boolean | string} true if valid, error message if invalid
* @example
* validateCompanyName("Acme Corp") // returns true
* validateCompanyName("Acme_Corp-123") // returns true
* validateCompanyName("Acme{Corp}") // returns error message
*/
export const validateCompanyName = (companyName: string, required: boolean = false): boolean | string => {
if (!companyName || companyName.trim() === "") {
return required ? "Company name is required" : true;
}
if (companyName.length > 80) {
return "Company name must be 80 characters or less";
}
if (hasInjectionRiskChars(companyName)) {
return "Company name cannot contain special characters like < > ' \" { } [ ] * ^ ! # %";
}
if (!COMPANY_NAME_REGEX.test(companyName)) {
return "Company name can only contain letters, numbers, spaces, hyphens, and underscores";
}
return true;
};
/**
* @description Validates company and organization names
* @param {string} workspaceName - Workspace name to validate
* @param {boolean} required - Whether the field is required
* @returns {boolean | string} true if valid, error message if invalid
* @example
* validateWorkspaceName("Acme Corp") // returns true
* validateWorkspaceName("Acme_Corp-123") // returns true
* validateWorkspaceName("Acme{Corp}") // returns error message
*/
export const validateWorkspaceName = (workspaceName: string, required: boolean = false): boolean | string => {
if (!workspaceName || workspaceName.trim() === "") {
return required ? "Workspace name is required" : true;
}
if (workspaceName.length > 80) {
return "Workspace name must be 80 characters or less";
}
if (hasInjectionRiskChars(workspaceName)) {
return "Workspace name cannot contain special characters like < > ' \" { } [ ] * ^ ! # %";
}
if (!COMPANY_NAME_REGEX.test(workspaceName)) {
return "Workspace name can only contain letters, numbers, spaces, hyphens, and underscores";
}
return true;
};
/**
* @description Validates URL slugs and identifiers
* @param {string} slug - Slug to validate
* @returns {boolean | string} true if valid, error message if invalid
* @example
* validateSlug("my-workspace") // returns true
* validateSlug("my_workspace_123") // returns true
* validateSlug("my workspace") // returns error message (spaces not allowed)
*/
export const validateSlug = (slug: string): boolean | string => {
if (!slug || slug.trim() === "") {
return "Slug is required";
}
if (slug.length > 48) {
return "Slug must be 48 characters or less";
}
if (hasInjectionRiskChars(slug)) {
return "Slug cannot contain special characters like < > ' \" { } [ ] * ^ ! # %";
}
if (!SLUG_REGEX.test(slug)) {
return "Slug can only contain letters, numbers, hyphens, and underscores";
}
return true;
};
/**
* @description Checks if a string contains any injection-risk characters
* @param {string} input - String to check
* @returns {boolean} true if injection-risk characters found
* @example
* hasInjectionRiskChars("Hello World") // returns false
* hasInjectionRiskChars("Hello<script>") // returns true
*/
export const hasInjectionRiskChars = (input: string): boolean => {
const injectionRiskPattern = /[<>'"{}[\]*^!#%]/;
return injectionRiskPattern.test(input);
};