diff --git a/.changeset/redos-email-regex-validators.md b/.changeset/redos-email-regex-validators.md new file mode 100644 index 0000000000..27fa418c32 --- /dev/null +++ b/.changeset/redos-email-regex-validators.md @@ -0,0 +1,13 @@ +--- +"@objectstack/objectql": patch +"@objectstack/plugin-email": patch +--- + +fix(validation): remove polynomial ReDoS in email validation regexes + +The email validators used `/^[^\s@]+@[^\s@]+\.[^\s@]+$/`, whose quantifiers +around `\.` overlap (the literal dot is also matched by `[^\s@]`) and backtrack +polynomially on adversarial input. The domain part is rewritten as +`[^\s@.]+(?:\.[^\s@.]+)+` so labels exclude `.` and matching is linear. Valid +addresses (including multi-label domains) are unaffected; addresses with an +empty label such as `a@b..c` are now correctly rejected. diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index b2e5f86493..d7e14e7587 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -43,7 +43,11 @@ const SKIP_FIELDS = new Set([ 'id', 'created_at', 'created_by', 'updated_at', 'updated_by', ]); -const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +// Linear-time email check. Domain labels exclude '.', so the quantifiers on +// either side of each '.' can't overlap — this avoids the polynomial +// backtracking (ReDoS) of the naive `[^\s@]+\.[^\s@]+` shape while still +// requiring a local part, an '@', and a dotted domain. +const EMAIL_RE = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/; // Permissive URL pattern: accept any scheme:// + non-empty body so that // non-HTTP URIs used by drivers (libsql://, postgres://, mysql://, file://, s3://, …) // pass field-level validation. Stricter per-field checks can be enforced diff --git a/packages/objectql/src/validation/rule-validator.test.ts b/packages/objectql/src/validation/rule-validator.test.ts index 7fa0f34843..8b87b9451c 100644 --- a/packages/objectql/src/validation/rule-validator.test.ts +++ b/packages/objectql/src/validation/rule-validator.test.ts @@ -303,6 +303,33 @@ describe('format enforcement', () => { ).not.toThrow(); }); + it('accepts multi-label domains and rejects malformed / empty-label emails', () => { + const emailSchema = schema({ field: 'email', format: 'email' }); + const ok = (v: string) => + expect(() => evaluateValidationRules(emailSchema, { email: v }, 'insert')).not.toThrow(); + const bad = (v: string) => + expect(() => evaluateValidationRules(emailSchema, { email: v }, 'insert')).toThrow(ValidationError); + + ok('a@b.co.uk'); + ok('x.y+z@sub.domain.io'); + bad('a@b'); // no dot in domain + bad('a@b.'); // empty trailing label + bad('a@.com'); // empty leading label + bad('a@b..c'); // consecutive dots -> empty label + bad('a b@c.com'); // whitespace in local part + }); + + it('validates an email in linear time (no ReDoS on adversarial input)', () => { + // Overlapping-quantifier email regexes backtrack polynomially on a long + // run of domain-ish chars with no valid terminator. This must stay fast. + const attack = `a@${'a'.repeat(50_000)}${'.'.repeat(50_000)}!`; + const start = performance.now(); + expect(() => + evaluateValidationRules(schema({ field: 'email', format: 'email' }), { email: attack }, 'insert'), + ).toThrow(ValidationError); + expect(performance.now() - start).toBeLessThan(1_000); + }); + it('validates url / phone / json named formats', () => { expect(() => evaluateValidationRules(schema({ field: 'site', format: 'url' }), { site: 'nope' }, 'insert')).toThrow(ValidationError); expect(() => evaluateValidationRules(schema({ field: 'site', format: 'url' }), { site: 'https://x.io' }, 'insert')).not.toThrow(); diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index a950e9693b..4360af3aa1 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -530,7 +530,11 @@ function checkPredicate( return null; } -const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +// Linear-time email check. Domain labels exclude '.', so the quantifiers on +// either side of each '.' can't overlap — this avoids the polynomial +// backtracking (ReDoS) of the naive `[^\s@]+\.[^\s@]+` shape while still +// requiring a local part, an '@', and a dotted domain. +const EMAIL_RE = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/; // Lenient phone matcher: optional leading +, then 7–20 digits with spaces, // dashes, dots and parens allowed as separators. Intentionally permissive — // strict national formats belong in a `regex`. diff --git a/packages/plugins/plugin-email/src/email-service.ts b/packages/plugins/plugin-email/src/email-service.ts index fd71781bc9..1602f435f6 100644 --- a/packages/plugins/plugin-email/src/email-service.ts +++ b/packages/plugins/plugin-email/src/email-service.ts @@ -26,7 +26,10 @@ export interface EmailPersistence { * Naive RFC-5322 validator — good enough to catch obvious typos. * Defers full validation to the transport / receiving MTA. */ -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +// Linear-time email check. Domain labels exclude '.', so the quantifiers on +// either side of each '.' can't overlap — this avoids the polynomial +// backtracking (ReDoS) of the naive `[^\s@]+\.[^\s@]+` shape. +const EMAIL_REGEX = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/; /** * Format an EmailAddress (string or {name,address}) into the canonical