diff --git a/.changeset/import-email-client-validation.md b/.changeset/import-email-client-validation.md new file mode 100644 index 000000000..b6ee3dc2c --- /dev/null +++ b/.changeset/import-email-client-validation.md @@ -0,0 +1,15 @@ +--- +"@object-ui/plugin-grid": patch +--- + +fix(grid): validate email format in the import preview (objectstack#3566) + +The ImportWizard's per-cell `validateValue` did no format check for `email` +columns (it fell through to `default → true`), so an obviously-bad address — +e.g. a non-ASCII domain like `x@柴仟.com` — passed client validation (and the +server dry-run) and only failed at real-import time inside better-auth, giving +a jarring "passed validation, then failed" experience. + +- Added `isPlausibleEmail`, a single-pass structural + ASCII check that mirrors + the server's `isLikelyEmail`, so bad emails are flagged red in the preview + step before submit. No regex backtracking (same ReDoS-safety as the server). diff --git a/packages/plugin-grid/src/ImportWizard.tsx b/packages/plugin-grid/src/ImportWizard.tsx index 371982e16..c061faaba 100644 --- a/packages/plugin-grid/src/ImportWizard.tsx +++ b/packages/plugin-grid/src/ImportWizard.tsx @@ -414,12 +414,31 @@ function formatDryRunError( return { fieldLabel, message }; } +/** + * Plausible email? Mirrors the server's `isLikelyEmail` (structure + ASCII) so + * an obviously-bad address — e.g. a non-ASCII domain like `x@柴仟.com` — is + * flagged red in the preview here, instead of passing client + dry-run + * validation only to be rejected by better-auth at real-import time + * (framework#3566). Deliberately not a regex: a single-pass structural check + * has no backtracking (cf. the server-side ReDoS note). + */ +export function isPlausibleEmail(value: string): boolean { + if (value.length === 0 || value.length > 254 || /\s/.test(value)) return false; + if (/[^\x00-\x7f]/.test(value)) return false; // ASCII only, like the server + const at = value.indexOf('@'); + if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return false; + const domain = value.slice(at + 1); + const dot = domain.lastIndexOf('.'); + return dot > 0 && dot < domain.length - 1; +} + function validateValue(value: string, type: string): boolean { if (!value) return true; switch (type) { case 'number': case 'currency': case 'percent': return !isNaN(Number(value)); case 'boolean': return BOOLEAN_IMPORT_TOKENS.has(value.trim().toLowerCase()); case 'date': case 'datetime': return !isNaN(Date.parse(value)); + case 'email': return isPlausibleEmail(value.trim()); default: return true; } } diff --git a/packages/plugin-grid/src/importEmailValidation.test.ts b/packages/plugin-grid/src/importEmailValidation.test.ts new file mode 100644 index 000000000..d0dd731c2 --- /dev/null +++ b/packages/plugin-grid/src/importEmailValidation.test.ts @@ -0,0 +1,39 @@ +/** + * ObjectUI – Copyright (c) 2024-present ObjectStack Inc. + * Licensed under MIT. + */ + +/** + * isPlausibleEmail() mirrors the server's `isLikelyEmail` (structure + ASCII) + * so obviously-bad addresses fail in the preview step instead of only at + * real-import time (framework#3566). + */ +import { describe, it, expect } from 'vitest'; +import { isPlausibleEmail } from './ImportWizard'; + +describe('isPlausibleEmail', () => { + it('accepts normal addresses', () => { + expect(isPlausibleEmail('name@example.com')).toBe(true); + expect(isPlausibleEmail('first.last@sub.example.co')).toBe(true); + }); + + it('rejects non-ASCII addresses (the reported bug)', () => { + expect(isPlausibleEmail('735431496@柴仟.com')).toBe(false); + expect(isPlausibleEmail('abc@b.com')).toBe(false); + }); + + it('rejects structurally-broken addresses', () => { + expect(isPlausibleEmail('no-at.example.com')).toBe(false); + expect(isPlausibleEmail('a@b')).toBe(false); + expect(isPlausibleEmail('a@@b.co')).toBe(false); + expect(isPlausibleEmail('a@.co')).toBe(false); + expect(isPlausibleEmail('a@b.')).toBe(false); + expect(isPlausibleEmail('has space@b.co')).toBe(false); + }); + + it('is fast on adversarial input (no backtracking)', () => { + const t0 = Date.now(); + expect(isPlausibleEmail('!@'.repeat(100_000))).toBe(false); + expect(Date.now() - t0).toBeLessThan(1000); + }); +});