Skip to content

Commit 0a53c88

Browse files
committed
improvement(utils): one email syntax gate, drop the backtracking placeholder regex
Audit follow-ups on the emails chip input. - move EMAIL_SYNTAX_REGEX and the new @Domain pattern into @sim/utils/string as isValidEmailSyntax, so emcn and lib/messaging/email/validation.ts stop keeping byte-identical copies of the RFC 5322 regex - allow single-label domains (@intranet) again — requiring a dot rejected entries the old startsWith('@') check accepted, which self-hosted deployments use. A lone @ and malformed labels stay rejected - replace derivePlaceholderWithTags' /^Enter\s+(.+?)s?$/i with string ops; CodeQL flagged it as polynomial backtracking (js/redos). Verified identical output across the placeholder shapes in use - forward the emails control's props explicitly instead of underscore-discard destructuring, matching ChipModalFileControl in the same file
1 parent 33b242d commit 0a53c88

5 files changed

Lines changed: 63 additions & 45 deletions

File tree

apps/sim/lib/messaging/email/validation.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { isValidEmailSyntax } from '@sim/utils/string'
2+
13
export interface EmailValidationResult {
24
isValid: boolean
35
reason?: string
@@ -38,15 +40,6 @@ const DISPOSABLE_DOMAINS = new Set([
3840
'yopmail.com',
3941
])
4042

41-
/**
42-
* Validates email syntax using RFC 5322 compliant regex
43-
*/
44-
function validateEmailSyntax(email: string): boolean {
45-
const emailRegex =
46-
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
47-
return emailRegex.test(email) && email.length <= 254
48-
}
49-
5043
/**
5144
* Checks if email is from a known disposable email provider
5245
*/
@@ -78,7 +71,7 @@ export function quickValidateEmail(email: string): EmailValidationResult {
7871
disposable: false,
7972
}
8073

81-
checks.syntax = validateEmailSyntax(email)
74+
checks.syntax = isValidEmailSyntax(email)
8275
if (!checks.syntax) {
8376
return {
8477
isValid: false,
@@ -136,9 +129,9 @@ export function quickValidateEmail(email: string): EmailValidationResult {
136129

137130
/**
138131
* App-level policy for a single access-allowlist entry, applied on top of the
139-
* syntax gate in `ChipEmailsInput`. A bare `@domain` entry carries no local
140-
* part, so the address-level checks (disposable providers, suspicious patterns)
141-
* only apply to full addresses.
132+
* caller's format gate. A bare `@domain` entry carries no local part, so the
133+
* address-level checks (disposable providers, suspicious patterns) only apply
134+
* to full addresses.
142135
*
143136
* @returns the rejection reason, or `null` when the entry is accepted.
144137
*/

packages/emcn/src/components/chip-emails-input/chip-emails-input.tsx

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,26 @@
11
'use client'
22

33
import * as React from 'react'
4-
import { normalizeEmail } from '@sim/utils/string'
4+
import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string'
55
import { TagInput, type TagItem } from '../tag-input/tag-input'
66

7-
/**
8-
* Generic RFC 5322 email syntax gate. This is deliberately format-only —
9-
* app-specific policy (disposable domains, MX/DNS, membership rules) is the
10-
* consumer's concern and flows through the `validate` prop, keeping that logic
11-
* in the app rather than the design system.
12-
*/
13-
const EMAIL_SYNTAX_REGEX =
14-
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
15-
16-
/**
17-
* Bare `@domain.tld` pattern, accepted only when the consumer opts in via
18-
* `allowDomains` — for allowlists that grant access to a whole domain.
19-
*/
20-
const EMAIL_DOMAIN_SYNTAX_REGEX =
21-
/^@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/
22-
23-
function isValidEmailSyntax(email: string, allowDomains: boolean): boolean {
24-
if (email.length > 254) return false
25-
return EMAIL_SYNTAX_REGEX.test(email) || (allowDomains && EMAIL_DOMAIN_SYNTAX_REGEX.test(email))
26-
}
7+
const ENTER_PREFIX = 'enter'
278

289
/**
2910
* Derives the post-first-chip placeholder from the initial placeholder so
3011
* consumers don't have to spell both. Tries an `'Enter <noun>s'` →
3112
* `'Add <noun>'` singularize; falls back to a generic `'Add another'`.
13+
*
14+
* Deliberately string ops rather than a regex — `/^Enter\s+(.+?)s?$/` has
15+
* polynomial backtracking on whitespace-heavy input (CodeQL js/redos).
3216
*/
3317
function derivePlaceholderWithTags(placeholder: string): string {
34-
const match = placeholder.match(/^Enter\s+(.+?)s?$/i)
35-
if (match) return `Add ${match[1]}`
36-
return 'Add another'
18+
const rest = placeholder.slice(ENTER_PREFIX.length)
19+
const noun = rest.trim()
20+
const startsWithEnter = placeholder.slice(0, ENTER_PREFIX.length).toLowerCase() === ENTER_PREFIX
21+
if (!startsWithEnter || rest === noun || !noun) return 'Add another'
22+
const singular = noun.toLowerCase().endsWith('s') ? noun.slice(0, -1) : noun
23+
return `Add ${singular}`
3724
}
3825

3926
export interface ChipEmailsInputProps {

packages/emcn/src/components/chip-modal/chip-modal.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -730,20 +730,31 @@ function renderChipModalControl(
730730
* server-side submit failure).
731731
*/
732732
function ChipModalEmailsControl({
733-
type: _type,
734-
title: _title,
735-
required: _required,
736-
hint: _hint,
737-
flush: _flush,
738-
className: _className,
733+
value,
734+
onChange,
735+
validate,
736+
allowDomains,
737+
placeholder,
738+
placeholderWithTags,
739+
autoFocus,
740+
disabled,
739741
error,
740742
errorId,
741743
id,
742-
...emailsProps
743744
}: ChipModalEmailsFieldProps & { id: string; errorId: string }) {
744745
return (
745746
<>
746-
<ChipEmailsInput id={id} {...emailsProps} />
747+
<ChipEmailsInput
748+
id={id}
749+
value={value}
750+
onChange={onChange}
751+
validate={validate}
752+
allowDomains={allowDomains}
753+
placeholder={placeholder}
754+
placeholderWithTags={placeholderWithTags}
755+
autoFocus={autoFocus}
756+
disabled={disabled}
757+
/>
747758
{error && (
748759
<p id={errorId} role='alert' className={CHIP_MODAL_FIELD_ERROR_CLASS}>
749760
{error}

packages/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type { BackoffOptions } from './retry.js'
3434
export { backoffWithJitter, parseRetryAfter } from './retry.js'
3535
export { normalizeSSODomain } from './sso-domain.js'
3636
export {
37+
isValidEmailSyntax,
3738
normalizeEmail,
3839
sanitizeForJsonb,
3940
sanitizeValueForJsonb,

packages/utils/src/string.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,32 @@ export function normalizeEmail(email: string): string {
4646
return email.trim().toLowerCase()
4747
}
4848

49+
/**
50+
* RFC 5322-shaped syntax gate for a full address. Format only — domain
51+
* reputation, MX/DNS, and membership policy are the caller's concern.
52+
*/
53+
const EMAIL_SYNTAX_REGEX =
54+
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
55+
56+
/**
57+
* Bare `@domain` pattern, for allowlists that grant access to a whole domain.
58+
* Single-label domains (`@intranet`) are allowed — self-hosted deployments use
59+
* them — but a lone `@` and malformed labels are not.
60+
*/
61+
const EMAIL_DOMAIN_SYNTAX_REGEX =
62+
/^@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
63+
64+
/**
65+
* Format-only email syntax check, capped at the RFC 5321 length limit.
66+
*
67+
* @param allowDomains - also accept a bare `@domain` entry, for allowlists that
68+
* grant access to an entire domain rather than a single address.
69+
*/
70+
export function isValidEmailSyntax(email: string, allowDomains = false): boolean {
71+
if (email.length > 254) return false
72+
return EMAIL_SYNTAX_REGEX.test(email) || (allowDomains && EMAIL_DOMAIN_SYNTAX_REGEX.test(email))
73+
}
74+
4975
/**
5076
* Matches UTF-16 code units that Postgres JSONB rejects: unpaired surrogate
5177
* halves (e.g. produced by `slice()` cutting an astral character like 𝐀 in

0 commit comments

Comments
 (0)