diff --git a/apps/remix/app/components/dialogs/folder-create-dialog.tsx b/apps/remix/app/components/dialogs/folder-create-dialog.tsx index f29fa3e1c..bbad765aa 100644 --- a/apps/remix/app/components/dialogs/folder-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/folder-create-dialog.tsx @@ -1,4 +1,4 @@ -import { ZFolderNameSchema } from '@documenso/lib/utils/folder-name'; +import { ZNameSchema } from '@documenso/lib/constants/auth'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -24,7 +24,7 @@ import { useParams } from 'react-router'; import { z } from 'zod'; const ZCreateFolderFormSchema = z.object({ - name: ZFolderNameSchema, + name: ZNameSchema, }); type TCreateFolderFormSchema = z.infer; @@ -66,7 +66,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }: toast({ description: t`Folder created successfully`, }); - } catch (err) { + } catch (_err) { toast({ title: t`Failed to create folder`, description: t`An unknown error occurred while creating the folder.`, diff --git a/apps/remix/app/components/dialogs/folder-update-dialog.tsx b/apps/remix/app/components/dialogs/folder-update-dialog.tsx index eceea2781..be50f6a80 100644 --- a/apps/remix/app/components/dialogs/folder-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/folder-update-dialog.tsx @@ -1,6 +1,6 @@ +import { ZNameSchema } from '@documenso/lib/constants/auth'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; -import { ZFolderNameSchema } from '@documenso/lib/utils/folder-name'; import { trpc } from '@documenso/trpc/react'; import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema'; import { Button } from '@documenso/ui/primitives/button'; @@ -24,8 +24,6 @@ import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { useOptionalCurrentTeam } from '~/providers/team'; - export type FolderUpdateDialogProps = { folder: TFolderWithSubfolders | null; isOpen: boolean; @@ -33,7 +31,7 @@ export type FolderUpdateDialogProps = { } & Omit; export const ZUpdateFolderFormSchema = z.object({ - name: ZFolderNameSchema, + name: ZNameSchema, visibility: z.nativeEnum(DocumentVisibility).optional(), }); @@ -41,7 +39,6 @@ export type TUpdateFolderFormSchema = z.infer; export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => { const { t } = useLingui(); - const team = useOptionalCurrentTeam(); const { toast } = useToast(); const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation(); diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index 4768540d4..bf8945e06 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -2,6 +2,7 @@ import MailChecker from 'mailchecker'; import { z } from 'zod'; import { env } from '../utils/env'; +import { hasInvalidTextCharacters } from '../utils/zod'; import { NEXT_PUBLIC_WEBAPP_URL } from './app'; export const SALT_ROUNDS = 12; @@ -9,15 +10,20 @@ export const SALT_ROUNDS = 12; export const URL_PATTERN = /https?:\/\/|www\./i; /** - * Shared name schema that disallows URLs to prevent phishing via email rendering. + * Shared name schema that disallows URLs to prevent phishing via email rendering, + * and invisible/control characters that render as empty or break the UI. + * Also disallows invalid resource name characters. */ export const ZNameSchema = z .string() .trim() - .min(3, { message: 'Please enter a valid name.' }) + .min(2, { message: 'Please enter a valid name.' }) .max(255, { message: 'Name cannot be more than 255 characters.' }) .refine((value) => !URL_PATTERN.test(value), { message: 'Name cannot contain URLs.', + }) + .refine((value) => !hasInvalidTextCharacters(value), { + message: 'Name contains invalid characters.', }); export const IDENTITY_PROVIDER_NAME: Record = { diff --git a/packages/lib/utils/zod.ts b/packages/lib/utils/zod.ts index fefaa2fdd..5ffe4bfbf 100644 --- a/packages/lib/utils/zod.ts +++ b/packages/lib/utils/zod.ts @@ -13,6 +13,54 @@ const EMAIL_REGEX = const DEFAULT_EMAIL_MESSAGE = 'Invalid email address'; +/** + * Code point ranges for control and invisible/formatting characters that render + * as empty or break the UI (e.g. NUL, zero-width spaces, bidi overrides, BOM). + */ +const INVALID_TEXT_CODE_POINT_RANGES: [number, number][] = [ + [0x0000, 0x001f], + [0x007f, 0x009f], + [0x00ad, 0x00ad], + [0x034f, 0x034f], + [0x061c, 0x061c], + [0x180e, 0x180e], + [0x200b, 0x200f], + [0x2028, 0x202e], + [0x2060, 0x206f], + [0xfeff, 0xfeff], + [0xd800, 0xdfff], +]; + +/** + * The same characters expressed as literal "\\uXXXX" text, which can be stored + * verbatim (e.g. the 6 characters `\`, `u`, `0`, `0`, `0`, `0`) and still break + * rendering downstream. This regex is pure ASCII so it is safe to inline. + */ +const INVALID_TEXT_ESCAPE_SEQUENCE_REGEX = + /\\u(?:00[0-1][0-9a-f]|007f|00[89][0-9a-f]|00ad|034f|061c|180e|200[b-f]|202[8-e]|206[0-9a-f]|feff)/iu; + +export const hasInvalidTextCharacters = (value: string) => { + if (INVALID_TEXT_ESCAPE_SEQUENCE_REGEX.test(value)) { + return true; + } + + for (const char of value) { + const codePoint = char.codePointAt(0); + + if (codePoint === undefined) { + continue; + } + + const isInvalid = INVALID_TEXT_CODE_POINT_RANGES.some(([start, end]) => codePoint >= start && codePoint <= end); + + if (isInvalid) { + return true; + } + } + + return false; +}; + /** * Creates a Zod email schema using an RFC 5322 compliant regex. * diff --git a/packages/trpc/server/folder-router/schema.ts b/packages/trpc/server/folder-router/schema.ts index d8a430589..f16981836 100644 --- a/packages/trpc/server/folder-router/schema.ts +++ b/packages/trpc/server/folder-router/schema.ts @@ -1,6 +1,6 @@ +import { ZNameSchema } from '@documenso/lib/constants/auth'; import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type'; import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; -import { ZFolderNameSchema } from '@documenso/lib/utils/folder-name'; import { DocumentVisibility } from '@documenso/prisma/generated/types'; import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema'; import { z } from 'zod'; @@ -43,7 +43,7 @@ const ZFolderParentIdSchema = z .describe('The folder ID to place this folder within. Leave empty to place folder at the root level.'); export const ZCreateFolderRequestSchema = z.object({ - name: ZFolderNameSchema, + name: ZNameSchema, parentId: ZFolderParentIdSchema.optional(), type: ZFolderTypeSchema.optional(), }); @@ -53,7 +53,7 @@ export const ZCreateFolderResponseSchema = ZFolderSchema; export const ZUpdateFolderRequestSchema = z.object({ folderId: z.string().describe('The ID of the folder to update'), data: z.object({ - name: ZFolderNameSchema.optional().describe('The name of the folder'), + name: ZNameSchema.optional().describe('The name of the folder'), parentId: ZFolderParentIdSchema.optional().nullable(), visibility: z.nativeEnum(DocumentVisibility).optional().describe('The visibility of the folder'), pinned: z.boolean().optional().describe('Whether the folder should be pinned'),