
The sentry at the form — when the portfolio learned to defend itself
The front door of my site
Every project has a surface everyone sees and almost nobody hardens: the public site. In my case, the portfolio. It’s static, pretty, fast — and it has two open doors facing the whole internet:
- The contact form, which takes a name, an email, and a message from anyone.
- The resume download, which asks for consent and accepts a name and email before serving the PDF.
Two HTTP endpoints that accept input from strangers. On top of that, each one fires notifications: an HTML-formatted email, a message on a channel, a record in a database. So — input from an unknown person + a template built with that input. The classic scenario where two families of bugs are born: abuse (spam, request flooding) and injection (the famous HTML/script landing inside the template).
The site is static, but those two endpoints run server functions. That’s where the portfolio stopped being just a showcase and became a potential target.
The problem: nobody was watching the front door
I already had active hunting across the seven projects in the ecosystem: ZAP, gitleaks, bandit, opengrep running in CI. But active hunting looks at code. And here the problem was the behavior of the public surface: who is knocking on the door, and what does that door do with what it receives?
The contact form, for example: any bot on the internet could send a thousand messages in a minute. Each one became a pretty email in my inbox. That’s not a leak, it’s not an RCE — it’s worse, because it doesn’t feel urgent: it’s noise. And constant noise destroys the usefulness of an alert.
And there was the template detail: the name, email, and message typed by the visitor were interpolated straight into the email’s HTML. In a browser that’s classic XSS. In an email client the risk is lower, but the principle is the same: never trust text that came from a stranger, even when it looks harmless.
The fix in three layers
1. Rate limiting — the door that closes
Each endpoint got an in-memory limiter: a short window with a low ceiling per IP. After that, it answers 429 and sends them away. Enough for a human to download a resume or send a message — and enough to choke a bot.
// short window + low ceiling of requests per IP
const rateLimit = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT_WINDOW = WINDOW; // ms
const RATE_LIMIT_MAX = CEILING; // max per window
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const entry = rateLimit.get(ip);
if (!entry || now > entry.resetAt) {
rateLimit.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
return true;
}
entry.count++;
return entry.count <= RATE_LIMIT_MAX;
}
One detail that avoids a memory leak: a cleanup setInterval runs periodically and removes expired entries. A Map that never cleans up is a leak disguised as protection.
The IP comes from a chain of proxy headers, falling back to unknown. Not bulletproof against sophisticated spoofing, but enough against the attacker we actually have: the lazy bot.
2. HTML escaping — the message that can’t carry code
After rate limiting came output handling. A small, honest function:
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
And every value interpolated into the email template — name, email, message, IP, referrer — now goes through it. No library, no framework: five replaces cover what an email template needs. The visitor’s message becomes text, not structure.
3. security.txt — the back door for people who want to report
And for someone who finds a real flaw, I created the file the industry standardized for that: /.well-known/security.txt. It’s the site’s “if you found something, talk to me” — with the contact email, the expiration date, the preferred languages, and the policy link.
Contact: mailto:your-email
Expires: (a future date)
Preferred-Languages: pt, en
Canonical: (your site's url)/.well-known/security.txt
Policy: (your terms url)
A simple convention: automated security researchers crawl the internet looking for this file to learn how to report a problem on each domain. Not having the file doesn’t stop an attacker — but having it tells the good guys there’s a reporting path.
The sentry: a scanner that watches in silence
The final step was turning all of this into a checklist that runs by itself. The portfolio gained two levels of vigilance:
- In CI, on every push: the repository’s security workflow runs gitleaks (secrets in git history), bandit (Python), and opengrep (insecure multi-language patterns) — the same trio from active hunting, now coupled to the deploy. Fail, and it doesn’t ship.
- In production, weekly: a scanner hits the public URL and checks, one by one: security headers, the presence of security.txt, attempts to reach files that shouldn’t exist, the rate limit on both endpoints (sends requests until it hits the block and confirms the 429), a path traversal attempt, and TLS certificate validity.
The scanner’s golden rule: silence is a feature. If everything is fine, it says nothing. It only speaks when it finds a problem — and then it delivers the exact list of what’s wrong, so the fix is direct.
Takeaways
- A static site has an attack surface. Two server functions are enough to turn a showcase into a target. Anything that accepts input must be treated as a border.
- Rate limiting isn’t anti-spam, it’s sanity. The goal isn’t to stop the perfect attacker — it’s to make abuse expensive enough that the system stays useful. An endpoint any bot can hammer for free isn’t an alert, it’s an open pipe.
- HTML escaping is basic hygiene. Interpolating user input into a template without escaping is the same class of mistake in any decade. Five replaces solve it — no more than that.
- Security is also courtesy. security.txt is the site telling researchers “there’s a path to report.” It costs one config line and turns chaotic reports into a process.
- An automated checklist beats memory. I wouldn’t remember to check six headers, TLS, and rate limits every week. The scanner remembers — and stays quiet when there’s nothing to say.
What’s next
- In-memory rate limiting doesn’t scale. When multiple instances are needed, migrate to a distributed store (the deploy platform offers one natively).
- SBOM and supply chain — the whole ecosystem already has this on the list; the portfolio joins in.
- ZAP active scan against a staging version before production, not just the baseline.
The front door now has a sentry: it closes when a stranger knocks too much, delivers only clean text when someone sends a message, and watches by itself so I don’t have to remember.