[object Object]

← back to NationalPaperHangers

Reject malformed emails at signup and claim instead of just checking for an @

50d107f1c77d41565ab8442e7643d4941e66a34c · 2026-05-19 07:55:39 -0700 · Steve

Files touched

Diff

commit 50d107f1c77d41565ab8442e7643d4941e66a34c
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue May 19 07:55:39 2026 -0700

    Reject malformed emails at signup and claim instead of just checking for an @
---
 lib/utils.js    | 12 +++++++++++-
 routes/auth.js  |  3 ++-
 routes/claim.js |  4 ++--
 3 files changed, 15 insertions(+), 4 deletions(-)

diff --git a/lib/utils.js b/lib/utils.js
index dc6d98e..1246863 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -40,4 +40,14 @@ function clockMinutes(v) {
   return h * 60 + Number(m[2]) + (m[3] ? Number(m[3]) / 60 : 0);
 }
 
-module.exports = { escapeHtml, safeHttpUrl, clampLen, clockMinutes };
+// Single source of truth for "is this a usable email address". Mirrors the
+// shape check the booking endpoint already uses, so signup, claim, and
+// booking all reject the same garbage (e.g. "@", "a@", "a@b") instead of
+// letting it into the installers.email / bookings.customer_email columns.
+function isValidEmail(v) {
+  const s = String(v == null ? '' : v).trim();
+  if (!s || s.length > 254) return false;
+  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
+}
+
+module.exports = { escapeHtml, safeHttpUrl, clampLen, clockMinutes, isValidEmail };
diff --git a/routes/auth.js b/routes/auth.js
index 36ffc51..846f407 100644
--- a/routes/auth.js
+++ b/routes/auth.js
@@ -2,6 +2,7 @@ const express = require('express');
 const slugify = require('slugify');
 const db = require('../lib/db');
 const { hashPassword, verifyPassword } = require('../lib/auth');
+const { isValidEmail } = require('../lib/utils');
 const router = express.Router();
 
 const GENERIC_LOGIN_ERROR = 'Invalid email or password';
@@ -72,7 +73,7 @@ router.post('/signup', async (req, res, next) => {
     const zip = (f.zip || '').trim();
 
     const errs = [];
-    if (!email || !email.includes('@')) errs.push('Valid email required');
+    if (!isValidEmail(email)) errs.push('Valid email required');
     if (password.length < 8) errs.push('Password must be at least 8 characters');
     if (!businessName) errs.push('Business name required');
     if (!city) errs.push('City required');
diff --git a/routes/claim.js b/routes/claim.js
index 6123c07..865ab0c 100644
--- a/routes/claim.js
+++ b/routes/claim.js
@@ -15,7 +15,7 @@ const express = require('express');
 const crypto = require('crypto');
 const db = require('../lib/db');
 const email = require('../lib/email');
-const { escapeHtml } = require('../lib/utils');
+const { escapeHtml, isValidEmail } = require('../lib/utils');
 
 const router = express.Router();
 
@@ -171,7 +171,7 @@ router.post('/installer/:slug/claim/complete', async (req, res, next) => {
 
     const newEmail = (req.body.email || '').toLowerCase().trim();
     const password = req.body.password || '';
-    if (!newEmail.includes('@') || password.length < 8) {
+    if (!isValidEmail(newEmail) || password.length < 8) {
       return res.status(400).render('public/claim-complete', { title: 'Finish claim', installer, error: 'Email and 8+ char password required' });
     }
     const dupe = await db.one('SELECT id FROM installers WHERE email=$1 AND id<>$2', [newEmail, installer.id]);

← a3b0896 Validate admin portfolio uploads by magic bytes, not the spo  ·  back to NationalPaperHangers  ·  Coerce a garbage COI project-value to null instead of 500ing 124a38f →