From 863579b72475df86d2403f684df7bda89eae1a8c Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 12 Jan 2026 14:27:35 +0530 Subject: [PATCH 1/3] 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 --- apps/admin/components/instance/setup-form.tsx | 30 ++- .../onboarding/create-workspace.tsx | 7 +- .../components/onboarding/profile-setup.tsx | 12 +- .../onboarding/steps/profile/root.tsx | 7 +- .../onboarding/steps/workspace/create.tsx | 8 +- .../profile/content/pages/general/form.tsx | 22 +- .../workspace/create-workspace-form.tsx | 7 +- packages/utils/src/index.ts | 1 + packages/utils/src/validation.ts | 190 ++++++++++++++++++ 9 files changed, 249 insertions(+), 35 deletions(-) create mode 100644 packages/utils/src/validation.ts diff --git a/apps/admin/components/instance/setup-form.tsx b/apps/admin/components/instance/setup-form.tsx index e500a55e177..48bfe8fa2fc 100644 --- a/apps/admin/components/instance/setup-form.tsx +++ b/apps/admin/components/instance/setup-form.tsx @@ -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} />
@@ -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} />
@@ -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} /> diff --git a/apps/web/core/components/onboarding/create-workspace.tsx b/apps/web/core/components/onboarding/create-workspace.tsx index 1cf5d087cc3..85a45d2e394 100644 --- a/apps/web/core/components/onboarding/create-workspace.tsx +++ b/apps/web/core/components/onboarding/create-workspace.tsx @@ -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()); }} diff --git a/apps/web/core/components/onboarding/profile-setup.tsx b/apps/web/core/components/onboarding/profile-setup.tsx index 41e384b21f0..0f8be9482ad 100644 --- a/apps/web/core/components/onboarding/profile-setup.tsx +++ b/apps/web/core/components/onboarding/profile-setup.tsx @@ -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 } }) => ( diff --git a/apps/web/core/components/onboarding/steps/profile/root.tsx b/apps/web/core/components/onboarding/steps/profile/root.tsx index 788e3896691..ca8a648b92b 100644 --- a/apps/web/core/components/onboarding/steps/profile/root.tsx +++ b/apps/web/core/components/onboarding/steps/profile/root.tsx @@ -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 } }) => ( diff --git a/apps/web/core/components/onboarding/steps/workspace/create.tsx b/apps/web/core/components/onboarding/steps/workspace/create.tsx index 6b5eccf264c..371c4532ab0 100644 --- a/apps/web/core/components/onboarding/steps/workspace/create.tsx +++ b/apps/web/core/components/onboarding/steps/workspace/create.tsx @@ -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()); }} diff --git a/apps/web/core/components/settings/profile/content/pages/general/form.tsx b/apps/web/core/components/settings/profile/content/pages/general/form.tsx index b92388e1d38..f4f67e5b3ad 100644 --- a/apps/web/core/components/settings/profile/content/pages/general/form.tsx +++ b/apps/web/core/components/settings/profile/content/pages/general/form.tsx @@ -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 } }) => ( )} @@ -284,6 +287,9 @@ export const GeneralProfileSettingsForm = observer(function GeneralProfileSettin ( )} /> + {errors.last_name && {errors.last_name.message}}

@@ -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 } }) => ( )} /> diff --git a/apps/web/core/components/workspace/create-workspace-form.tsx b/apps/web/core/components/workspace/create-workspace-form.tsx index c1d7e983aa6..c8ea50dbdbd 100644 --- a/apps/web/core/components/workspace/create-workspace-form.tsx +++ b/apps/web/core/components/workspace/create-workspace-form.tsx @@ -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()); }} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e59acc58196..450acb4b275 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -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"; diff --git a/packages/utils/src/validation.ts b/packages/utils/src/validation.ts new file mode 100644 index 00000000000..273dbb573c5 --- /dev/null +++ b/packages/utils/src/validation.ts @@ -0,0 +1,190 @@ +/** + * 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: Letters (a-zA-Z), spaces, hyphens, apostrophes + * Use case: Accommodates names like "O'Brien", "Jean-Paul", "Mary Ann" + * Blocks: All special characters including injection-risk chars + */ +export const PERSON_NAME_REGEX = /^[a-zA-Z\s'-]+$/; + +/** + * Display Name Pattern (for display_name, usernames) + * Allows: Alphanumeric, underscore, period, hyphen + * Use case: Standard across GitHub, Slack, Twitter, etc. + * Blocks: Spaces and all special characters including injection-risk chars + */ +export const DISPLAY_NAME_REGEX = /^[a-zA-Z0-9_.-]+$/; + +/** + * Company/Organization Name Pattern (for company_name, workspace names) + * Allows: Alphanumeric, spaces, underscores, hyphens + * Use case: Business names that may include spaces and basic punctuation + * Blocks: Special punctuation and injection-risk chars + */ +export const COMPANY_NAME_REGEX = /^[a-zA-Z0-9\s_-]+$/; + +/** + * URL Slug Pattern (for workspace slugs, URL-safe identifiers) + * Allows: Alphanumeric, underscores, hyphens only + * Use case: URL-safe identifiers without spaces + * Blocks: Spaces and all special characters + */ +export const SLUG_REGEX = /^[a-zA-Z0-9_-]+$/; + +// ============================================================================= +// 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