Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/redos-email-regex-validators.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion packages/objectql/src/validation/record-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ const SKIP_FIELDS = new Set<string>([
'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
Expand Down
27 changes: 27 additions & 0 deletions packages/objectql/src/validation/rule-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 5 additions & 1 deletion packages/objectql/src/validation/rule-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
5 changes: 4 additions & 1 deletion packages/plugins/plugin-email/src/email-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down