DEV Community

Cover image for The Bug That Turned My Own Inbox Into a Phishing Target
Lior Karaev
Lior Karaev

Posted on

The Bug That Turned My Own Inbox Into a Phishing Target

Summer Bug Smash: Clear the Lineup 🐛🛹

This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.

Project Overview

RamenHire (ramenhire.com) is a job board for bootstrapped, profitable startups — built with Next.js and Supabase. Job seekers apply, and companies submit listings through public forms with no login required. Every submission triggers an internal notification email, built from user-supplied fields, sent straight to the team's own inbox.

Bug Fix or Performance Improvement

lib/email-templates.ts interpolated user-controlled fields — applicant name, company name, "why interested," job description, and more — directly into raw HTML email strings with zero output encoding, including values embedded inside href="..." attributes.

That meant any of the site's four public forms (apply, post-a-job, subscribe, company registration) could inject arbitrary HTML into the notification email the team reads internally — no authentication required, no rate limit standing in the way of a single crafted submission. A malicious applicant name could silently smuggle in a fake "click here to verify your login" link, styled to look like it belonged in the template, sitting right next to the real "Review in Supabase →" button. This is a stored HTML-injection vector aimed squarely at whoever opens the inbox — a real phishing risk against the team itself, not a hypothetical.

Code

Commit 3aed95d — specifically the lib/email-templates.ts hunk. Added a small escapeHtml() helper (standard &, <, >, ", ' entity escaping) and applied it to every user-supplied value across all six email template functions — both plain text content and values embedded inside href attributes, escaped before the attribute string is built, not after.

My Improvements

The fix required more than "wrap everything in a helper" — text-node context and attribute context aren't the same problem. A value escaped only for display text still allows a quote-breakout attack inside an href="..." (e.g. " onmouseover="...), so escaping has to happen before the attribute is constructed, not at final render.

Verified this at two levels. First, locally: ran the actual template functions against a real injection payload —

"><img src=x onerror=alert(1)><script>alert(2)</script><a href="https://evil.example/phish">click</a>
Enter fullscreen mode Exit fullscreen mode

— plus a separate quote-based attribute-breakout string, confirming none of it survived unescaped.

Then in production, end-to-end: submitted that same payload through the live site as a real application — real Cloudflare Turnstile challenge (not scripted around), real database insert, real notification email sent to the team inbox through the exact code path this fix touches. I opened the resulting email myself and confirmed the actual outcome: the <script> tag never executed, the fake <img> never attempted to load, and the injected link rendered as inert, garbled text sitting inside obviously-escaped characters — not a working "click here" button.

One honest caveat worth including rather than glossing over: in the screenshot below, the raw evil.example URL still appears blue and clickable. That's Gmail's own auto-linkification of any bare URL it finds in plain text — completely independent of the fix, and something Gmail would do to any plain-text email mentioning a URL, malicious or not. The actual thing this fix prevents — a disguised, legitimate-looking clickable button — is gone; the substring being technically linkable inside garbled text is a cosmetic side effect of Gmail's own behavior, not a gap in the fix.

Gmail inbox showing a received test email with subject line and body containing an HTML injection payload rendered as plain, garbled text with escaped characters instead of executing as markup — no script alert fired, no fake image loaded, and the injected link appears as inert text rather than a clickable button

Built and directed with Claude Code under my own review and testing — including designing the verification payloads and personally confirming the production result described above.

Top comments (0)