Notifio has exactly five form controls on its whole 38 page site. Four of them are the contact form on /help. I went to add a character counter to the message box, read both halves of the form properly first, and found that the two halves do not agree about what a valid submission is. The browser half ContactForm.tsx is 133 lines and all of its validation is HTML attributes: {/* name */} {/* email */} {/* subject */} {/* message */} Enter fullscreen mode Exit fullscreen mode Four checks: three required attributes and one type="email". The submit handler adds none of its own: async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setStatus("sending"); const res = await fetch("/api/contact", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(fields), }); ... } Enter fullscreen mode Exit fullscreen mode preventDefault, then straight to fetch. No trimming, no length check, no second look at the email. That is a defensible choice: client validation is a courtesy and the server is the authority, so duplicating rules in two languages is how they drift apart. The question is whether the courtesy is accurate, and here it is not. The server half api/contact/route.ts has seven gates in order: 1 rate limit per IP 429 2 JSON.parse the body 400 "Invalid request body." 3 trim all four fields, lowercase the email - 4 name, email or message empty after trim 400 "name, email and message are required." 5 /^[^\s@]+@[^\s@]+\.[^\s@]+$/ on the email 400 "Invalid email address." 6 message.length > 5000 400 "Message is too long." 7 send via Resend 500 "Failed to send message. Please try again." Enter fullscreen mode Exit fullscreen mode Four checks on one side, seven on the other, and only gate 5 overlaps with anything the browser does. So that is the one worth testing. Three things the browser accepts and the API rejects All three verified against production. None of them reaches the send step, so no email was created by any of this: $ curl -X POST https://notifio.app/api/contact -H 'Content-Type: application/json' \ -d '{"name":"Test","email":"a@b","message":"hello"}' 400 {"error":"Invalid email address."} Enter fullscreen mode Exit fullscreen mode a@b is a valid type="email" value. The HTML specification's email production makes the dotted part of the domain optional, so the browser is right by its own rules and will submit the form without complaint. The server regex requires a literal dot after the @, so it refuses. A user who types dan@localhost gets told their address is invalid by the server, immediately after the browser told them it was fine. $ ... -d '{"name":" ","email":"a@b.co","message":"hello"}' 400 {"error":"name, email and message are required."} Enter fullscreen mode Exit fullscreen mode required is satisfied by any non-empty value, including a single space. The server trims first and then checks, so a space-only name fails gate 4, and the message it fails with names all three fields when only one of them was blank. The user is told that name, email and message are required while looking at a form where all three contain something. $ ... -d '{"name":"Test","email":"a@b.co","message":"xxxx…5001 chars"}' 400 {"error":"Message is too long."} Enter fullscreen mode Exit fullscreen mode The 5,000 character cap exists on the server only. There are zero maxlength attributes in the HTML of all 38 pages of this site, so the textarea accepts any length, gives no counter and no warning, and the error that comes back does not say what the limit is. The worst case is a genuinely long support message typed into the box, rejected on submit, with the text still in the field and no clue how much to cut. The field asymmetry is the better bug Of the four fields the API accepts, exactly one has a length limit: field client cap server cap name none none email none none subject none none message none 5,000 Enter fullscreen mode Exit fullscreen mode name and subject are both interpolated into the email that gets sent. subject goes into the subject line: const emailSubject = subject ? `Notifio Contact: ${subject}` : `Notifio Contact from ${name}`; Enter fullscreen mode Exit fullscreen mode Neither has a bound anywhere in the path. The body is escaped properly, five escHtml() call sites covering every interpolation into the HTML template, so this is not an injection story. It is a story about a cap that was added once, to the field that obviously needed it, and never generalised. The rule I now want is one length check applied to the payload as a whole, before any field is looked at individually, because "the one field somebody remembered" is not a validation strategy. The form has no name attributes, on purpose, with a cost Something else the audit turned up: Enter fullscreen mode Exit fullscreen mode No action, no method, and not one of the four inputs has a name. That is internally consistent, because nothing here ever performs a native form submission: React holds the values in one useState object and fetch sends them as JSON. Adding name attributes would be decoration. The cost is that with JavaScript disabled, /help renders a complete, enabled, fully styled form whose submit button does nothing at all. Not an error, not a fallback, nothing: a native submit with no action reloads the same URL, and with no name attributes there is not even a query string to show for it. Every other page on this site is static HTML that works fine without JS. The single interactive thing on it is the one part that silently is not there. Both of those are the same bug in different clothes. A browser side check and a server side check are not two layers of the same validation unless somebody keeps them in step, and nothing in the type system, the tests or the build will tell you when they drift. The only thing that catches a@b is sending it. Related, on the receiving end of this form: Our support inbox is a webhook, and two angle brackets were breaking the images and Our inbound mail webhook answers 200 to everything it refuses.
Our contact form runs four checks in the browser and seven on the server, and the only check both of them do is the one they disagree about
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.